Python 3 易混淆点
31 个问答,集中辨析对象、函数、生成器、异常和并发中的常见面试考点。
版本:示例以 Python 3.13 为基线,涉及 3.14 的变化单独标注。每个 Python 代码块均可独立运行。
按问题查结论,不区分基础与进阶。语法查询见 Python 3 基础速查,完整机制示例见 Python 3 进阶速查。
对象与容器
1. 可变与不可变,究竟什么不能变?
答:指对象能否原地修改,不是变量名能否重新赋值。
| 类别 | 常见类型 |
|---|---|
| 可变 | list、dict、set、bytearray |
| 不可变 | int、float、bool、str、tuple、bytes、frozenset |
name = "A" 后再写 name = "B",是让名字绑定到另一个字符串,不是修改原字符串。
元组固定的是它直接保存的对象引用,内部列表仍然可以修改:
| |
record[0] = [] 则是在替换元组中的引用,会触发 TypeError。见 对象与可变性。
2. == 与 is 有什么区别?小整数缓存能当判断依据吗?
答:== 判断是否相等;is 判断是不是同一个对象。 相等规则由类型定义,不一定只是逐字段比较。
| |
- 值比较:数值、字符串等业务内容通常使用
==。 - 单例判断:判断空值使用
value is None。 - 缓存误区:整数复用、字符串驻留可能让两个名字碰巧指向同一对象;具体表现受实现、版本和编译方式影响。
不要用一次整数或字符串的 is 结果,推导值相等的通用规则。 id() 在对象存活期间标识身份,对象销毁后其标识可能被复用。
3. 字典键为什么不能是列表?元组一定能当键吗?
答:字典键和集合元素要求可哈希,不是简单要求“看起来不可变”。
- 可哈希:哈希值在对象生命周期内保持稳定;相等对象必须有相同哈希值。
- 列表、字典、普通集合:不可哈希,不能直接当键。
- 元组:只有内部所有元素都可哈希,整个元组才可哈希。
| |
哈希相同不等于对象相等,哈希冲突仍要继续判断相等性。普通自定义对象也可能按身份哈希,不能把可哈希与不可变完全画等号。见 可哈希定义。
4. 赋值、浅拷贝、深拷贝分别复制了什么?
答:赋值只是多一个名字指向原对象;浅拷贝新建外层容器,内部对象仍共享;深拷贝还会复制内部可变对象。
以订单为例:status 是字符串,items 是商品列表。复制后,分别修改状态和商品:
| |
- 赋值:
alias和order是同一份订单,不是两份副本。 - 浅拷贝:
shallow是另一个字典,所以替换status互不影响;但items列表没复制,追加商品会让原订单也看到pen。 - 深拷贝:
deep连商品列表也单独复制了,因此仍只有book。普通字典的order.copy()也是浅拷贝。
深拷贝不保证每个内部对象都重新创建,也不能复制任意文件或 socket 资源。 它还会保留副本内部原有的共享关系;是否需要它,取决于业务是否允许共享。见 copy。
5. 列表、字典、集合操作都是 O(1) 吗?
答:不是,要说清具体操作,以及平均、最坏还是摊销复杂度。 下表针对常见 CPython 实现,假设一次哈希或元素比较的成本为常数:
| 操作 | 常见复杂度 |
|---|---|
| 列表按索引读取 | O(1) |
列表尾部 append() | 摊销 O(1),扩容时可能 O(n) |
列表头部插入、pop(0) | O(n),需要移动后续元素 |
item in list | O(n) |
| 字典按键查找、集合成员判断 | 平均 O(1),最坏 O(n) |
| 取长度为 k 的列表切片 | O(k),会创建新列表 |
字典中的 x in mapping 查的是键,不是值。两端频繁进出的队列可考虑 collections.deque,不要反复使用列表 pop(0)。见 CPython 操作复杂度与 deque。
函数与作用域
6. 为什么不要用 items=[] 作为常规默认参数?
答:默认值在函数定义时求值,不是每次调用时重新创建。 多次省略这个参数,会复用同一个默认列表。
| |
为什么第二次不是 ['B']? 按执行顺序看:
- 执行
def时创建默认列表[],并把它保存在函数的默认参数中。 - 第一次调用
collect("A")没传items,使用这个列表;追加后,它变成['A']。 - 第二次调用
collect("B")也没传items,用的还是刚才的列表;继续追加,便得到['A', 'B']。
函数调用结束后,默认列表没有被清空;下次调用也不会重新执行参数位置的 []。
每次都需要新列表时,用 None 表示“没有传入”,把创建列表的语句放到函数体内:
| |
这次每次省略 items 都会进入 if,执行 items = [],因此两次调用各有自己的列表。
不要用 items = items or [] 替代上面的判断:调用方传入空列表时,它也会被换掉。见 默认参数。
7. global 与 nonlocal 有什么区别?
答:两者都能让函数内的赋值作用于外层名字,但指向的作用域不同。
global name:给当前模块的全局变量name赋值,不是修改外层函数的局部变量。nonlocal name:给最近一层外部函数中已有的变量name赋值;不能指向模块全局变量,也不能凭空创建外层变量。
只读取这些变量时,通常不需要声明。见 名称解析。
8. 循环创建的 lambda,为什么都返回最后一个值?
答:三个函数共享同一个外层变量;创建函数时没有保存当轮值,调用时才读取它。
| |
lambda: i:循环结束时,共享的i是2;之后三个函数都读取它。lambda i=i: i:右边的i在创建函数时求值,分别存为参数默认值;调用时返回各自参数。
这是闭包的晚绑定现象,不是 lambda 独有;普通嵌套函数也会遇到。默认参数保存的是对象引用,不是深拷贝快照。见 闭包常见问题。
9. *args、**kwargs、/、单独的 * 有什么区别?
答:前两者收集多余参数,后两者限制参数的传入方式。
| 定义中的写法 | 含义 |
|---|---|
*args | 收集额外位置参数,得到元组 |
**kwargs | 收集额外关键字参数,得到字典 |
/ 前的参数 | 只能按位置传入 |
单独的 * 后的参数 | 只能按关键字传入 |
| |
这里 name 不能写成关键字参数,enabled 不能按位置传入。调用时的 *values、**options 则是解包,不是收集。见 特殊参数。
10. 装饰器做了什么?wraps 又有什么用?
答:装饰器接收原函数,返回一个替代它的对象;常见做法是返回包装函数,在原函数前后增加行为。
下面为问候函数加一条调用日志,原函数本身不需要写日志逻辑:
| |
分两步理解:
- 定义时包装:
@log_call相当于定义后执行greet = log_call(greet)。原函数保存在function中,名字greet改为指向返回的wrapper。 - 调用时执行:
greet("小李")实际先进入wrapper,打印日志,再调用原函数,最后把问候语返回给外面的print()。
wraps 保留的是原函数的名称、文档等信息,不负责调用原函数。 本例删掉 @wraps(function) 后,问候功能仍能运行,但 greet.__name__ 变成 wrapper,greet.__doc__ 变成 None。
示例只包装一个 name 参数;通用包装器常用 *args, **kwargs 转交参数。本例使用闭包,但装饰器并不必须用闭包实现。见 functools.wraps。
迭代与生成器
11. 可迭代对象、迭代器、生成器是什么关系?
答:可迭代对象能提供迭代器;迭代器记住下一项读到哪里;生成器是一种自动保存执行进度的迭代器。
先看列表与迭代器: 列表保存任务内容;iter(tasks) 创建一个读取它的迭代器,next() 每次取下一项。
| |
tasks是可迭代对象,但不是迭代器:不能直接next(tasks),要先iter(tasks)。iterator是迭代器:第一次取完“整理需求”,它记住位置,下一次继续取“编写代码”。读完后不会自动回到开头。
再看生成器: 不先写出整个列表,而是用 yield 描述每一步要交出什么。
| |
make_tasks 是生成器函数,调用后得到的 generator 才是生成器对象。每次 next() 运行到一个 yield 并暂停,下次从暂停处继续,不从函数开头重跑。
生成器是迭代器,迭代器也可迭代;但列表可迭代,不代表列表本身就是迭代器。
读完之后:不带默认值的 next() 会抛出 StopIteration;for 自动处理这个结束信号。列表可以重新 iter(tasks) 从头读,但同一个迭代器或生成器不能自动重置。见 迭代器协议。
12. 列表推导式与生成器表达式,差别只是括号吗?
答:列表推导式立即构建结果列表;生成器表达式按需计算,结果流通常只能消费一次。
| |
- 需要索引、长度或多次遍历:数据量允许时使用列表。
- 逐项处理、可能提前结束:可使用生成器,避免预先保存全部结果。
- 不能过度推断:惰性不保证更快、零内存或不持有大对象;保留的状态和资源同样占用内存。
生成器表达式也不是“创建时什么都不执行”:最左侧 for 的可迭代表达式会立即求值,并立即取得迭代器。见 生成器表达式。
13. yield 与 yield from 的区别是什么?
答:yield value 把这个值整体交出去;yield from iterable 把其中的元素逐项交出去。 比如,一次交出“整份任务清单”,还是“一条任务”?
| |
yield:只交出 1 项,这一项是整个列表,所以外面的for只执行一次,打印列表。yield from:依次交出 2 项,分别是两个字符串,所以外面的for执行两次,每次打印一条任务。
仅就上例逐项交出元素而言,也可以用 for 循环逐项 yield。但完整语义不只是循环简写,它还支持对子生成器的 send() 等操作进行委托。
yield from 接受可迭代对象,不只接受生成器;它不会自动递归展开任意嵌套。 完整委托规则见 PEP 380。
14. next() 与生成器的 send() 有什么区别?
答:next() 让生成器继续执行;send(value) 在继续执行的同时,还向上次暂停的位置传入一个值。 用“提出问题,再收到答案”来理解:
| |
- 先提出问题:
next(conversation)执行到yield "你叫什么名字?",把问题交给外部并暂停,此时name还没赋值。 - 再收到答案:
send("小明")把“小明”送回暂停处,完成name = "小明",然后继续执行到下一个yield,把“你好,小明!”交给外面的print()。
yield 后面的内容是向外发出的值;send() 传入的内容是恢复执行时收到的值。两者不是一回事。
对生成器,next(generator) 相当于 generator.send(None);本例第二次如果也用 next(),name 收到的就是 None,而不是姓名。
先启动,再发送答案:刚创建时要先用 next() 或 send(None),不能直接发送“小明”。见 生成器方法。
表达式与数据
15. eval() 与 json.loads() 能互相替代吗?
答:不能。一个求值 Python 表达式,一个解析 JSON 数据。
| 方法 | 用途 | 关键边界 |
|---|---|---|
eval() | 求值 Python 表达式 | 可调用函数、执行代码,不能用于不可信输入 |
json.loads() | JSON 文本转 Python 对象 | 不执行 Python 表达式;结果不一定是字典 |
ast.literal_eval() | 解析受限的 Python 字面量和容器 | 不支持任意表达式,也不是无条件安全的解析器 |
| |
- 语法不同:JSON 字符串和对象键使用双引号,布尔和空值写
true、false、null;Python 写True、False、None。 - 用途不同:外部 JSON 数据使用 JSON 解析器;已知目标类型时使用
int()、float()等,不要绕到eval()。 - 安全边界:JSON 仍需限制输入大小并校验结构;
literal_eval()对恶意输入也可能耗尽内存、CPU 或栈。
“不执行 Python 表达式”不等于“任意输入都安全”。 见 eval、json与 literal_eval。
16. 为什么 0.1 + 0.2 != 0.3?换成 Decimal 就全精确了吗?
答:许多十进制小数不能被二进制浮点数精确表示;Decimal 也有运算精度和舍入规则。
| |
- 近似比较:
math.isclose()根据相对容差和绝对容差判断是否足够接近;接近零时尤其要按业务设置abs_tol。 - 十进制计算:优先从字符串构造
Decimal,避免先把float的误差带进去。 - 仍需规则:无限循环小数、除法和金额取舍仍涉及精度与舍入;不能承诺所有结果无限精确。
见 浮点数说明、math.isclose与 decimal。
17. if not value 与 value is None 是一回事吗?
答:不是。前者判断假值,后者只判断是否为 None。
None、False、数值零和空容器通常都是假值;自定义对象还可以通过 __bool__() 或 __len__() 定义真值。
| |
当 0、空字符串或空列表也是合法输入时,不要用 if not value 统统当作“未传入”。见 真值测试。
18. and、or 的返回值一定是布尔值吗?
答:不一定;它们短路求值,返回某个操作数本身。 not 才总是返回布尔值。
| |
a or b:a为真就返回a,否则才求值并返回b。a and b:a为假就返回a,否则才求值并返回b。
因此 value or default 会替换所有假值,不只替换 None;&、| 是另一组运算符,不能随意拿来替代短路逻辑。见 布尔运算。
19. list.sort() 与 sorted() 有什么区别?排序稳定是什么意思?
答:sort() 原地修改列表并返回 None;sorted() 接受可迭代对象,返回新列表。
| |
稳定排序指排序键相等的元素,保持原来的相对次序。两者都稳定,也都支持 key=;不是说“无论输入怎样,整个输出顺序都不变”。见 排序指南。
20. str 与 bytes 有什么区别?编码与解码怎么记?
答:str 表示 Unicode 文本,bytes 表示字节序列;编码是文本转字节,解码是字节转文本。
| |
Python 3 的 str 没有 .decode();文件或网络读到的字节则可以按对应编码解码。len(str) 数的是 Unicode 码点,不是字节数,也不保证等于屏幕上看到的字符个数。见 Unicode HOWTO。
类与异常
21. 类属性与实例属性有什么区别?为什么多个实例共用一个列表?
答:类属性放在类上;实例属性属于具体实例。 把可变默认数据直接放在类体里,多个实例可能通过类属性访问到同一个对象。
| |
这里 append() 修改共享列表,赋值则为 first 建立同名实例属性。需要每个实例独立的列表时,在 __init__() 中写 self.items = []。 见 类变量与实例变量。
22. __new__() 与 __init__() 都是构造函数吗?
答:__new__() 负责创建或取得实例,__init__() 负责初始化实例。
__new__(cls, ...):先执行,通常创建新实例,也可以返回已有实例;定制不可变类型的子类时可能需要它。__init__(self, ...):在适当的实例创建后执行,设置属性;必须返回None,不能返回另一个对象。- 容易漏掉的条件:如果
__new__()返回的不是所请求类的实例,就不会按通常流程调用该类的__init__()。
日常业务类通常只实现 __init__(),不必为了“完整”同时重写两个方法。见 对象创建与初始化。
23. super() 就是“调用父类”吗?MRO 是什么?
答:super() 沿 MRO 查找下一个实现,不一定是源码里直觉上的某个父类。 MRO 是方法解析顺序。
例如 B、C 都继承 A,D 按 B, C 的顺序继承两者,常见菱形结构的顺序是:D → B → C → A → object。
- 在这个结构中,
B的协作式方法调用super(),下一站可能是C,而不是A。 - 参与同一调用链的方法应使用兼容的参数,并协作调用
super();不要随意混入写死类名的调用,导致跳过或重复执行。 - Python 使用 C3 方法解析顺序;无法形成一致顺序的继承关系,会在建类时出错。
继承不是代码复用的唯一方式:对象“拥有另一个对象”时,组合通常更直接。见 super与 多重继承。
24. @dataclass(frozen=True) 就彻底不可变了吗?类型标注会自动校验吗?
答:都不是。frozen=True 阻止常规字段赋值,不会递归冻结字段指向的对象;类型标注通常不做运行时校验。
| |
config.ports = []是替换字段,会触发FrozenInstanceError;append()修改的是内部列表,所以仍能执行。list[int]表达预期类型,不会自动拦截所有错误数据;外部输入仍需显式验证或使用验证工具。- 数据类主要减少
__init__()、__repr__()、相等比较等重复代码;它不是自动的数据校验框架。
25. try、except、else、finally 按什么顺序执行?
答:try 放可能失败的操作,except 处理匹配异常,else 处理正常完成,finally 做退出前清理。
| |
else:try正常执行到末尾时运行;异常或提前return、break、continue都会跳过它。finally:正常离开、返回或异常传播前都会执行,但进程被强制终止等情况不在这套正常控制流保证内。- 异常捕获:尽量捕获具体异常;裸
except:还会接住KeyboardInterrupt、SystemExit等,不适合一概吞掉。
不要在 finally 中 return:它可能覆盖原返回值,甚至压掉原异常。 Python 3.14 还会对相关跳出 finally 的控制流发出 SyntaxWarning。见 try 语义与 3.14 变化。
26. with 只是自动关闭文件吗?会不会吞异常?
答:with 使用上下文管理协议;做什么清理、是否抑制异常,由具体管理器决定。
- 进入:调用
__enter__(),它的返回值交给as后面的变量。 - 退出:
__enter__()成功后,离开代码块时调用__exit__(),并传入异常信息。 - 异常:
__exit__()返回真值可以抑制块内异常;不是所有with都会吞异常。
文件管理器会关闭文件,锁管理器会释放锁;数据库事务管理器则可能只提交或回滚,不一定关闭连接。
用 @contextmanager 简化实现时,通常编写一个正常路径恰好 yield 一次的生成器函数,不是给任意普通函数加装饰器就能使用。见 官方 with 语句说明与 contextmanager。
27. 能用 assert 校验用户输入或权限吗?
答:不能把 assert 当成必须执行的校验。 使用 python -O 时,断言及其表达式可能被移除。
- 内部假设检查:开发阶段可以使用断言,帮助尽早暴露程序逻辑错误。
- 必需的业务校验:明确写条件判断,并使用
raise ValueError(...)等异常报告问题。 - 副作用:不要把必须执行的写入、函数调用或状态变更放在断言表达式里。
测试框架有自己的断言机制,也不能据此推导线上 assert 永远有效。见 assert 语义。
运行时与并发
28. del 会立即释放对象吗?循环引用一定导致内存泄漏吗?
答:del name 删除名字绑定,不等于直接销毁对象;循环引用也不等于必然泄漏。
| |
alias 还引用这个列表,所以它仍然可用。
- 常规 CPython:主要使用引用计数,并由循环垃圾检测处理引用环中的垃圾;仅靠引用计数无法处理所有环。
- 不要依赖时机:何时回收不是 Python 语言统一保证;调试器、全局容器等还可能继续持有引用。
- 资源另行管理:内存回收与文件、连接释放不是一回事,资源应使用
with或显式关闭。
见 垃圾回收接口。本题不要求背诵某个补丁版本的具体分代数量。
29. 有 GIL 为什么还要加锁?Python 3.13 以后没有 GIL 了吗?
答:GIL 不是业务原子性保证;升级版本也不代表默认关闭 GIL。
- 常规启用 GIL 的 CPython:同一解释器内,同一时刻通常只有一个线程执行 Python 字节码;I/O 等待和释放 GIL 的扩展计算是不同情况。
- 业务同步:检查库存再扣减等多步操作仍需保护;不要把
count += 1或某次实验结果当作跨版本线程安全保证。 - 版本变化:3.13 提供实验性 free-threaded 构建;3.14 正式支持,但仍是可选构建,依赖库兼容性需要核对。
有无 GIL,都要根据共享数据的操作边界决定是否同步。 见 线程与 GIL与 free-threaded 支持状态。
30. 线程、进程、协程怎么选?多进程会共享全局变量吗?
答:先判断瓶颈和接口类型,再选择并发方式;普通进程的全局变量不会自动同步。
| 场景 | 通常考虑 | 主要代价 |
|---|---|---|
| 阻塞 I/O 等待 | 线程池 | 共享状态同步、线程调度 |
| 可拆分的纯 Python CPU 计算 | 默认 CPython 下的进程池 | 启动、序列化、进程间通信 |
| 大量使用异步接口的 I/O 等待 | asyncio | 不能长时间阻塞事件循环 |
并发不等于并行,也不保证更快。 小任务可能被调度成本拖慢;会释放 GIL 的扩展计算也不必一概使用多进程。
进程启动方式还影响初始状态: spawn 启动新解释器,fork 从当前进程派生,forkserver 从专用服务器派生;无论哪种,子进程修改普通对象都不会自动更新父进程。
Windows、macOS 默认使用 spawn;Linux 等支持平台在 3.13 通常默认 fork,3.14 改为 forkserver。显式指定方式,统一的是启动机制,不是各平台性能。任务数据优先通过参数和返回值交换。见 进程启动方式。
31. 写了 async def 和 await,任务就自动并发了吗?
答:没有。调用协程函数只得到协程对象;连续等待两个调用,通常仍是先后执行。
要让多个任务有机会并发推进,先调度它们。例如 Python 3.11 起可以使用 TaskGroup:
| |
await不是强制切换开关:所等待对象实际挂起时,事件循环才有机会推进别的任务。- 阻塞操作不会自动变异步:在事件循环中执行
time.sleep()、阻塞网络调用或长时间计算,仍会卡住其他任务。 - 运行环境:脚本用
asyncio.run();已有事件循环的交互环境通常直接await main(),不要嵌套启动。
见 协程与任务。完整并发实践留给独立文章,本篇只保留面试辨析要点。
评论
使用 GitHub 登录后参与讨论,评论将公开保存在 GitHub。
站长管理删除会同步到 GitHub,无法撤销。
阅读到这里时加载评论。