跳至内容
Python 3 易混淆点

Python 3 易混淆点

31 个问答,集中辨析对象、函数、生成器、异常和并发中的常见面试考点。

  • Python 3
  • 面试复习
  • 易混淆点
  • Python
  • 生成器
  • 变量

版本:示例以 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",是让名字绑定到另一个字符串,不是修改原字符串。

元组固定的是它直接保存的对象引用,内部列表仍然可以修改:

代码块 Python
1
2
3
4
5
record = ([1, 2], "fixed")
record[0].append(3)
print(record)

# 输出:([1, 2, 3], 'fixed')

record[0] = [] 则是在替换元组中的引用,会触发 TypeError。见 对象与可变性。

2. == 与 is 有什么区别?小整数缓存能当判断依据吗?

答:== 判断是否相等;is 判断是不是同一个对象。 相等规则由类型定义,不一定只是逐字段比较。

代码块 Python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
left = [1, 2]
right = [1, 2]
alias = left
print(left == right)
print(left is right)
print(left is alias)

# 输出:
# True
# False
# True
  • 值比较:数值、字符串等业务内容通常使用 ==。
  • 单例判断:判断空值使用 value is None。
  • 缓存误区:整数复用、字符串驻留可能让两个名字碰巧指向同一对象;具体表现受实现、版本和编译方式影响。

不要用一次整数或字符串的 is 结果,推导值相等的通用规则。 id() 在对象存活期间标识身份,对象销毁后其标识可能被复用。

3. 字典键为什么不能是列表?元组一定能当键吗?

答:字典键和集合元素要求可哈希,不是简单要求“看起来不可变”。

  • 可哈希:哈希值在对象生命周期内保持稳定;相等对象必须有相同哈希值。
  • 列表、字典、普通集合:不可哈希,不能直接当键。
  • 元组:只有内部所有元素都可哈希,整个元组才可哈希。
代码块 Python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
mapping = {(1, 2): "ok"}
print(mapping[(1, 2)])

try:
    hash(([1, 2],))
except TypeError as error:
    print(f"TypeError: {error}")

# 输出:
# ok
# TypeError: unhashable type: 'list'

哈希相同不等于对象相等,哈希冲突仍要继续判断相等性。普通自定义对象也可能按身份哈希,不能把可哈希与不可变完全画等号。见 可哈希定义。

4. 赋值、浅拷贝、深拷贝分别复制了什么?

答:赋值只是多一个名字指向原对象;浅拷贝新建外层容器,内部对象仍共享;深拷贝还会复制内部可变对象。

以订单为例:status 是字符串,items 是商品列表。复制后,分别修改状态和商品:

代码块 Python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import copy

order = {"status": "new", "items": ["book"]}
alias = order
shallow = copy.copy(order)
deep = copy.deepcopy(order)

# 改浅拷贝字典里的状态,不会改原字典的状态。
shallow["status"] = "paid"
# 但两份字典里的 items 仍指向同一个列表。
shallow["items"].append("pen")

print(alias is order)
print(order)
print(shallow)
print(deep)

# 输出:
# True
# {'status': 'new', 'items': ['book', 'pen']}
# {'status': 'paid', 'items': ['book', 'pen']}
# {'status': 'new', 'items': ['book']}
  • 赋值: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 listO(n)
字典按键查找、集合成员判断平均 O(1),最坏 O(n)
取长度为 k 的列表切片O(k),会创建新列表

字典中的 x in mapping 查的是键,不是值。两端频繁进出的队列可考虑 collections.deque,不要反复使用列表 pop(0)。见 CPython 操作复杂度与 deque。

函数与作用域

6. 为什么不要用 items=[] 作为常规默认参数?

答:默认值在函数定义时求值,不是每次调用时重新创建。 多次省略这个参数,会复用同一个默认列表。

代码块 Python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def collect(value, items=[]):
    items.append(value)
    return items


print(collect("A"))
print(collect("B"))

# 输出:
# ['A']
# ['A', 'B']

为什么第二次不是 ['B']? 按执行顺序看:

  1. 执行 def 时创建默认列表 [],并把它保存在函数的默认参数中。
  2. 第一次调用 collect("A") 没传 items,使用这个列表;追加后,它变成 ['A']。
  3. 第二次调用 collect("B") 也没传 items,用的还是刚才的列表;继续追加,便得到 ['A', 'B']。

函数调用结束后,默认列表没有被清空;下次调用也不会重新执行参数位置的 []。

每次都需要新列表时,用 None 表示“没有传入”,把创建列表的语句放到函数体内:

代码块 Python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
def collect(value, items=None):
    if items is None:
        items = []
    items.append(value)
    return items


print(collect("A"))
print(collect("B"))

# 输出:
# ['A']
# ['B']

这次每次省略 items 都会进入 if,执行 items = [],因此两次调用各有自己的列表。

不要用 items = items or [] 替代上面的判断:调用方传入空列表时,它也会被换掉。见 默认参数。

7. global 与 nonlocal 有什么区别?

答:两者都能让函数内的赋值作用于外层名字,但指向的作用域不同。

  • global name:给当前模块的全局变量 name 赋值,不是修改外层函数的局部变量。
  • nonlocal name:给最近一层外部函数中已有的变量 name 赋值;不能指向模块全局变量,也不能凭空创建外层变量。

只读取这些变量时,通常不需要声明。见 名称解析。

8. 循环创建的 lambda,为什么都返回最后一个值?

答:三个函数共享同一个外层变量;创建函数时没有保存当轮值,调用时才读取它。

代码块 Python
1
2
3
4
5
6
7
8
late = [lambda: i for i in range(3)]
bound = [lambda i=i: i for i in range(3)]
print([function() for function in late])
print([function() for function in bound])

# 输出:
# [2, 2, 2]
# [0, 1, 2]
  • lambda: i:循环结束时,共享的 i 是 2;之后三个函数都读取它。
  • lambda i=i: i:右边的 i 在创建函数时求值,分别存为参数默认值;调用时返回各自参数。

这是闭包的晚绑定现象,不是 lambda 独有;普通嵌套函数也会遇到。默认参数保存的是对象引用,不是深拷贝快照。见 闭包常见问题。

9. *args、**kwargs、/、单独的 * 有什么区别?

答:前两者收集多余参数,后两者限制参数的传入方式。

定义中的写法含义
*args收集额外位置参数,得到元组
**kwargs收集额外关键字参数,得到字典
/ 前的参数只能按位置传入
单独的 * 后的参数只能按关键字传入
代码块 Python
1
2
3
4
5
6
7
def describe(name, /, *, enabled=True):
    return f"{name}: {enabled}"


print(describe("worker", enabled=False))

# 输出:worker: False

这里 name 不能写成关键字参数,enabled 不能按位置传入。调用时的 *values、**options 则是解包,不是收集。见 特殊参数。

10. 装饰器做了什么?wraps 又有什么用?

答:装饰器接收原函数,返回一个替代它的对象;常见做法是返回包装函数,在原函数前后增加行为。

下面为问候函数加一条调用日志,原函数本身不需要写日志逻辑:

代码块 Python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from functools import wraps


def log_call(function):
    @wraps(function)
    def wrapper(name):
        print("正在生成问候语")
        return function(name)
    return wrapper


@log_call
def greet(name):
    """生成问候语。"""
    return f"你好,{name}!"


print(greet("小李"))
print(greet.__name__)
print(greet.__doc__)

# 输出:
# 正在生成问候语
# 你好,小李!
# greet
# 生成问候语。

分两步理解:

  1. 定义时包装:@log_call 相当于定义后执行 greet = log_call(greet)。原函数保存在 function 中,名字 greet 改为指向返回的 wrapper。
  2. 调用时执行:greet("小李") 实际先进入 wrapper,打印日志,再调用原函数,最后把问候语返回给外面的 print()。

wraps 保留的是原函数的名称、文档等信息,不负责调用原函数。 本例删掉 @wraps(function) 后,问候功能仍能运行,但 greet.__name__ 变成 wrapper,greet.__doc__ 变成 None。

示例只包装一个 name 参数;通用包装器常用 *args, **kwargs 转交参数。本例使用闭包,但装饰器并不必须用闭包实现。见 functools.wraps。

迭代与生成器

11. 可迭代对象、迭代器、生成器是什么关系?

答:可迭代对象能提供迭代器;迭代器记住下一项读到哪里;生成器是一种自动保存执行进度的迭代器。

先看列表与迭代器: 列表保存任务内容;iter(tasks) 创建一个读取它的迭代器,next() 每次取下一项。

代码块 Python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
tasks = ["整理需求", "编写代码"]
iterator = iter(tasks)
print(next(iterator))
print(next(iterator))
print(next(iterator, "任务已读完"))

# 输出:
# 整理需求
# 编写代码
# 任务已读完
  • tasks 是可迭代对象,但不是迭代器:不能直接 next(tasks),要先 iter(tasks)。
  • iterator 是迭代器:第一次取完“整理需求”,它记住位置,下一次继续取“编写代码”。读完后不会自动回到开头。

再看生成器: 不先写出整个列表,而是用 yield 描述每一步要交出什么。

代码块 Python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
def make_tasks():
    yield "整理需求"
    yield "编写代码"


generator = make_tasks()
print(next(generator))
print(next(generator))
print(next(generator, "任务已读完"))

# 输出:
# 整理需求
# 编写代码
# 任务已读完

make_tasks 是生成器函数,调用后得到的 generator 才是生成器对象。每次 next() 运行到一个 yield 并暂停,下次从暂停处继续,不从函数开头重跑。

生成器是迭代器,迭代器也可迭代;但列表可迭代,不代表列表本身就是迭代器。

读完之后:不带默认值的 next() 会抛出 StopIteration;for 自动处理这个结束信号。列表可以重新 iter(tasks) 从头读,但同一个迭代器或生成器不能自动重置。见 迭代器协议。

12. 列表推导式与生成器表达式,差别只是括号吗?

答:列表推导式立即构建结果列表;生成器表达式按需计算,结果流通常只能消费一次。

代码块 Python
1
2
3
4
5
6
7
8
9
squares = (number ** 2 for number in range(3))
print(next(squares))
print(list(squares))
print(list(squares))

# 输出:
# 0
# [1, 4]
# []
  • 需要索引、长度或多次遍历:数据量允许时使用列表。
  • 逐项处理、可能提前结束:可使用生成器,避免预先保存全部结果。
  • 不能过度推断:惰性不保证更快、零内存或不持有大对象;保留的状态和资源同样占用内存。

生成器表达式也不是“创建时什么都不执行”:最左侧 for 的可迭代表达式会立即求值,并立即取得迭代器。见 生成器表达式。

13. yield 与 yield from 的区别是什么?

答:yield value 把这个值整体交出去;yield from iterable 把其中的元素逐项交出去。 比如,一次交出“整份任务清单”,还是“一条任务”?

代码块 Python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
def task_list():
    yield ["整理需求", "编写代码"]


def each_task():
    yield from ["整理需求", "编写代码"]


print("使用 yield:")
for item in task_list():
    print(item)

print("使用 yield from:")
for item in each_task():
    print(item)

# 输出:
# 使用 yield:
# ['整理需求', '编写代码']
# 使用 yield from:
# 整理需求
# 编写代码
  • yield:只交出 1 项,这一项是整个列表,所以外面的 for 只执行一次,打印列表。
  • yield from:依次交出 2 项,分别是两个字符串,所以外面的 for 执行两次,每次打印一条任务。

仅就上例逐项交出元素而言,也可以用 for 循环逐项 yield。但完整语义不只是循环简写,它还支持对子生成器的 send() 等操作进行委托。

yield from 接受可迭代对象,不只接受生成器;它不会自动递归展开任意嵌套。 完整委托规则见 PEP 380。

14. next() 与生成器的 send() 有什么区别?

答:next() 让生成器继续执行;send(value) 在继续执行的同时,还向上次暂停的位置传入一个值。 用“提出问题,再收到答案”来理解:

代码块 Python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
def ask_name():
    name = yield "你叫什么名字?"
    yield f"你好,{name}!"


conversation = ask_name()
print(next(conversation))
print(conversation.send("小明"))

# 输出:
# 你叫什么名字?
# 你好,小明!
  1. 先提出问题:next(conversation) 执行到 yield "你叫什么名字?",把问题交给外部并暂停,此时 name 还没赋值。
  2. 再收到答案: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 字面量和容器不支持任意表达式,也不是无条件安全的解析器
代码块 Python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import ast
import json

# 这里只演示写死的可信表达式,不接收用户输入。
print(eval("1 + 2"))
print(json.loads('{"enabled": true, "note": null}'))
print(ast.literal_eval("[1, (2, 3)]"))

# 输出:
# 3
# {'enabled': True, 'note': None}
# [1, (2, 3)]
  • 语法不同: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 也有运算精度和舍入规则。

代码块 Python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import math
from decimal import Decimal

print(0.1 + 0.2 == 0.3)
print(math.isclose(0.1 + 0.2, 0.3))
print(Decimal("0.1") + Decimal("0.2") == Decimal("0.3"))

# 输出:
# False
# True
# True
  • 近似比较:math.isclose() 根据相对容差和绝对容差判断是否足够接近;接近零时尤其要按业务设置 abs_tol。
  • 十进制计算:优先从字符串构造 Decimal,避免先把 float 的误差带进去。
  • 仍需规则:无限循环小数、除法和金额取舍仍涉及精度与舍入;不能承诺所有结果无限精确。

见 浮点数说明、math.isclose与 decimal。

17. if not value 与 value is None 是一回事吗?

答:不是。前者判断假值,后者只判断是否为 None。

None、False、数值零和空容器通常都是假值;自定义对象还可以通过 __bool__() 或 __len__() 定义真值。

代码块 Python
1
2
3
4
5
6
7
value = 0
print(not value)
print(value is None)

# 输出:
# True
# False

当 0、空字符串或空列表也是合法输入时,不要用 if not value 统统当作“未传入”。见 真值测试。

18. and、or 的返回值一定是布尔值吗?

答:不一定;它们短路求值,返回某个操作数本身。 not 才总是返回布尔值。

代码块 Python
1
2
3
4
5
6
7
8
print("" or "默认值")
print(0 and "不会取到这里")
print("已启用" and 7)

# 输出:
# 默认值
# 0
# 7
  • a or b:a 为真就返回 a,否则才求值并返回 b。
  • a and b:a 为假就返回 a,否则才求值并返回 b。

因此 value or default 会替换所有假值,不只替换 None;&、| 是另一组运算符,不能随意拿来替代短路逻辑。见 布尔运算。

19. list.sort() 与 sorted() 有什么区别?排序稳定是什么意思?

答:sort() 原地修改列表并返回 None;sorted() 接受可迭代对象,返回新列表。

代码块 Python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
values = [3, 1, 2]
ordered = sorted(values)
print(values)
print(ordered)
print(values.sort())
print(values)

# 输出:
# [3, 1, 2]
# [1, 2, 3]
# None
# [1, 2, 3]

稳定排序指排序键相等的元素,保持原来的相对次序。两者都稳定,也都支持 key=;不是说“无论输入怎样,整个输出顺序都不变”。见 排序指南。

20. str 与 bytes 有什么区别?编码与解码怎么记?

答:str 表示 Unicode 文本,bytes 表示字节序列;编码是文本转字节,解码是字节转文本。

代码块 Python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
text = "你好"
data = text.encode("utf-8")
print(len(text))
print(len(data))
print(data.decode("utf-8"))

# 输出:
# 2
# 6
# 你好

Python 3 的 str 没有 .decode();文件或网络读到的字节则可以按对应编码解码。len(str) 数的是 Unicode 码点,不是字节数,也不保证等于屏幕上看到的字符个数。见 Unicode HOWTO。

类与异常

21. 类属性与实例属性有什么区别?为什么多个实例共用一个列表?

答:类属性放在类上;实例属性属于具体实例。 把可变默认数据直接放在类体里,多个实例可能通过类属性访问到同一个对象。

代码块 Python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
class Bag:
    items = []


first = Bag()
second = Bag()
first.items.append("A")
print(second.items)

# 为 first 创建同名实例属性;不会替换类上的 items。
first.items = ["B"]
print(first.items)
print(second.items)

# 输出:
# ['A']
# ['B']
# ['A']

这里 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 阻止常规字段赋值,不会递归冻结字段指向的对象;类型标注通常不做运行时校验。

代码块 Python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
from dataclasses import dataclass, field


@dataclass(frozen=True)
class Config:
    # 每个实例单独创建一个列表。
    ports: list[int] = field(default_factory=list)


config = Config()
config.ports.append(8000)
print(config.ports)

# 输出:[8000]
  • config.ports = [] 是替换字段,会触发 FrozenInstanceError;append() 修改的是内部列表,所以仍能执行。
  • list[int] 表达预期类型,不会自动拦截所有错误数据;外部输入仍需显式验证或使用验证工具。
  • 数据类主要减少 __init__()、__repr__()、相等比较等重复代码;它不是自动的数据校验框架。

见 dataclasses与 类型标注的运行时边界。

25. try、except、else、finally 按什么顺序执行?

答:try 放可能失败的操作,except 处理匹配异常,else 处理正常完成,finally 做退出前清理。

代码块 Python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
def parse(text):
    try:
        value = int(text)
    except ValueError:
        print("转换失败")
    else:
        print(value + 1)
    finally:
        print("清理")


parse("2")
parse("x")

# 输出:
# 3
# 清理
# 转换失败
# 清理
  • 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 删除名字绑定,不等于直接销毁对象;循环引用也不等于必然泄漏。

代码块 Python
1
2
3
4
5
6
data = [1]
alias = data
del data
print(alias)

# 输出:[1]

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:

代码块 Python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import asyncio


async def label(name):
    # 仅模拟异步等待,不访问网络,也不做性能测试。
    await asyncio.sleep(0.01)
    return name


async def main():
    async with asyncio.TaskGroup() as group:
        first = group.create_task(label("A"))
        second = group.create_task(label("B"))
    # 两项均已完成,按固定顺序读取结果。
    print([first.result(), second.result()])


asyncio.run(main())

# 输出:['A', 'B']
  • await 不是强制切换开关:所等待对象实际挂起时,事件循环才有机会推进别的任务。
  • 阻塞操作不会自动变异步:在事件循环中执行 time.sleep()、阻塞网络调用或长时间计算,仍会卡住其他任务。
  • 运行环境:脚本用 asyncio.run();已有事件循环的交互环境通常直接 await main(),不要嵌套启动。

见 协程与任务。完整并发实践留给独立文章,本篇只保留面试辨析要点。

评论

使用 GitHub 登录后参与讨论,评论将公开保存在 GitHub。

阅读到这里时加载评论。