# Python面试题

大家好,我是小林。

Python 写起来很简洁,但准备面试时,光会写代码还不够。列表切片后为什么还可能影响原数据,可变默认参数为什么会被多次调用共享,有 GIL 为什么还需要加锁,这些问题都需要沿着对象引用和执行过程去理解。

这篇文章整理了 Python 面试中值得系统准备的知识点,从语言基础、List/Dict、函数和面向对象,到多线程、asyncio、内存管理与 CPython 执行原理。

准备 Python 后端或 Agent 开发岗位时,也可以重点看工具调用中的并发、超时和资源清理。

有几块内容建议重点花时间:

  • 对象引用与容器:变量绑定、函数传参、浅拷贝和深拷贝,以及 List、Dict 的底层结构。把引用关系看清楚,很多看似奇怪的代码行为就能解释通了。
  • 函数与面向对象:默认参数、作用域、闭包、装饰器、方法绑定和 MRO。这些机制会影响代码怎样保存状态、查找变量和调用方法。
  • GIL 与异步编程:线程、进程和协程的选择,事件循环怎样调度任务,以及超时、取消和并发控制怎样配合。涉及多个工具或外部接口时,这部分尤其值得掌握。
  • 内存管理与垃圾回收:引用计数、循环引用、对象生命周期,以及缓存和后台任务为什么可能留住大量内存。

如果是第一次系统准备 Python 面试,建议先把对象引用、容器和函数吃透,再看面向对象、并发与内存管理。

版本说明:语言语义按Python 3说明,代码示例以Python 3.11及以上为基线;涉及解释器内部实现时明确限定为CPython。需要特定版本的特性,以及不同版本或构建方式之间的差异,会在对应题目中单独说明。


# 1. Python基础与对象模型面试题

# 1.1 Python是动态类型语言,也是强类型语言,这两个概念有什么区别?

动态类型和强类型讨论的是不同问题:

  • 动态类型:变量不必提前声明固定类型,运行时可以绑定不同类型的对象。一个名字先指向整数、后来指向字符串,是换了绑定,对象自身的类型没有因此改变。
  • 强类型:运算要遵守类型规则,Python 不会为了让运算成功,就把不兼容的类型随意转换。例如字符串与整数直接相加会报错,需要显式转换。

Python 同时具备这两个特点。强类型也允许语言规定的隐式转换,比如整数和浮点数可以一起运算。

# 1.2 如何理解Python中“一切皆对象”?变量保存的是值还是对象引用?

Python 的整数、字符串、列表是对象,函数、类、模块也是对象。对象具有身份、类型和值,变量名则通过引用与对象建立绑定;id() 和 type() 分别用于查看身份与类型。

执行 b = a 后,两个名字引用同一个对象,没有复制对象。假设它们指向列表 [1, 2]:通过 b 追加元素,a 也能看到,因为改动发生在共享列表上;如果执行 b = [9],则只让 b 指向新列表,a 仍然引用原列表。这就是修改对象与重新绑定名字的区别。

# 1.3 Python中is和==有什么区别?

区别在于比较对象身份还是相等关系:

运算 判断内容 比较规则
is 是否为同一个对象 直接比较身份,不调用 __eq__()
== 两个对象是否相等 按类型的相等规则比较,可由 __eq__() 定义

两个分别创建的列表都包含 [1, 2],可以 == 为真、is 为假。比较数字、字符串或容器内容应使用 ==;小整数缓存和字符串驻留偶尔会让相等的值共用一个对象,但这些优化不能替代值比较。判断 None 则通常使用 is None,因为要确认它是否为那个单例对象。

# 1.4 什么是可变对象和不可变对象?常见的类型分别有哪些?

可变对象可以在身份不变的情况下修改内容;不可变对象创建后,不能修改自身内容。

  • 常见可变类型有 list、dict、set、bytearray。列表追加元素后仍是原列表,只是内容发生变化。
  • 常见不可变类型有 int、float、bool、str、bytes、tuple、frozenset。字符串拼接后再赋值,是把名字绑定到结果,原字符串没有改变。

元组的不可变性约束它保存的成员引用。如果某个成员是列表,元组不能换掉这个成员,但列表自身仍然可以修改。

# 1.5 Python函数传参是值传递还是引用传递?为什么修改列表会影响调用方?

Python 使用对象共享传参,也可以理解为传递对象引用的值。进入函数时,形参获得自己的局部绑定,同时与调用方的变量引用同一个实参对象。

这能解释列表的两种行为:函数执行追加、删除或修改元素时,改的是共享列表,调用方能看到结果;函数把形参赋值为另一列表时,只改了局部绑定,调用方的变量仍指向原对象。它与 C++ 中可以直接改变调用方变量的引用参数不同。

整数、字符串也遵循同一传参规则,只是它们不能原地修改。对形参做运算并重新赋值,通常只改变函数里的绑定;要让调用方拿到新值,需要通过返回值传回。

# 1.6 赋值、浅拷贝和深拷贝有什么区别?

可以把一个嵌套列表分成外层容器和内层对象来看:

  • 赋值:外层都不复制,两个名字指向同一个列表。
  • 浅拷贝:创建新的外层列表,内部仍引用原来的成员对象。列表的 copy()、切片和 copy.copy() 都是常用方式。
  • 深拷贝:递归复制内部需要复制的对象,让嵌套的可变数据通常也能独立,常用 copy.deepcopy()。

因此,浅拷贝后对新列表追加一行,原列表不会增加这一行;修改已有行里的元素,双方仍然能看到,因为这一行还是共享对象。深拷贝则会把这类嵌套列表也复制开。

deepcopy() 会记录已经处理过的对象,以应对循环引用和重复引用;它也可能复用不可变对象,并不要求每个成员都生成新实例。文件、连接等资源不能靠深拷贝获得独立副本。

# 1.7 a += b和a = a + b的行为一定相同吗?

不一定。列表的 += 会修改原列表,+ 会创建新的拼接列表,其他变量是否仍能看到变化也就不同:

a = [1]
alias = a
a += [2]
print(alias)     # [1, 2],原列表被修改

a = a + [3]
print(a, alias)  # [1, 2, 3] [1, 2],只有 a 指向新列表

增强赋值优先尝试 __iadd__(),不支持时再按普通加法规则处理,所以具体行为由类型决定。整数、字符串不会因 += 改变原对象,自定义类的 __iadd__() 也可以返回另一个对象。

增强赋值还只对左侧求值一次。左侧若是有副作用的属性或下标表达式,机械改写为 a = a + b 也可能改变行为。

# 1.8 None、False、0和空容器有什么区别?为什么判断None通常用is?

它们在条件判断中都为假,但各有含义:None 通常表示没有值,False 表示布尔假,0 是数值零,空容器表示没有成员。if not value 会把这些情况一起匹配到。

如果超时配置允许取 0,只有 None 表示没有提供配置,就需要用 value is None 区分,不能把 0 也换成默认值。None 是单例,is None 直接确认对象身份,也不会受自定义 __eq__() 的影响。

# 1.9 Python的and、or一定返回布尔值吗?短路求值是怎么回事?

and、or 返回的是操作数本身,不保证是布尔值:

  • a and b:a 为假时返回 a,不执行右侧;否则计算并返回 b。
  • a or b:a 为真时返回 a,不执行右侧;否则计算并返回 b。

左侧已能决定结果,右侧就不会求值,这叫短路求值。右侧即使写的是函数调用,也可能不会执行。比如 0 or 10 返回整数 10,所以用 value or default 设置默认值,会连合法的零和空字符串也一起替换;只想处理 None 时,需要明确判断 None。要求布尔结果则可用 bool() 转换。

# 1.10 type()和isinstance()有什么区别?

  • type(obj) 返回对象的实际类型。用 type(obj) is T 比较时,要求精确属于 T。
  • isinstance(obj, T) 判断对象是否属于 T,会考虑继承关系,因此 T 的子类实例也能通过。

比如一个 list 子类的实例,type(obj) is list 为假,isinstance(obj, list) 为真。希望接口接纳子类时用后者,要求精确类型时用前者。bool 也是 int 的子类,所以 isinstance(True, int) 为真;只接收整数、不接受布尔值的接口,需要额外排除 bool 或检查精确类型。

# 1.11 Python有哪些常见的基础数据类型?分别有什么特点?

Python 常见的内置数据类型包括数值与布尔、文本、序列、映射、集合、二进制数据和空值。它们各自适合表达不同的数据:

类型 主要特点 常见用途
int 整数,支持任意精度,不受固定 32 位或 64 位范围限制 计数、下标、整数运算
float 浮点数,通常用来表示小数,部分数值存在表示误差 测量值、一般数值计算
complex 复数,包含实部和虚部,例如 1 + 2j 复数运算
bool 布尔值,只有 True、False,也是 int 的子类 条件判断、状态标记
str 不可变的 Unicode 文本序列 名称、消息、文本处理
list 有序、可变,允许重复元素,支持索引和切片 动态数据列表
tuple 有序、不可变,不能增删或替换成员引用 坐标、固定字段组合、多个返回值
range 不可变的整数序列,用起止值和步长描述范围 循环中的整数范围
dict 键值映射,key 必须可哈希,保留插入顺序 配置、索引、结构化数据
set 可变集合,元素必须可哈希,去重且不保证插入顺序 去重、成员判断、交并差运算
frozenset 不可变集合,自身可哈希 固定成员集合、字典 key
bytes 不可变的字节序列 网络传输、二进制文件内容
bytearray 可变的字节序列 需要原地修改的二进制数据
NoneType 只有 None 这一个值,表示没有值 参数未提供、没有结果等状态

其中,list、dict、set、bytearray 是可变类型,表中其余类型自身不可变。元组的不可变性只约束成员引用,不会把里面的列表也冻结;字典虽然保留插入顺序,也不会自动按 key 大小排序。

二进制类型与文本类型处理的是不同内容:str 表达字符,bytes、bytearray 表达字节,两者通过编码和解码转换。None 则用于表达“没有值”,需要与数值零、布尔假和空容器区分。

# 1.12 Python有哪些常用的数据结构?分别适合什么场景?

Python 常用的数据结构包括列表、元组、字典、集合,以及栈、队列和堆。有些可以直接用内置容器,有些需要配合标准库实现,选择时主要看数据怎样访问和修改。

数据结构 Python 中常用的实现 特点与适用场景
动态数组 list 支持按下标访问,适合保存需要增删、修改的有序数据;CPython 列表的索引访问是 O(1),尾部追加均摊 O(1),中间插入和删除是 O(n)
不可变序列 tuple 成员引用固定,适合坐标、固定字段组合,也常用于返回多个值
哈希映射 dict 通过 key 查找 value,查找、插入、删除平均 O(1),适合配置、缓存和按标识查询数据
哈希集合 set、frozenset 元素唯一,成员判断平均 O(1),适合去重、交并差运算;frozenset 不可变
栈 list 或 collections.deque 后进先出,使用尾部的 append() 和 pop(),适合回退、深度优先遍历、表达式处理
队列、双端队列 collections.deque 队列按先进先出处理;双端队列支持两端增删,适合广度优先遍历、滑动窗口和待处理任务缓存
堆 heapq 配合 list 默认是最小堆,堆顶访问 O(1),入堆、出堆 O(log n),适合优先级调度、Top K 和合并有序数据

链表、树和图也可以在 Python 中实现,只是没有统一的通用内置容器。链表可以用节点对象保存数据和下一个节点的引用;树用节点对象保存父子关系;图常用字典配合列表或集合表示邻接关系,例如记录每个节点连接了哪些节点。

有几个选型细节需要注意:

  • 队列要频繁从头部取元素时,优先用 deque。它的两端追加和弹出是 O(1),而列表的 pop(0) 需要移动后续元素,是 O(n)。如果还需要线程间阻塞等待、任务计数等生产者与消费者协作能力,可以使用 queue.Queue。
  • 堆只保证堆顶是最小元素,并不保证整个列表有序。需要反复取出当前优先级最高的元素时用堆,需要完整的有序结果时用排序。
  • dict、set 的 O(1) 是平均复杂度,遇到严重哈希冲突时性能会下降,不能理解为任何情况下都只做一次操作。

# 2. List、Tuple和字符串面试题

# 2.1 Python的列表和元组有什么区别?分别适合什么场景?

列表和元组都支持索引、切片、迭代,区别主要在可变性及表达的数据含义:

对比项 list tuple
成员关系 可以增删、替换成员 创建后不能替换、增删成员
常用场景 动态结果集、持续修改的数据 坐标、固定字段组合、多个返回值
能否作为字典 key 不可哈希,不能使用 所有成员可哈希时可以使用

元组只固定成员引用,成员本身仍可能可变,比如元组里的列表可以继续追加元素。CPython 的元组通常不需要为后续增长预留空间,某些情况下更省内存;列表和元组的选择首先取决于是否允许修改成员,速度不能仅由类型名字判断。

# 2.2 CPython中列表的底层结构是怎样的?为什么一个列表可以存放不同类型的对象?

CPython 列表可以理解为一个动态对象指针数组。列表对象记录当前长度、已分配容量,以及指向底层数组的入口:长度是实际元素数,容量是已经分配的引用槽位数。

底层每个槽位保存一个对象引用。例如 [10, "hello", [1, 2]] 的三个槽位分别指向整数、字符串和另一列表,对象自身携带类型信息,所以不要求元素类型一致。

索引访问能够直接算出槽位位置,再取得对应引用,时间复杂度是 O(1)。这里连续的是引用槽位,引用指向的对象不一定相邻;与直接保存连续数值的数组相比,它需要额外的对象与引用开销。

# 2.3 列表是如何扩容的?为什么append()的均摊时间复杂度是O(1)?

当 CPython 列表的长度达到容量,再追加元素就需要调整底层引用数组。整个过程可以分成两条路径:

  1. 还有空槽位:写入新元素引用,更新长度,不必搬移原有元素。
  2. 容量不足:计算比所需长度更大的容量,调整内存,必要时搬移已有引用,再写入新元素。预留的空间供后续追加使用。

预留容量是均摊 O(1) 的原因。如果每次只增加一格,连续追加 n 次可能搬移 1、2、3……个引用,总成本达到 O(n²);按比例增长,扩容时需要搬移的总量仍为 O(n),再加上 n 次写入,平均每次就是 O(1)。

因此,单次触发扩容的追加可能是 O(n),一串追加操作的均摊成本才是 O(1)。具体预留比例、批量增长与缩容策略属于 CPython 实现细节,列表缩短也不意味着立即收回全部空余容量。

# 2.4 列表的索引、查找、插入和删除,时间复杂度分别是多少?

CPython 列表的成本主要来自两件事:按值查找时逐个比较,插入或删除后搬移引用。设长度为 n,切片包含 k 个元素,并把单次比较和引用操作看作 O(1):

操作 典型时间复杂度 原因
索引读写、len() O(1) 直接定位槽位,长度已有记录
in、index()、count() O(n) 逐个比较元素
append()、末尾 pop() 均摊 O(1) 偶尔调整容量
中间或头部 insert()、pop(i)、del O(n) 需要移动后续引用
remove() O(n) 先查找,再删除
长度为 k 的切片 O(k) 创建列表并复制 k 个引用

列表用 append()、pop() 做栈比较合适。若反复用 pop(0) 取队首,后面的引用每次都要移动,通常应选择 collections.deque。自定义元素比较很昂贵时,查找还要计入每次 __eq__() 的开销。

# 2.5 对列表进行切片会复制数据吗?修改切片中的嵌套对象会影响原列表吗?

会复制,但内置列表切片只复制外层的成员引用,属于浅拷贝。

假设原列表是 [[1], [2]],切片后得到一个新的外层列表,但两边的第一项仍然指向同一个 [1]。给切片列表追加 [3],只修改新的外层;把第一行追加为 [1, 9],双方看到的第一行都会改变。需要内层也独立时,就要继续复制这些成员,或者使用 copy.deepcopy()。

b = a[:] 是读取切片并创建列表,a[:] = values 是修改原列表的切片,两者不能混用。其他类型的切片行为由类型决定,有些返回共享底层数据的视图。

# 2.6 append()、extend()和insert()有什么区别?

三种方法都修改原列表,但对传入对象的处理不同:

  • append(x):把 x 作为一个元素放到末尾。给 [1] 追加 [2, 3],结果是 [1, [2, 3]],均摊成本 O(1)。
  • extend(iterable):遍历输入,把元素逐个放到末尾。同样输入得到 [1, 2, 3];传字符串则逐个加入字符,处理 k 个元素需要相应的遍历与写入成本。
  • insert(i, x):在指定位置之前插入一个元素。头部或中间插入通常要移动后续引用,成本 O(n)。

它们都返回 None。所以 a = a.append(x) 会先修改列表,再把名字 a 绑定到 None,不能这样保存修改后的列表。

# 2.7 为什么[[0] * 3] * 3创建的二维列表容易出问题?

这个表达式创建的三行实际上是同一个行列表的三个引用。外层乘以 3 时,Python 只重复引用,没有为每个位置创建新行,因此修改任何一行,其他位置看到的也是修改后的同一对象。

rows = [[0] * 3] * 3
rows[0][0] = 1
print(rows)  # [[1, 0, 0], [1, 0, 0], [1, 0, 0]]

用 [[0] * 3 for _ in range(3)] 才会每次创建独立行。内层重复整数 0 不会产生同样的问题,因为替换一个位置并不会修改不可变整数;如果更深一层仍包含列表等可变对象,仍要确保没有意外共享。

# 2.8 元组是不可变的,为什么元组中的列表还能被修改?

元组不能改变它保存的成员引用,列表可以改变自己的内容。两层对象各自遵守自己的可变性规则。

例如 t = ([1, 2], "固定字段"),执行 t[0].append(3) 改的是列表,元组仍引用原来的列表,所以可以执行;t[0] = [9] 要替换元组成员,就会报错。这个元组也不可哈希,因为它包含不可哈希的列表,外层不可变不等于整组对象都被冻结。

# 2.9 sort()和sorted()有什么区别?如何按多个字段排序?

  • list.sort() 修改原列表并返回 None,适合允许改变已有列表顺序的情况。
  • sorted() 接受可迭代对象,创建并返回新列表,原输入无需是列表,也可以保留原来的列表顺序。

多字段排序常让 key 返回元组,排序时从左到右比较,前一字段相等才继续比较下一字段。例如 key=lambda u: (-u["score"], u["name"]),先按分数降序、再按姓名升序。reverse=True 反转整体比较关系,不能单独控制每个字段的方向。

另一种方式是利用稳定排序:相同排序键的元素保留原相对次序,因此可以先排次要字段,再排主要字段。key 对每个元素计算一次,字段提取或规范化可以放在这里完成。

# 2.10 str和bytes有什么区别?如何正确处理编码,以及高效拼接字符串?

str 保存 Unicode 文本,bytes 保存字节序列。索引 str 得到单字符字符串,索引 bytes 得到 0—255 的整数;文本长度也不一定等于编码后的字节长度。

文本通过 encode() 变成字节,字节通过 decode() 还原文本。例如“你好”是两个字符,按 UTF-8 编码后是六个字节。解码要与数据实际采用的编码匹配,str(data) 只得到字节对象的展示形式,不能代替解码。读写文本文件通常显式指定编码,图片、压缩包等使用二进制模式。

字符串不可变,拼接会产生结果对象。少量字段适合 f-string 或 +;大量片段通常先收集,再用 "".join(parts) 一次拼接,减少反复生成长中间字符串的成本。需要逐步写入时也可以用 io.StringIO,CPython 对某些拼接场景的优化不能作为所有输入都高效的保证。

# 2.11 列表、字典和集合推导式有什么区别?与普通for循环相比有什么特点?

它们都能在一个表达式中完成遍历、筛选和构造,结果遵循不同容器的规则:

推导式 结果特点
列表推导式 保留顺序与重复元素
字典推导式 构造 key-value,重复 key 的后一个值覆盖前一个
集合推导式 按哈希和相等关系去重,没有插入顺序保证

简单转换、筛选用推导式比较紧凑,复杂分支、异常处理或多步业务逻辑用普通 for 更清楚。Python 3 中,推导式的迭代变量不会泄漏到外部作用域;普通 for 的循环变量则会保留。

这三种推导式都立即构造容器。如果结果只消费一次、数据又很多,可以用生成器表达式按需计算,避免保存完整中间结果。

# 3. Dict和Set面试题

# 3.1 CPython中字典的底层实现原理是怎样的?

CPython 字典用哈希表查找 key,普通字典将内部结构分成两部分:稀疏索引表负责定位,条目区保存 key、value 及查找所需的信息。

一次查询大致沿着这条路径执行:

  1. 计算 key 的哈希值,确定初始索引槽位。
  2. 根据槽位里的条目编号,找到候选 key-value。
  3. 检查候选 key 是否匹配;匹配就返回 value,不匹配则沿探测序列继续,遇到从未使用的空槽表示不存在。

哈希分布合理、哈希和比较成本为常数时,平均查询和更新是 O(1);冲突严重则可能接近 O(n)。条目按插入关系组织,所以可以同时做到哈希查找和按插入顺序遍历。

普通字典的 key、value 通常保存在一起,也叫 combined table;某些实例属性字典可以共享 key 的结构,只分别保存 value,也叫 split table,用来减少同类实例的重复开销。具体布局还会随 key 类型和 CPython 构建方式优化。

# 3.2 字典发生哈希冲突时,是如何处理的?

当两个不同 key 映射到同一个初始槽位,CPython 字典会在表内沿探测序列继续寻找位置,这叫开放寻址。

查询碰到候选条目时,会先确认身份,必要时再比较相等关系。候选不相等就继续探测,因此哈希相同的不同 key 也能分别保存。CPython 的探测会利用哈希值的其他位,并非固定往后一格。

删除条目还需要维持这条探测路径。假设第一个 key 占了初始位置,第二个因冲突放到了后方;删除第一个后如果把位置直接视为未使用,查询第二个就可能在这里提前停止。所以删除通常会留下 dummy 标记,查询遇到它继续前进,遇到真正未使用的空槽才确认不存在。

# 3.3 字典在什么情况下会扩容?扩容过程是怎样的?

CPython 字典插入新 key 时,如果内部已经没有可用条目位置,就需要重建表。更新已有 key 的 value 通常不需要新条目。普通字典通常让可用条目上限约为索引槽位数的三分之二,保留空位以控制探测成本,并让缺失 key 的查询能够结束。

重建的主要过程是:

  1. 根据存活条目数和增长策略选择合适的表大小,准备新表。
  2. 整理仍然存活的条目,清除删除留下的空洞。
  3. 按新表大小重新定位这些条目的哈希索引,切换到重建后的结构。

移动的是索引信息和对象引用,key、value 本身不会被深拷贝。旧索引不能原样搬过去,因为表大小变了,同一个哈希值对应的位置也可能变化。

删除空洞也会消耗可用空间,所以仅看 len(d) 与槽位数的比例,不能判断下次插入是否重建。连续插入时通常会扩大容量;大量删除后,后续插入触发的重建也可能只整理或缩小,单纯删除不会立即按比例缩容。具体增长策略属于 CPython 的版本实现细节。

# 3.4 Python字典的遍历是有序的吗?插入顺序和排序有什么区别?

Python 3.7 起,字典按插入顺序遍历是语言保证;CPython 3.6 已经如此,但当时还是实现细节。

这个顺序由 key 进入字典的先后决定:

  • 更新已有 key 的 value,位置不变。
  • 删除后重新插入同一个 key,会排到末尾。
  • 遍历字典及 keys()、values()、items(),都遵循这一顺序。

插入顺序不会自动按大小排列。需要排序时,可以用 sorted(d) 得到排序后的 key 列表,或用 sorted(d.items(), key=...) 得到排序后的条目列表。普通字典的 == 只比较映射关系,不要求两个字典的插入顺序相同。

# 3.5 什么是可哈希对象?字典的key可以是列表或元组吗?

字典 key 必须可哈希,要求能取得生命周期内稳定的哈希值,并满足相等对象具有相同哈希值。字典依赖哈希定位,如果 key 的哈希在使用中变化,就可能沿错误路径查找。

内置列表、字典、集合不可哈希,不能作为 key。元组则取决于成员:(1, "a") 可以,([1], 2) 不可以,因为其中的列表不可哈希。

可哈希也不要求对象所有属性都冻结。普通自定义对象默认按身份比较和哈希,属性可修改但身份不变,仍然可以作为 key;如果改为按某些属性计算哈希和比较相等,就需要保持这些属性稳定。

# 3.6 __hash__()和__eq__()有什么关系?自定义对象作为key时要注意什么?

字典先用 __hash__() 定位候选,再用身份或 __eq__() 确认匹配。因此两者必须遵守同一个约定:a == b 时,必须有 hash(a) == hash(b);哈希相同则不一定相等。

比如两个用户对象按用户 ID 判断相等,哈希也应该基于这一 ID。若仍用对象身份计算哈希,相等对象可能走不同探测路径,字典就可能无法按预期把它们识别为同一个 key。插入以后修改参与哈希或相等比较的字段,也会破坏查找规则。

类重写 __eq__() 却没有定义对应的 __hash__() 时,Python 通常会把 __hash__ 设为 None,让实例不可哈希。这能避免直接继承身份哈希而与内容相等冲突。需要自定义 key 时,应一起设计这两个方法,不能只为消除报错就加回 object.__hash__。

# 3.7 为什么字典中的1、True和1.0可能对应同一个key?

字典识别 key 时不要求类型相同。1、True、1.0 在数值比较中相等,哈希也相同,因此后续赋值会命中已有条目,而不是增加新 key。bool 是 int 的子类,True 与 1 相等;1 与 1.0 也按数值相等。

所以 {1: "整数", True: "布尔", 1.0: "浮点"} 只有一个映射,value 最后变成“浮点”,集合也会把三者去重。如果业务含义要求它们分开,可以把类型加入 key,比如 (type(value), value)。

# 3.8 字典可以边遍历边添加或删除元素吗?为什么?

直接遍历字典时,只修改已有 key 的 value 通常可以,添加或删除 key 则不安全。迭代器依赖当前结构继续找条目,key 集合变化后可能出现 RuntimeError 或漏掉条目;删除一个再增加一个,即使总长度没变,结构也已经改变。

需要筛选或删除时,可以选择:

  • 遍历 list(d) 生成的 key 快照,再修改原字典。这样增加一份快照内存,也不会自动解决多线程同步。
  • 先收集待删 key,遍历结束后统一删除。待删项很少时,可以避免复制全部 key。
  • 用字典推导式构造过滤后的新字典,适合直接生成新结果。

# 3.9 dict、defaultdict和Counter有什么区别?

  • dict 用于通用映射,下标读取缺失 key 会抛出 KeyError。
  • defaultdict 为缺失 key 创建默认值。比如 defaultdict(list) 用下标读取新 key 时,会调用 list(),把新列表保存进去并返回,适合分组累积。这里传的是工厂 list,不是 [];get() 不会触发工厂。
  • Counter 用元素作 key、计数作 value,支持计数加减和 most_common()。下标读取缺失元素返回 0,但不因此插入 key;计数可以为零或负数,赋零也不会自动删除条目。

defaultdict 和 Counter 都是字典子类,分别把“缺失时初始化”和“按元素计数”这两类常见操作封装起来。

# 3.10 集合是如何实现去重的?set和frozenset有什么区别?

集合使用哈希与相等关系判断元素是否重复。CPython 集合基于哈希表:插入时先按哈希定位,碰到候选元素再确认是否相等;已经存在就不重复插入,冲突但不相等则继续探测。平均成员检查成本是 O(1)。

类型 能否增删成员 能否作为字典 key 或集合元素
set 可以 自身不可哈希,不能使用
frozenset 不可以 可哈希,可以使用

两者的成员都必须可哈希,也都没有插入顺序的语言保证。去重会合并类型不同但相等的值,例如 1 和 True。需要保留第一次出现的顺序时,对于可哈希元素可以使用 list(dict.fromkeys(values))。

# 3.11 d[key]、get()和setdefault()有什么区别?key不存在时分别会怎样处理?

对普通内置字典,这三种方式的区别集中在 key 缺失时:

操作 key 存在 key 不存在 是否插入默认值
d[key] 返回已有值 抛出 KeyError 否
d.get(key, default) 返回已有值 返回 default,省略则为 None 否
d.setdefault(key, default) 返回已有值 插入并返回 default,省略则为 None 是

必需字段缺失属于错误时,用 d[key] 可以及时暴露问题;允许缺失时用 get();按 key 初始化分组时可以用 setdefault()。已有值为 None、0 或空容器,也算 key 存在,不会被默认值替换。

函数实参会在调用前求值,因此 d.get(key, build()) 和 d.setdefault(key, build()) 都会先执行 build(),即使 key 已存在。默认对象构造昂贵时,可以显式判断,或用合适的 defaultdict 工厂控制创建时机。

# 4. 函数、作用域、闭包和装饰器面试题

# 4.1 Python中的函数为什么可以赋值给变量,也可以作为参数和返回值?

函数是 Python 的一等对象。执行 def 会创建函数对象并绑定函数名,与整数、列表一样,这个对象可以赋给其他名字、存入容器,也可以作为参数或返回值。

函数对象和调用结果要分开。例如注册回调时传入 func,是让框架以后调用它;传入 func() 则会立即执行,只把结果交给框架。接收或返回函数的函数叫高阶函数,装饰器、函数工厂都依赖这种能力。

函数对象还保存默认参数、所关联模块的全局命名空间和闭包引用等状态,并非只有一段待执行的代码。

# 4.2 位置参数、关键字参数、*args和**kwargs有什么区别?

位置实参和关键字实参决定如何匹配参数,*args 和 **kwargs 在函数定义中收集多余实参:

方式 匹配或保存规则
位置实参 按传入顺序匹配参数
关键字实参 按参数名匹配
*args 将多余的位置实参收集为元组
**kwargs 将多余的关键字实参收集为字典

args、kwargs 只是惯用名字,星号决定收集规则。在调用处,星号执行相反的操作:*values 展开可迭代对象,**options 展开映射;关键字 key 必须是字符串,同一个参数不能得到重复值。

函数还可以规定哪些参数必须按位置或关键字传入。/ 前只能按位置传,单独 * 或 *args 后的普通参数只能按关键字传,例如 def f(a, /, *, timeout=5) 中,a 按位置接收,timeout 按名字接收。

# 4.3 为什么不建议使用列表、字典作为函数参数的默认值?

默认参数在执行函数定义时计算一次,随后保存在函数对象中。以列表、字典作默认值,多次省略该参数的调用就会拿到同一个容器,先前修改的内容会保留:

def collect(value, items=[]):
    items.append(value)
    return items

print(collect(1))  # [1]
print(collect(2))  # [1, 2],两次调用共享默认列表

解决方式是默认传 None,在函数内用 items is None 判断并创建新列表。这样每次没有提供容器的调用都有独立状态,同时保留调用者传入的空列表;items or [] 会把这个合法空列表也替换掉。

可变默认值可以用于明确设计的共享状态,但不能让调用者无意间共享。定义时求值还影响 datetime.now() 这类表达式:默认时间停留在定义那一刻,即使每次调用时间已经不同。

# 4.4 Python的LEGB作用域查找规则是怎样的?

普通函数中的名字按 LEGB 顺序查找,找到就停止:

  • L,Local:当前函数的参数和局部名字。
  • E,Enclosing:外层函数的作用域,从近到远查找。
  • G,Global:函数定义所在模块的全局名字。
  • B,Builtins:len、print 等内置名字。

局部名字的判定发生在执行前。普通函数里给某个名字赋值,且没有 global 或 nonlocal 声明,这个名字通常在整个函数块都被认作局部;赋值前读取会触发 UnboundLocalError,不会退回使用同名全局变量。

if 和普通 for 不会建立独立局部作用域。类命名空间也不是方法查找普通变量时的外层函数作用域,访问属性通常要写 self.attr 或类名。LEGB 概括的是普通函数规则,注解作用域等特例有各自的名字解析规则。

# 4.5 global和nonlocal有什么区别?

  • global 指定名字属于函数关联模块的全局作用域,函数里对这个名字重新赋值,就会改变模块级绑定。这个“全局”只针对该模块。
  • nonlocal 指定名字属于最近一个已经定义它的外层函数作用域,用于修改闭包变量。外层必须已有这个名字,否则是语法错误,模块全局名字不属于它的目标。

只读外部变量不需要声明。执行 items.append(x) 也通常不需要,因为修改的是列表对象,名字 items 的绑定没有变化;重新赋值外部名字时,才需要相应声明。两种声明只确定作用域,不提供跨进程共享或并发同步。

# 4.6 什么是闭包?闭包是如何保留外部函数变量的?

闭包是函数与它引用的外层函数变量绑定的组合。例如外层创建计数变量,再返回一个递增计数的内部函数,外层调用结束后,内部函数仍能继续访问这一计数。

CPython 将捕获的变量保存在 cell 对象里,内部函数通过闭包引用关联这些 cell。外层调用栈可以结束,但内部函数仍持有需要的状态;再次调用外层函数,则通常会创建另一组独立绑定。

闭包没有在定义时复制一份值快照,它读取的是所保存绑定的当前值。要重新绑定捕获变量,用 nonlocal;只是读取或修改其指向的可变对象,通常不需要。这个引用关系也解释了为什么一个长寿命闭包可能留住捕获的大对象。

# 4.7 为什么在循环中创建的多个lambda,可能返回相同的结果?

这些函数如果引用同一个外部变量,通常会在调用时读取它当前的值。循环已经结束时,该变量保存着最后一次迭代的值,于是多个函数都返回相同结果;这与使用 lambda 还是普通 def 无关。

funcs = [lambda: i for i in range(3)]
fixed = [lambda i=i: i for i in range(3)]
print([f() for f in funcs])  # [2, 2, 2]
print([f() for f in fixed])  # [0, 1, 2]

第二种写法的 i=i 在定义每个函数时求值,分别把当前对象引用放进默认参数,调用时读取的是自己的参数。也可以每轮调用函数工厂,为返回的函数建立独立外层绑定。

默认参数保存的仍是引用,如果引用可变对象,后续修改其内容也会被函数看到,并不会自动获得深拷贝。

# 4.8 什么是装饰器?@decorator的执行过程是怎样的?

装饰器是可调用对象,接收被装饰对象,再把返回的对象交给原名字。常见做法是返回包装函数,在原函数调用前后加入日志、计时或权限校验。

对函数使用 @decorator 时,执行定义的过程可以理解为:

  1. 创建原函数对象。
  2. 调用 decorator(原函数),得到装饰结果。
  3. 把原函数名绑定到这个结果,相当于 func = decorator(func)。

装饰阶段已经完成了包装或注册,业务真正调用函数名时,才会进入包装函数并调用原函数。模块导入时执行了定义,装饰器也会在导入阶段执行。装饰器也可以只完成注册并返回原函数,或返回其他可调用对象,不必每次都创建包装函数。

# 4.9 如何实现带参数的装饰器?多个装饰器的执行顺序是怎样的?

带参数的装饰器通常先经过一个工厂函数。工厂接收配置,返回接收原函数的装饰器;装饰器再返回包装函数,包装函数接收业务调用的参数。因此配置、被装饰函数、调用实参进入的是不同层。

多个装饰器叠加时,要区分几个阶段。假设 @A 写在 @B 上方:

阶段 顺序
装饰器表达式求值 从上到下,先求出 A,再求出 B
应用装饰器 从下到上,先 B 包装原函数,再 A 包装 B 的结果
正常调用 从外向内,进入 A、B,再进入原函数
正常返回 从内向外,退出原函数、B、A

最终绑定关系是 func = A(B(func))。进入和退出的顺序以每层都调用下一层、正常返回为前提;外层若直接返回缓存结果或抛出异常,内层就可能完全不执行。

# 4.10 functools.wraps有什么作用?装饰异步函数时要注意什么?

functools.wraps 保留原函数的名称、文档、注解等元信息,并设置 __wrapped__ 指向原函数。这样日志、文档和框架检查时能够找到原函数信息,inspect.signature() 通常也会沿这个引用查看签名,但包装函数实际接收参数的规则并没有自动改变。

装饰协程函数时,调用原函数先得到协程对象,函数体还没有执行。如果同步包装只是计时一次 func() 调用,测到的只是创建对象的时间。要覆盖实际执行阶段,包装层通常也应使用 async def,通过 await func(...) 等待原函数完成。

异常与取消应继续向外传递,清理可以放在 finally。wraps 不会自动完成这些异步处理;异步生成器也需要保留异步迭代协议,不能直接套用等待单个协程结果的包装。

# 4.11 lambda与普通函数有什么区别?map()、filter()与推导式应该如何选择?

lambda 用一个表达式创建函数,表达式结果就是返回值;def 可以写多条语句,也便于提供名字和文档。两者都是函数对象,都能接收参数、形成闭包,写成 lambda 不会天然更快。

数据转换和筛选可以按结果形式选择:

  • 已经有清楚的转换函数,例如 str,map(str, values) 比较直观;Python 3 的 map() 按需转换,返回迭代器。
  • 只需筛选元素,filter() 返回按需筛选的迭代器。
  • 同时转换和筛选时,推导式通常更容易表达整体关系。列表推导式立即保存结果,生成器表达式按需产出。

结果要反复访问,保存列表比较合适;只消费一次的大数据,保留迭代器可以减少中间结果。复杂业务规则可以提取成具名函数,再交给这些工具处理。

# 5. 迭代器和生成器面试题

# 5.1 可迭代对象和迭代器有什么区别?

区别在于:一个提供遍历入口,一个负责记录遍历进度。

  • 可迭代对象能通过 iter() 提供迭代器,比如列表、元组和字符串。它们保存数据,但不负责记住某次遍历读到了哪里。
  • 迭代器能通过 next() 逐个返回元素,同时保存当前位置;没有元素时抛出 StopIteration。迭代器自身也可迭代,iter(iterator) 返回的就是它自己。

因此,对同一列表调用两次 iter(),通常会得到两个独立的游标;两个消费者共用一个迭代器,就会接着同一个位置往后读。生成器、文件对象都属于迭代器,能被 for 遍历不代表一定能重复遍历。

# 5.2 for循环的底层执行过程是怎样的?

for 使用的是迭代协议,不要求对象有下标,也不需要提前知道元素数量。执行过程是:

  1. 对目标调用 iter(),得到迭代器。
  2. 调用 next() 取一个元素,把它绑定给循环变量。
  3. 执行循环体,再回到取值步骤。
  4. 取值时遇到 StopIteration,说明迭代器耗尽,循环正常结束。

continue 会进入下一次取值,break 则直接退出。for ... else 中的 else 在正常耗尽、没有执行 break 时运行,所以空序列也会进入 else;异常或 return 离开循环时不会进入。

这里作为结束信号的 StopIteration 必须来自迭代器取值阶段。循环体里的异常不会被 for 自动吞掉,取值时的其他异常也会继续传播。

# 5.3 __iter__()和__next__()分别有什么作用?

__iter__() 返回供遍历使用的迭代器,__next__() 从这个迭代器中取下一个元素。容器的 __iter__() 通常创建新迭代器;迭代器的 __iter__() 则返回自己,真正的取值和状态推进放在 __next__() 中。

结束必须通过 StopIteration 表达,不能返回 None 代替,因为 None 也可以是正常元素。迭代器一旦耗尽,后续取值应持续报告耗尽,而不是自行重新开始。

Python 的 iter() 还兼容旧式序列协议:对象没有 __iter__() 时,可以从下标 0 开始调用 __getitem__(),直到 IndexError。所以判断能否迭代,尝试 iter(obj) 比仅检查有没有 __iter__ 更完整。

# 5.4 什么是生成器?生成器为什么能节省内存?

生成器是按需产出数据的迭代器。普通 def 函数体直接包含 yield,就会成为生成器函数;调用时只创建生成器对象,直到取值才开始运行函数体。

比如处理一百万条记录,列表方案会先算出全部结果并保存下来,消费者随后再取;生成器可以算出一条就交给消费者,暂停后等下一次请求。这样不必同时保存完整结果,也能省掉流水线中间的几个大列表。

生成器仍要保留执行状态和必要引用。数据源本身如果已经是大列表,或者函数内部先把文件全部读进来,这些内存不会因为用了 yield 就消失;把最终结果转换成 list(),也会重新保存全部元素。

# 5.5 yield和return有什么区别?生成器暂停后如何恢复执行?

return 表示结束,yield 表示交出一个值、暂时停在这里。生成器遇到 yield 后,不会丢掉局部变量和执行位置;下一次恢复会从暂停点继续,而不是重跑函数开头。CPython 通过保留执行帧等状态实现这个过程。

恢复方式有两种常见用法:

  • next() 继续取值,相当于 send(None)。
  • send(value) 在恢复的同时,把 value 作为当前 yield 表达式的结果传回生成器。首次启动只能使用 next() 或 send(None),因为这时还没有能接收数据的暂停点。

生成器里也能写 return value,但它表示迭代结束,并把值放进 StopIteration.value,不会把这个值当成下一个元素交给 for。这与普通函数直接把 return 的值交给调用方不同。

# 5.6 列表推导式和生成器表达式有什么区别?

两者可以写相近的转换逻辑,但计算时机和结果形态不同。

对比项 列表推导式 生成器表达式
结果 完整列表 生成器对象
计算时机 创建列表时计算全部元素 取值时逐个计算
保存结果 保存全部元素 通常只保留执行状态和必要引用
后续操作 支持索引、长度、重复遍历 顺序消费,耗尽后不能自动重来

需要反复使用结果或按索引访问时,列表更合适;求和、逐条转换等只消费一次的流程可以用生成器减少中间存储。生成器计算得晚,读取外部变量或可变数据源时,也可能看到创建之后的变化。

它也不是所有部分都延迟执行。生成器表达式最左侧 for 的可迭代对象表达式,会在创建时求值并获取迭代器,所以 (x for x in get_data()) 此时就会调用 get_data()。是否更快要看实际计算和消费方式,惰性主要改变计算时机与内存需求。

# 5.7 yield from有什么作用?与直接使用yield有什么区别?

yield [1, 2] 交出的是一个列表对象,yield from [1, 2] 则委托这个列表的迭代过程,依次交出 1 和 2。所以 yield from 可以用来串联数据流,省去手写逐个 yield 的循环。

委托给子生成器时,它还会按协议转发 send()、throw() 和 close(),让调用方与子生成器交互。子生成器结束时,其 return 值成为外层 yield from 表达式的结果,外层可以接住它;这个返回值不会自动混进产出的元素里。

普通列表也能接受迭代委托,但不会因此获得子生成器那样完整的双向交互能力。

# 5.8 为什么生成器遍历一次之后,再次遍历可能没有数据?

因为遍历消耗的是同一个生成器的执行状态。它已经运行到结束,再调用 iter() 得到的仍是自身,不会自动把位置重置到开头。两个消费者共享一个生成器时,也会接着同一条流读取,一个已经读走的元素不会再交给另一个。

需要重新遍历,可以从可重复的数据源重新创建生成器;结果较小则可以先保存成列表。itertools.tee() 可以提供独立消费位置,但它靠缓存落后消费者还没读到的数据实现,进度相差很大时会占用很多内存,并不会重新执行上游文件读取或网络请求。

# 6. 面向对象面试题

# 6.1 Python中的self是什么?实例方法是如何绑定到对象上的?

self 是接收当前实例的参数名,属于约定,不是关键字。调用 obj.method() 时,不需要手动传它,是因为访问方法时已经完成了绑定:

  1. 普通函数保存在类上,并实现描述符协议。
  2. 通过实例访问这个函数,会得到绑定方法,其中保存原函数 __func__ 和实例 __self__。
  3. 调用绑定方法时,实例会自动放到原函数的第一个参数位置。

因此,多个实例可以使用同一份函数代码,不需要每个实例各存一份。self.name 是访问实例属性,而单独的 name 按函数作用域查找;类的命名空间不是方法的外层函数作用域。

# 6.2 __new__()和__init__()有什么区别?对象的创建过程是怎样的?

__new__() 负责创建并返回对象,__init__() 负责初始化已经创建的对象。对于默认元类 type 控制的普通类,调用类的过程是:

  1. 调用 __new__(cls, ...),让它返回对象。
  2. 判断返回对象是否为 cls 或其子类的实例;是的话,对这个实例执行初始化,否则跳过这次常规初始化。
  3. 初始化完成后,把实例返回给调用方。__init__() 不能返回另一个对象,必须返回 None。

大部分业务类只需要在 __init__() 里设置属性。继承 int、str 等不可变类型、想改变创建时保存的值,则通常要在 __new__() 中处理;对象已经创建后,初始化无法再改变那个不可变值。

这条流程适用于默认调用机制;自定义元类重写 __call__() 后,可以改变类的创建行为。

# 6.3 类属性和实例属性有什么区别?为什么可变的类属性容易引起数据共享问题?

类属性保存在类上,实例属性属于具体对象。对于普通属性,实例没有同名属性时,会沿类及其 MRO 查找,所以多个实例可能取得同一个类属性对象。

比如把 messages = [] 放在类体里,两个会话读到的就是同一个列表。这里要分清两种操作:

  • 通过一个实例执行 obj.messages.append(...),是在修改共享列表,另一个实例也能看到新增消息。
  • 给一个实例的 messages 重新赋值,是建立同名实例属性,遮住普通类属性;类上的列表和另一个实例的访问结果并没有被替换。

每个会话需要独立消息时,应在 __init__() 中执行 self.messages = [],让每次初始化都创建新列表。是否共享取决于对象在哪里创建、引用指向哪里,并不取决于访问时有没有写 self。描述符属性另有优先级,不能把上述普通属性规则直接套到 property 上。

# 6.4 实例方法、类方法和静态方法有什么区别?

区别在于调用时 Python 自动绑定了什么,以及方法要操作哪一层状态。

方法 定义方式 自动传入 适合处理的事情
实例方法 普通方法 当前实例 self 读取或修改实例状态
类方法 @classmethod 当前调用所对应的类 cls 替代构造入口、类级操作
静态方法 @staticmethod 不自动传入实例或类 与类职责相关但不依赖状态的辅助操作

类方法经常用来提供 from_text() 之类的创建入口。它使用 cls(...) 时,子类调用可以创建子类实例,不必把具体类名写死。静态方法即使通过实例调用,也不会获得 self;它只是被放在类的命名空间中,并不形成访问权限隔离。

# 6.5 什么是鸭子类型?Python是如何实现多态的?

鸭子类型就是按对象能提供的行为来使用它,不要求先确认它继承了哪个类。比如本地工具和远程工具都提供约定好的 run(),调用方可以统一执行 tool.run(),具体行为由传入的对象决定。这就是多态,继承中的方法重写也能做到这一点。

这个约定包含方法名,也包含参数、返回值和行为。如果一个 run() 直接返回结果,另一个返回必须等待的协程,调用方就不能照同一套流程使用。抽象基类可以声明必须实现的方法,typing.Protocol 可以让静态检查器按结构检查接口;普通动态调用本身不要求对象继承这些定义。

# 6.6 Python多继承中的MRO是什么?super()是如何查找方法的?

MRO 是方法解析顺序,Python 沿这条顺序查找继承体系中的属性和方法。多继承时不能只顺着某条“父类边”查,否则菱形继承中的公共基类可能重复出现,父类之间也可能产生顺序冲突。

Python 用 C3 线性化生成一条顺序,保持子类在基类之前、声明的基类先后关系,以及已有继承顺序的一致性。假设 D 继承 B、C,而 B、C 都继承 A,它的 MRO 可以是 D → B → C → A → object。

假设 D 未重写这个方法,B、C、A 都提供同名实现,B、C 通过 super() 继续调用,那么在 D 的实例上调用时,先进入 B 的实现;B 中调用 super(),从 MRO 里 B 的后面继续找,下一站是 C;C 再调用 super(),才进入 A。super() 的含义是继续 MRO,不是固定调用当前类的直接父类。

协作式多继承要求参与调用的方法参数兼容,并配合调用 super();写死 A.method(self) 可能跳过 C,或造成公共基类重复执行。顺序可通过 Class.__mro__ 查看,继承约束无法组成一致顺序时,类定义就会失败。

# 6.7 @property有什么作用?它和描述符有什么关系?

@property 把方法包装为属性访问接口,比如余额看起来是一个普通属性,但读写时可以执行逻辑:

  • 读取 obj.balance 进入 getter,返回内部值或计算结果。
  • 给 obj.balance 赋值进入 setter,可以先检查金额是否合法,再写入 _balance;没有 setter 时赋值会报错。

这样能保持调用方的属性语法,同时控制值怎样产生和修改。getter 中应该访问实际存储位置,而不是再次读取 self.balance,否则会递归调用自己。

底层依靠描述符协议:对象放在类上,并实现 __get__()、__set__() 或 __delete__() 等方法,就可以参与属性访问。property 是内置的数据描述符,优先级高于同名实例字典项;只读 property 仍属于数据描述符,因此不会靠新建同名实例属性绕过只读限制。

# 6.8 __getattr__()和__getattribute__()有什么区别?

两者都能影响属性读取,但介入的时机不同:

  • __getattribute__() 参与普通实例属性的每次读取,可以控制整个查找过程。
  • __getattr__() 是补救机制,点号访问或 getattr() 在正常查找抛出 AttributeError 后,才会尝试调用它,常用于动态属性和代理。

默认的 object.__getattribute__() 不是简单地先查实例、再查类,它会按下面的优先级处理:

  1. 沿类的 MRO 找到第一个同名类属性。如果它是支持读取的数据描述符,优先执行描述符读取,比如 property。
  2. 否则查实例字典,有同名值就返回。
  3. 实例里没有,再使用此前找到的非数据描述符或普通类属性。普通方法属于这一阶段的绑定规则。
  4. 都没有就抛出 AttributeError,由外层属性访问机制尝试 __getattr__()。

重写 __getattribute__() 后,读取内部属性通常要通过 object.__getattribute__(),否则写 self.name 又会进入自己,造成递归。找不到的属性应抛出 AttributeError,保持 hasattr() 等操作的语义;直接调用 obj.__getattribute__() 则没有外层机制自动替它执行 __getattr__()。

# 6.9 __str__()、__repr__()等特殊方法有什么作用?如何让自定义对象支持内置操作?

特殊方法把自定义对象接入 Python 的内置协议。例如对象的字符串表示有两种用途:

  • __str__() 提供面向使用者的可读文本,供 str()、print() 使用。
  • __repr__() 提供更明确的调试表示,供 repr()、交互式查看等使用。没有自定义 __str__() 时,默认会使用对象的 __repr__()。

两者都必须返回字符串。其他协议也类似:实现 __len__() 可以支持 len(),实现 __iter__() 支持遍历,__getitem__() 支持下标,__contains__() 支持成员判断,__call__() 让实例可以像函数一样调用。协议要求也需要满足,比如长度必须是非负整数。

许多内置操作隐式调用特殊方法时,会从对象的类型上查找,而不是按普通实例属性查找。只给某个实例设置 obj.__len__,通常不会改变 len(obj);实现协议需要把方法放在类上。

# 6.10 __slots__有什么作用?使用它有哪些限制?

普通实例通常通过动态属性字典保存属性。__slots__ 可以声明固定的属性槽位,在条件满足时省去每个实例的字典。CPython 中如果要创建大量结构相同的小对象,这能减少存储开销,具体收益取决于对象布局。

它带来的限制主要在属性和继承上:

  • 没有 __dict__ 时,不能随意添加未声明的属性;需要动态属性,可以把 "__dict__" 加进 slots。
  • 弱引用支持如果没有从基类获得,需要加入 "__weakref__"。
  • 基类已经有实例字典,子类的 slots 不能把它移除;子类不声明 slots,通常又会获得字典。没有新增属性时可写 __slots__ = ()。
  • 多个基类都有非空 slots 布局时,可能发生多继承布局冲突。

slots 声明不负责填入属性值,也不检查类型,仍需要初始化;槽位中的值也可以修改,所以它不是不可变对象机制。

# 6.11 _name、__name和__name__有什么区别?Python中的名字改写是怎么回事?

  • _name 是“非公开实现细节”的约定,外部仍可访问。模块的星号导入默认不导入这种名称,显式定义 __all__ 时则以它为准。
  • __name 在类定义中通常会触发名字改写,变成 _类名__name,目的是减少父类与子类的同名成员冲突。
  • __name__ 这种前后双下划线名称不按上述规则改写,通常有 Python 规定的特殊含义,比如 __init__、__len__。

例如 Parent 和 Child 都写 __value,实际会形成 _Parent__value 和 _Child__value 两个不同字段,可以同时存在于同一个子类实例中。这解释了名字改写为什么适合解决继承冲突:它区分定义来源,而不是阻止访问。知道改写后的名称,外部仍能读取,所以并不是真正的私有权限控制。

# 7. 异常处理与上下文管理面试题

# 7.1 try、except、else和finally的执行顺序是怎样的?

执行从 try 开始,接下来根据结果走不同路径:

  • 正常执行到底:跳过 except,进入 else,再执行 finally。
  • 出现异常且有匹配处理器:进入对应的 except,跳过 else,再执行 finally。
  • 异常没有匹配处理器:先执行 finally,再把异常向外传播。

try 通过 return、break、continue 提前离开时,不执行 else,但仍会先执行 finally。同一组 except 只处理 try 中的异常,不会再捕获 else 或某个 except 中新出现的异常。

所以,成功后才做、又不希望被这组 except 捕获的操作,可以放在 else;资源释放适合放在 finally。如果 finally 自己抛出新异常或改变控制流,最终结果也会随之改变。

# 7.2 try和finally中都有return时,最终返回什么?

最终以执行到的 finally 中的 return 为准。比如 try 准备返回 1,finally 又返回 2,调用方拿到的是 2。因为 try 中的返回表达式虽然已经算好,函数还要先执行清理代码;finally 发出的新返回指令覆盖了原来的待返回结果。

同样的覆盖还会影响异常:try 原本抛出的异常正在向外传播,finally 如果返回,异常就被压下了。CPython 3.14 会对离开 finally 的 return、break、continue 发出 SyntaxWarning,也是为了暴露这类容易隐藏错误的写法,返回语义本身没有改变。

如果 finally 没有新的 return,只是修改待返回的列表内容,返回的仍是同一个列表,调用方会看到修改;只把局部变量重新绑定到另一个列表,则不会替换此前已经确定的返回对象。

# 7.3 raise、raise e和raise ... from ...有什么区别?

  • 裸 raise:重新抛出正在处理的异常,保留原有回溯。记录错误后还要继续传播,通常用这种方式;没有正在处理的异常时,它会抛出 RuntimeError。
  • raise e:显式抛出异常对象,会把当前抛出位置加入回溯,不是清空原始回溯。
  • raise NewError(...) from e:抛出一个新异常,把 e 设为直接原因,适合把底层错误转换为业务错误,同时保留排查线索。

处理异常时直接抛出新异常,即使不写 from,通常也会保留隐式上下文;from e 是把原因显式关联起来。from None 则隐藏默认展示的原异常上下文,适合简化对外错误信息,但上下文对象并没有因此被删除。

# 7.4 Exception和BaseException有什么区别?为什么不建议随意捕获所有异常?

BaseException 是异常体系的根类。Exception 是它下面用于普通错误的主要分支,像 ValueError、TypeError、OSError 都在这一支;KeyboardInterrupt、SystemExit、GeneratorExit 则不属于 Exception。

因此,except Exception 不会拦住这些中断和退出,而裸 except: 或 except BaseException 会把它们一起捕获,可能造成用户按了中断却停不下来。asyncio.CancelledError 从 Python 3.8 起也继承 BaseException,捕获它做完清理后,通常应继续抛出,让任务取消传递下去。

捕获范围过宽还会混淆失败原因。比如文件读取失败,却被 except Exception: pass 吞掉,程序可能拿着不完整数据继续处理。捕获应对应可执行的恢复、转换或失败响应;纯粹清理资源可以用 finally、with,不必吞掉异常。

# 7.5 如何设计自定义异常?什么时候应该捕获,什么时候应该继续向上抛出?

自定义异常通常继承 Exception,按上层需要区分的失败原因组织。例如工具错误用一个共同基类,下面区分超时、参数不合法、权限不足;调用方既能统一捕获工具错误,也能针对超时决定是否重试。工具名称、错误码等信息可以放在字段里,不必让调用方解析错误字符串。

捕获位置取决于这一层能做什么:

  • 调用层能恢复临时故障,就捕获相应异常,进行有限重试。
  • 中间层需要把底层错误转成业务错误,就抛出自定义异常,并用 raise ... from ... 保留原因。
  • 请求入口负责对外响应,可以统一把未处理的业务错误转换成失败结果。

无法恢复或转换的层就继续传播。仅记录日志之后,通常还要裸 raise;释放资源交给 finally 或上下文管理器。如果直接返回默认值掩盖错误,调用方就无法判断操作是否失败。

# 7.6 with语句的底层原理是怎样的?

with 依靠上下文管理协议把进入、执行、退出组织起来,文件、锁、事务都可以使用这个协议。以 with manager as resource: 为例:

  1. 计算表达式取得管理器,按特殊方法规则找到 __enter__()、__exit__()。
  2. 调用 __enter__(),将其返回值绑定给 resource。返回值可以是打开的资源,不一定是管理器自身。
  3. 执行代码块。正常结束或通过 return、break 等离开时,调用 __exit__(None, None, None)。
  4. 若代码块抛出异常,将异常类型、异常对象和回溯传给 __exit__();它返回真值就抑制异常,否则异常继续传播。

__enter__() 自己失败时,还没有成功进入,这个管理器的 __exit__() 不会被调用,所以进入阶段获得部分资源后再出错,需要自行清理。多个管理器相当于嵌套 with:从左到右进入、从右到左退出,后面的进入失败,前面已成功进入的仍会退出。

# 7.7 __enter__()和__exit__()分别什么时候执行?__exit__()的返回值有什么作用?

__enter__() 在代码块前执行,通常获取资源;成功进入之后,离开代码块时执行 __exit__(),通常释放资源。返回、正常结束或代码块抛出异常,都要经过这个退出过程。

__exit__() 的返回值只在异常退出时决定异常是否传播:

  • 返回真值:表示管理器处理了异常,离开整个 with 块,继续后面的代码。
  • 返回假值或 None:异常继续向外传播。

抑制异常不会让程序回到出错的那一行;正常退出时返回值也不会改变原来的 return 等控制流。如果退出方法自己抛出异常,传播的就是新异常,所以不能为了“继续运行”就无条件返回 True,把写入或事务失败隐藏掉。

# 7.8 如何使用contextlib.contextmanager实现上下文管理器?

可以把生成器函数交给 @contextmanager,用一个 yield 把进入与退出逻辑分开,而不必手写管理器类:

  1. yield 前获取资源、执行进入准备。
  2. yield resource 把资源交给 as 后的变量,生成器暂停,执行权交给 with 代码块。
  3. 代码块退出后恢复生成器,执行退出逻辑;资源释放通常放在包围 yield 的 finally 里。

异常退出时,包装器会把异常抛入 yield 的暂停点,而不是简单从下一行往后跑。因此周围的 except 可以处理它,finally 仍会清理;如果捕获后没有重新抛出,就相当于抑制了代码块异常。生成器必须恰好产出一次,不能没有 yield,也不能再产出第二次;每次调用函数会创建新管理器,同一个生成器式实例通常不能重复进入。

# 8. 模块、类型注解与工程实践面试题

# 8.1 Python的模块和包有什么区别?import时会执行哪些代码?

模块是组织代码的基本单元,一个 .py 文件通常对应模块,也可以有内置模块和 C 扩展模块。包是能够包含子模块、子包的模块:普通包一般有 __init__.py,命名空间包则可以没有,也可以由不同目录共同提供内容。

首次加载 Python 源模块的主要过程是:

  1. 查找模块并创建模块对象。
  2. 先把对象登记到 sys.modules,这样导入过程中的再次访问能找到它。
  3. 执行模块顶层代码,建立其全局命名空间。
  4. 把模块或指定名称绑定到导入方的作用域。

顶层的赋值、打印、导入、类定义都会执行;函数定义会创建函数对象,但不会因此执行函数体。导入子模块时,还需要处理父包,普通包的 __init__.py 也可能随之运行。

所以模块顶层如果写了启动服务、加载大数据等操作,导入方仅仅 import 就会触发它们。这些启动工作通常应放在显式调用的入口函数中。

# 8.2 多次导入同一个模块,会重复执行模块代码吗?sys.modules有什么作用?

同一个解释器里,按同一个模块名正常重复导入,通常不会重复执行。第一次加载得到的模块对象放在 sys.modules 中,后面的导入命中这个缓存,就直接复用它。

它和 .pyc 解决的问题不同:sys.modules 缓存模块对象,.pyc 缓存编译后的代码。新解释器即使能读取 pyc,仍然要执行顶层代码;同一解释器命中模块对象缓存,才省去重新加载执行。

重新执行的常见情况有显式 importlib.reload()、删除模块缓存后再导入,或同一份代码以不同模块名加载。缓存范围限定于当前解释器及模块名,不能用来保证跨进程只执行一次。

# 8.3 import module和from module import name有什么区别?

  • import module 把模块对象绑定到当前作用域,使用时通过 module.name 查属性。
  • from module import name 先取得模块里的指定对象,再把它绑定到当前作用域的 name。

假设模块里的配置原来是整数 10,后面被重新赋值为 20。通过模块对象读取属性,会读到当前的 20;此前用 from 取得的本地名称仍然引用原来的 10,不会随模块赋值自动更新。

from 也不是复制对象。如果导入的是列表,双方最初指向同一列表,修改内容可以互相看见;模块把属性换成新列表,则只改变模块自己的绑定。使用模块前缀更容易辨认来源,也减少名称冲突;星号导入的名称来源不直观,更容易遮住已有变量。

# 8.4 什么是循环导入?为什么会出现“模块尚未初始化完成”的错误?

循环导入是模块依赖形成了环,例如 A 导入 B,B 又导入 A。出现环并不一定立即失败,真正的问题常发生在“模块已经存在,但代码还没执行完”的阶段:

  1. 开始导入 A,先创建 A 并放入 sys.modules。
  2. A 执行到导入 B,暂停自己的顶层流程,转去加载 B。
  3. B 又导入 A,缓存里已经有 A,因此取到这个尚未完成初始化的对象。
  4. B 如果此时读取 A 后面才定义的函数或变量,就找不到名称,出现 partially initialized module 一类错误。

常用的解决办法是把共同接口、数据类型提取到独立模块,理顺依赖方向。仅在函数调用时需要的导入也可以放进函数,延后加载;改成 import A 并延后访问 A.name 有时能避开初始化阶段,但如果仍在顶层立即访问,问题没有解决。

# 8.5 if __name__ == "__main__"有什么作用?

它把程序入口逻辑与被导入时提供的定义区分开。直接运行文件,或通过 python -m 包.模块 运行时,入口模块的 __name__ 是 "__main__",判断内的启动逻辑会执行;正常导入时是模块名,就跳过这段逻辑。

这样一个文件既能提供可复用函数,也能单独运行。判断只保护里面的代码,外面的顶层语句仍会在导入时执行;通常把入口收进 main(),判断内只负责调用。使用 spawn 启动多进程时也需要这种保护,避免子进程重新加载入口相关代码后再次创建进程。

# 8.6 为什么需要虚拟环境?如何管理项目依赖,保证环境可以复现?

比如项目 A 依赖某库的旧版本,项目 B 依赖新版本,安装在同一个环境就可能互相覆盖。虚拟环境让各项目拥有自己的第三方依赖安装位置,减少这类冲突。它基于选定的 Python 解释器,不是操作系统容器,也不会自动选择项目的 Python 版本。

隔离环境解决“装在哪里”,要复现还需要说明“装什么”:

  • 记录 Python 版本,以及安装相关的平台信息。
  • 用 pyproject.toml 等文件声明项目依赖,再锁定直接与间接依赖,按锁定结果安装。
  • 在干净的目标环境验证,严格要求下还要固定分发来源并校验哈希。

python -m pip 可以明确使用当前解释器对应的安装工具,减少装错环境。pip freeze 只是当前已安装版本的快照,不自动具备跨平台完整锁定能力;虚拟环境目录本身也不能替代依赖清单。

# 8.7 Python类型注解会在运行时强制检查类型吗?Any、Optional和Union分别是什么意思?

不会。普通注解供阅读、IDE 和静态类型检查器使用,不会自动拦截不符合注解的参数。运行时是否报错取决于实际操作,或者是否使用了显式检查、验证库。

  • Any 放宽这个位置的静态类型约束。object 也能接收任意对象,但类型检查器仍会限制只能使用所有对象共有的操作,两者不同。
  • Optional[T] 等价于 T | None,表示值可以是 None。
  • Union[A, B] 表示值可以属于这些类型之一,Python 3.10 起也可以写成 A | B。

Optional 说的是可接受的值,不是调用时能否省略参数。name: str | None 仍要求传参,只有再写默认值,比如 name: str | None = None,才允许省略。

# 8.8 dict、TypedDict、dataclass和Pydantic模型有什么区别?如何选择工具调用的参数结构?

它们处理的是不同层次的问题,是否存在运行时验证是选择工具参数结构的关键。

方式 运行时形态 主要作用 默认按字段类型校验
dict 字典 灵活存储键值 不校验
TypedDict 普通字典 为字典字段提供静态类型描述 不校验
dataclass 类实例 自动生成初始化、比较等方法 不校验
Pydantic 模型 模型实例 验证、转换、序列化、生成 Schema 按模型规则验证

内部可信数据只需要键值访问,可以用 dict,再用 TypedDict 明确结构;要表达对象状态和行为,可以用 dataclass。用户或模型输出的工具参数属于外部输入,缺字段、类型错误、范围越界都需要在调用入口真正检查,Pydantic 等验证方案更适合这个边界。

Pydantic 默认可能做类型转换,是否启用严格模式、是否允许额外字段取决于配置。v2 可以用 model_validate() 验证、model_dump() 导出;生成的 JSON Schema 用来描述格式,也不能代替实际执行验证。

# 8.9 read()、readline()和readlines()有什么区别?如何处理大文件,避免一次性读入内存?

  • read() 返回读到的内容,不指定大小时通常读取全部剩余内容。
  • readline() 返回一行,通常保留换行符;文本模式下空白行通常是 "\n",文件末尾才返回 ""。
  • readlines() 把读取的多行保存为列表,不指定限制时通常也会读完剩余内容,还需要行列表的存储空间。

处理大日志一般直接迭代文件,读取一行、处理一行,而不是先调用 readlines()。如果单行本身就非常大,可以用 read(size) 分块,并处理跨块边界;文本模式通常以字符为单位,二进制模式以字节为单位。

流式读取还要求后续处理不要把全部结果重新积累成列表,否则只是把内存开销移到了下一步。文件的关闭可以交给 with。

# 8.10 JSON序列化与反序列化是什么?dump()、load()和dumps()、loads()有什么区别?

序列化把 Python 数据转换成 JSON 文本,反序列化把 JSON 文本解析成 Python 数据。方法名中的 s 可以理解为字符串接口,不带 s 则直接对文件对象读写。

方法 方向 输入或输出
dumps(obj) Python → JSON 返回字符串
loads(text) JSON → Python 接收字符串,也支持适当编码的 bytes/bytearray
dump(obj, file) Python → JSON 写入文件对象
load(file) JSON → Python 从文件对象读取

JSON 对象通常对应字典,数组对应列表,null 对应 None,但它不包含 Python 的全部类型。比如元组转换后再解析会变成列表,对象键也受 JSON 字符串键规则约束,往返转换不保证原始类型完全一致。

ensure_ascii=False 让中文直接保留在文本里,写文件时仍需选对编码。格式错误会抛出 JSONDecodeError;解析成功只证明文本符合 JSON 格式,工具参数的字段类型和业务范围还要另行验证。

# 9. 多线程、多进程与GIL面试题

# 9.1 进程、线程和协程有什么区别?

进程是操作系统分配资源和隔离运行环境的单位,线程是进程中的执行单位。一个进程可以有多个线程,它们共享地址空间,但各自有执行栈等状态。协程则由程序保存执行状态,在需要等待时暂停,之后再从原位置恢复。

比较项 进程 线程 asyncio协程
调度者 操作系统 操作系统 事件循环
内存关系 通常拥有独立地址空间 共享同一进程的内存 通常共享所在循环线程的内存
交换数据 IPC、共享内存等 共享对象,配合同步 参数、队列或共享对象
开销 启动、内存和通信成本较高 有线程栈与切换成本 通常较轻,依赖协作调度
常见用途 并行计算、运行隔离 同步I/O任务 大量异步I/O任务

比如多个工具请求等待网络响应,协程可以在同一个线程里交替推进,这属于并发。让多个核心同时计算则属于并行,单个事件循环线程不会因为增加协程,就同时执行多份纯Python计算。

# 9.2 CPU密集型和I/O密集型任务,应该如何选择并发方式?

选择并发方式主要看任务的时间花在计算,还是花在等待,还要看依赖库提供什么接口。

  • CPU密集型:在启用GIL的CPython中,较重的纯Python计算通常用进程池利用多核。但任务太小、参数太大时,启动、序列化和通信可能抵消收益,所以应先减少算法复杂度和重复计算。会释放GIL的C扩展计算也可以评估线程;自由线程构建则要结合依赖兼容性重新测量。
  • I/O密集型:同步网络或数据库接口适合用线程池;依赖提供异步接口、需要管理大量在途请求时,asyncio能在等待期间处理其他任务,并集中管理超时和取消。

实际服务也可能是混合负载,例如Agent的工具调用主要等待网络,文档处理又要做计算,可以把异步I/O留在事件循环,把重计算提交到进程池。并发量应与CPU、内存、连接池和上游限额匹配,积压过多会增加延迟。

# 9.3 什么是GIL?CPython为什么需要它?

GIL是全局解释器锁。在启用GIL的CPython中,同一解释器内的线程执行Python代码时需要取得这把锁,同一时刻通常只有一个线程能执行。

它与CPython的对象管理方式有关。一个对象的引用计数、容器和其他运行时状态可能被多个线程访问,未经协调的同时修改会产生竞争。传统实现用GIL串行化许多共享状态访问,也让大量C扩展能在持有GIL的前提下使用解释器API,降低各处分别加锁的复杂度。

线程运行时并不是一直占着GIL。执行可释放GIL的阻塞I/O时,线程把锁交出去,其他线程就能执行Python代码;部分C扩展也会在长时间计算中释放GIL。因此,网络等待可以重叠,原生计算还可能并行,但传统构建中的多个纯Python计算线程通常无法同时用满多个核心。

GIL承担的是解释器层面的协调。余额查询、扣减、写回等业务步骤仍需要业务锁或其他同步机制保证整体正确。Python 3.13起提供可选自由线程构建,Python语言本身并不要求所有实现都采用GIL。

# 9.4 在启用GIL的CPython中,多线程还能提升哪些任务的性能?

主要能提升两类任务的性能:

  • 大量时间花在I/O等待上。三个接口分别等待一秒,串行请求要逐个等;多个线程可以重叠网络等待,总耗时有机会接近最慢的请求。连接池容量和上游处理能力也会影响能重叠多少。
  • 底层计算会释放GIL。某些数值运算、压缩等原生操作会让出GIL,线程可能在不同核心上执行这些操作,前提是所用库和具体操作确实支持这种行为。

纯Python计算在传统GIL构建中通常无法这样获得多核加速。线程数增加还有调度和内存成本,不适合把所有任务都无约束地拆成线程。

# 9.5 有GIL就一定线程安全吗?count += 1为什么仍然需要考虑同步?

有GIL仍然需要保护共享业务状态。count += 1对整数计数器来说,逻辑上包含读取旧值、计算新值、写回绑定,Python语言没有承诺这整个过程原子化。

例如计数原来是10,两个执行者都读取10,各自算出11再写回,最终就可能只增加一次。这描述的是读改写竞争;某个CPython版本上的短整数循环未必能直接复现,因为解释器切换时机、优化和对象实现都会影响表现。

保护计数器时,应让所有访问者使用同一把锁,并把完整的读改写过程放在临界区内。“检查键是否存在,再插入”等操作也需要保护完整逻辑,仅锁住最后一次赋值不能防止两个线程依据同一个旧状态作出决定。

不能仅凭反汇编中有多个指令,就认定线程一定会在任意两条指令间切换,也不能凭一次结果正确,就认定操作有线程安全保证。

# 9.6 Lock和RLock有什么区别?什么情况下会发生死锁?

两者都是互斥锁,区别是同一线程能否重复获取:

  • Lock不可重入。线程已经获取锁,再次阻塞获取同一把锁,会等待锁被释放。
  • RLock可重入。它记录持有线程和获取次数,同一线程再次获取会增加计数;释放次数与获取次数相匹配,计数归零后其他线程才能进入。

外层函数持锁后,调用一个也要获取同一把锁的内部函数,用Lock可能使线程等待自己;用RLock可以支持这种嵌套调用。

线程间的死锁则是另一种依赖:A持有锁X等待锁Y,B持有锁Y等待锁X,双方都在等待对方释放。RLock不能解决这个环。多把锁按统一顺序获取,可以破坏循环等待;用with保证释放、缩短临界区、避免持锁等待I/O或其他任务,也能减少锁依赖问题。

# 9.7 多线程之间如何安全地传递任务和结果?queue.Queue有什么作用?

queue.Queue把线程同步放进了队列内部,适合生产者提交任务、消费者取出处理。空队列可以让消费者等待,满队列可以让生产者等待;正数maxsize限制排队元素数量,让消费速度反过来约束生产速度。

一次任务的流转涉及几个不同动作:

  1. put()将任务入队,同时增加未完成任务计数。
  2. get()将任务取走,队列槽位空出来,但任务还没有处理完成。
  3. 消费者处理结束后调用一次task_done(),减少未完成计数。
  4. join()等待未完成计数归零,等的是处理完成,不只是队列为空。

任务可能在出队后失败,完成通知也要与每次取出相匹配,通常放在finally里。消费者结束时可使用约定的结束信号,并保证每个消费者都能退出。

队列保护的是入队、出队和等待等操作。如果生产者和消费者继续同时修改同一个可变元素,仍需要约定对象所有权或另加同步。

# 9.8 多进程之间如何通信?为什么进程池中的参数和返回值经常需要能够被序列化?

进程一般拥有独立地址空间,交换数据需要进程间通信。常见方式有:

  • Queue、Pipe:把数据发送到另一进程,适合任务和结果传递。
  • 共享内存、Value、Array:多个进程访问同一块数据,适合减少大数据复制,但同步和资源释放需要管理。
  • Manager代理:通过管理进程操作共享对象,使用方便,也有代理通信成本。

进程池提交任务时,通常先pickle序列化函数和参数,经通信通道传到工作进程,再反序列化执行;结果或异常也要传回。因此,任务函数通常定义在可导入模块的顶层,参数和返回值应可序列化,不能把局部函数、活跃连接和打开的文件当成普通数据随意发送。

创建进程的入口一般放在if __name__ == "__main__":中,避免子进程导入模块时再次创建进程。Windows和macOS通常默认spawn;Python 3.14在其他支持forkserver的POSIX平台默认改为forkserver,fork不再是任何平台的默认方式。即使用fork启动,之后向进程池提交任务仍涉及序列化。

# 9.9 线程池和进程池有什么区别?Future是什么?

线程池和进程池都复用工作执行者,减少每个任务重复创建资源的成本,区别在于执行者和内存关系。

比较项 线程池 进程池
工作单元 同一进程内的线程 多个工作进程
数据交换 共享内存,传对象较直接 通常要序列化和通信
常见用途 同步I/O、释放GIL的原生计算 传统GIL构建中的重纯Python计算
主要代价 共享数据竞争、线程调度 额外内存、启动与传输成本

提交过程可以理解为:submit()接收函数和参数,返回一个Future;执行资源调度任务,任务结束后把结果或异常存入Future。调用者拿到Future时,任务可能还没有开始。

future.result()会等待结果,任务失败时重新抛出对应异常;等待超时不会终止正在执行的函数。cancel()通常只能取消尚未开始的任务。若池内任务占着工作线程,又等待同一池中的其他任务,而池已经没有空闲执行资源,可能形成死锁。

这里是concurrent.futures.Future。asyncio.Future未完成时调用result()会报状态错误,应在事件循环里通过await等待,不能拿线程池Future的阻塞接口直接卡住循环。

# 9.10 自由线程构建与传统GIL构建有什么区别?关闭GIL后还需要加锁吗?

自由线程构建提供多个线程并行执行Python代码的能力,传统构建则依赖GIL协调许多解释器操作。Python 3.13起提供这种可选构建,普通安装升级版本不会自动变成自由线程构建。

为支持并行,运行时调整了引用管理、内存分配和对象访问机制,内置容器也有相应内部保护。性能表现会随负载变化,单线程开销、内存占用和C扩展兼容性都可能不同;未声明支持的扩展还可能重新启用GIL。

关闭GIL仍需要业务锁。 比如库存只剩一件,两个线程都先判断“库存大于零”,再分别扣减,容器内部保护不能把这组步骤变成一笔事务。需要同步的是“检查条件并完成更新”这个整体,或者把状态集中交给一个执行者管理。

# 10. asyncio异步编程面试题

# 10.1 Python的协程是什么?与线程有什么区别?

协程能在暂停时保留局部变量和执行位置,再从原位置恢复。现代Python通常用async def定义原生协程,用await参与异步计算;协程对象本身不对应一个操作系统线程。

线程由操作系统调度,而asyncio通常在同一个事件循环线程里协作调度多个任务。例如任务A等待工具响应时,把控制权交回循环,循环推进B;A的响应就绪后,再安排A继续。这样可以用较少的线程组织大量在途I/O。

这种调度依赖任务及时返回控制权。耗时计算或同步阻塞调用会占住循环线程,拖慢其他协程。共享状态的读写如果被实际挂起点分开,也可能发生交错更新,需要asyncio.Lock或队列协调。

# 10.2 调用async def定义的函数时,会立即执行函数体吗?

不会。普通原生协程函数调用时先返回协程对象,函数体要交给运行机制推进才执行。

await coroutine()沿当前Task执行它;create_task(coroutine())将它包装成独立调度的Task;asyncio.run()可以作为程序入口运行协程。仅创建对象,之后既不等待也不调度,通常会产生“协程未被等待”的警告。协程对象结束后不能再次执行,需要重新调用函数创建对象。

含yield的async def定义的是异步生成器。急切任务工厂和Python 3.14的eager_start可以让创建Task时就推进协程,但只调用普通协程函数,仍然先得到对象。

# 10.3 await的作用是什么?每次执行await都一定会切换任务吗?

await取得可等待对象的结果,也传播它的异常。它是否交还控制权,取决于这次等待有没有实际挂起。

  • Future还未完成:当前Task暂停,等待结果就绪后恢复,循环可以在此期间处理其他任务。
  • Future已经完成,或被等待协程一路执行而没有挂起:可以直接取得结果,当前Task继续运行。

比如循环中每次await都立即命中已完成的缓存结果,仍可能连续占用线程很久。asyncio.sleep(0)可以主动让出执行机会;较重的计算则需要分批处理或移出事件循环。

# 10.4 asyncio的事件循环是如何调度协程的?

asyncio事件循环负责等待外部事件、执行就绪回调,再由回调推进Task。以CPython默认实现为例,循环会管理就绪队列、计时器和I/O事件。

一个等待网络响应的任务,大致经过以下过程:

  1. 循环执行推进Task的回调,协程开始运行。
  2. 协程等待一个未完成的Future,Task挂起,并登记结果完成后的唤醒机制;这一段执行交还控制权,循环能处理其他回调。
  3. 网络事件就绪,底层回调读到数据并完成相应Future。
  4. Future完成后安排Task的唤醒回调,Task再次获得执行机会,继续运行到下一个实际挂起点、返回或异常。

计时器到期也会使相关回调进入执行流程。就绪队列里的基本单位是回调,Task通过这些回调推进,不是简单地把全部协程按固定时间片轮流运行。不同平台的底层I/O机制可以不同。

协程一旦开始执行,循环不会自动抢占它。一个回调或协程片段执行太久,会延迟其他I/O处理、定时器和取消请求,因此异步程序中的计算和同步调用也会影响整个循环的响应速度。

# 10.5 协程对象、Task和Future有什么区别?

三者可以沿“描述计算、推进计算、保存结果”来区分。

对象 职责 常见来源
协程对象 保存可被推进的执行状态 调用普通协程函数
Task 驱动协程,管理完成和取消 asyncio.create_task()
Future 保存将来可用的结果、异常与完成通知 底层异步接口、loop.create_future()

协程对象单独存在时不会自动进入调度,Task将它交给事件循环。Task本身又是Future的一种具体形式,所以也能被等待、检查状态和添加完成回调。普通Future通常不负责执行计算,由其他过程完成后设置结果。

应用层常用协程和Task,底层则用Future把回调接口接到await上。asyncio.Future.result()在未完成时会报错,与可以阻塞线程等待的concurrent.futures.Future.result()不同。

# 10.6 连续await两个任务,与先创建两个Task再等待,有什么区别?

如果是两个尚未调度的协程,先等A完成再调用B,就会串行。先创建两个Task,A和B都进入调度,等待I/O的时间就有机会重叠。

# 以下写法位于协程函数内部:A结束后才开始B
await fetch("A")
await fetch("B")

# A、B先进入调度,等待可以重叠
a = asyncio.create_task(fetch("A"))
b = asyncio.create_task(fetch("B"))
await a
await b

后半段虽然按A、B的顺序获取结果,但B不需要等A结束才执行。已经在运行的Task被连续await,也不等于它们从此串行。

两个Task独立创建后,还需要处理失败路径:若等待A抛异常,B可能仍在执行,应按任务关系继续等待或取消并收尾。TaskGroup可以把这组生命周期放在同一个作用域管理。

# 10.7 asyncio.gather()和TaskGroup有什么区别?子任务异常时如何处理?

gather()偏向结果收集,TaskGroup偏向把一组相关任务的生命周期放在同一作用域管理。TaskGroup从Python 3.11开始提供。

行为 gather() TaskGroup
成功完成 按传入顺序返回结果列表 退出上下文前等待任务,结果从各Task获取
一个任务普通失败 默认立即向调用者传播首个异常,其他任务继续 取消未完成兄弟任务并等待清理,再汇总异常
允许独立失败 可用return_exceptions=True收集异常 非取消异常通常导致整组失败

比如A、B、C一起执行,A抛出ValueError:默认gather把这个异常交给等待它的调用者,B和C不会因此自动停止。调用者必须决定继续等待它们,还是取消并收尾。return_exceptions=True会等待并将异常对象放入结果,调用方要区分成功与失败。

TaskGroup的处理链条更完整:A失败后,向未结束的B和C请求取消;等待它们执行finally等清理;收集非取消异常;退出时通常抛出ExceptionGroup。这样上下文结束后,不会把组内任务遗留在后台。KeyboardInterrupt、SystemExit有特殊传播规则,不按普通异常组处理。

取消与普通失败也要区分。gather自身被取消,会向未完成子任务传播取消;单个子任务取消,不会自动取消其他任务。TaskGroup里的单个子任务取消,也不等于普通失败触发全组取消。主协程退出时,asyncio.run()清理未完成任务的行为,应与gather本身的异常规则分开。

# 10.8 为什么在异步函数里调用time.sleep()或同步网络请求,会拖慢整个服务?

同步调用会阻塞事件循环所在的线程。比如一个请求在协程里执行同步网络调用,卡住五秒,这五秒里同一循环的其他请求回调、计时器和取消处理都可能只能等待。

time.sleep()即使释放了GIL,也只是允许其他操作系统线程运行;当前循环线程仍在休眠,它负责调度的协程不会因此继续执行。async def也不会改造函数内部的同步操作。

处理方式对应实际工作:等待时间用await asyncio.sleep(),网络或数据库调用用异步客户端;只有同步接口时交给受控线程池。较重的纯Python计算通常要优化算法或交给进程池,避免持续占住循环线程。

# 10.9 如何使用asyncio.to_thread()或执行器调用同步函数?CPU密集型任务应该怎么处理?

同步函数可以通过await asyncio.to_thread(func, ...)交给线程执行,Task在等待结果时把执行机会交回事件循环。to_thread()还会把当前contextvars上下文传播到执行线程,适合同步文件、网络等调用。

需要指定池时,用loop.run_in_executor(pool, func, ...),执行器可以选择线程池或进程池。它与to_thread()的上下文传播行为不能直接等同。

CPU密集型要区分计算实现。 在启用GIL的CPython中,纯Python计算转入线程通常不能获得多核加速;它避免了直接在循环线程里连续计算,但仍可能与循环争用GIL。较重任务可以考虑进程池,释放GIL的C扩展或自由线程构建则另行评估。

取消等待方也不会杀死已经运行的线程函数。同步客户端需要自己的超时,必要时通过协作停止信号结束工作;池大小和提交量都应受控,避免请求已取消而后台工作继续积压。

# 10.10 如何为工具调用设置超时?任务取消是如何生效的,为什么需要清理资源?

asyncio.timeout()适合给一段异步流程设置时间预算,wait_for()适合限制一个可等待操作。timeout从Python 3.11开始提供。

以timeout为例:进入上下文后开始等待工具结果;预算到期,事件循环请求取消当前Task;Task在后续合适的执行机会收到CancelledError,展开异常处理和资源清理;退出超时上下文时,由它触发的取消转换为TimeoutError,通常在上下文外捕获。

Task.cancel()采用协作取消。任务正在同步阻塞或持续计算时,事件循环无法及时推进取消,所以时间预算不保证在到期瞬间结束。wait_for()还可能等待被取消任务完成清理,使实际返回时间超过设定值。

连接、文件和锁的清理放在finally、with或async with中。CancelledError继承BaseException,普通except Exception不会捕获它;如果显式捕获取消,通常在清理后重新抛出,让外层正常感知取消。

本地等待结束后,线程函数或远程服务仍可能继续执行。同步库需要自身超时,远程写操作还需要幂等机制和结果确认,防止重新调用造成重复副作用。

# 10.11 如何使用Semaphore和有界队列控制并发,避免一次创建过多任务?

两者限制的对象不同:Semaphore控制同时进入操作的任务数,有界队列控制等待处理的数据量。

给工具调用加上容量为10的Semaphore,就能让最多10个任务同时进入这段调用,但如果预先创建十万个Task,其余Task仍然存在,只是在等待许可。它们的上下文和参数也会占内存。

大量输入更适合用有界队列配合固定worker:生产者边读边put(),队列满时等待;固定数量的worker逐个get()并处理,结束后调用task_done()。这样Task数量和排队量都有上限,任务失败、worker退出与清理需要统一管理,结果也要及时写出,避免另一端积压。

并发上限控制在途请求数,QPS限制控制单位时间内的调用次数,二者需要分别管理。

# 10.12 contextvars有什么作用?如何在并发请求中隔离请求ID、用户信息等上下文?

请求A设置全局请求ID后暂停,请求B又覆盖这个变量,A恢复时就可能读到B的ID。contextvars通过为不同执行上下文维护各自的变量绑定,解决这类上下文串扰。

asyncio创建Task时通常复制当前上下文,切换Task时恢复它对应的上下文。所以两个请求即使在同一个线程里交替执行,也能读取各自的ID;线程局部变量只能按线程隔离,无法单独区分这些协程请求。

一次请求可以用ContextVar.set()设置值,保存返回的token,在finally里reset(token)恢复原绑定。这样同一Task后续处理不会残留上一段请求信息。

上下文复制不会深拷贝绑定对象。如果多个Task继承的是同一个可变字典,修改字典内容仍然共享;因此请求数据应独立绑定,能用不可变值时更容易控制共享关系。

# 11. 内存管理与垃圾回收面试题

# 11.1 CPython中的对象是如何组织和分配内存的?为什么一个整数占用的内存不只是数值本身?

CPython把整数存成对象,一个整数的内存至少要承担几部分内容:对象头中的类型和引用管理信息、数值表示所需的元数据、实际数值段。Python整数支持任意精度,数值增大时会增加数值段,不能始终按4或8字节估算。

把整数放进列表,还要加上列表对象和保存引用的数组。列表保存的是对象引用,重复引用同一个整数不会重复创建那份整数对象,所以直接把每个槽位的元素大小相加也可能重复计算。

对象创建时向相应分配器申请空间,分配器可以复用已有区域,减少频繁向操作系统申请的成本。传统GIL构建常用pymalloc处理小块分配,自由线程构建使用不同的默认分配器,布局随版本和构建变化。

sys.getsizeof()只报告对象直接占用的大小,不递归累加引用对象,也无法直接代表整个进程的RSS。

# 11.2 什么是引用计数?哪些操作会增加或减少对象的引用计数?

引用计数记录对象被多少个强引用持有,是传统CPython对象生命周期管理的基础机制之一。

  • 给已有对象再绑定一个名字、加入容器或保存到属性中,通常会增加引用。
  • 删除绑定、移除容器元素、覆盖属性,以及局部变量结束生命周期,通常会释放引用。
  • 弱引用可以观察对象,但不会按强引用的方式延长对象寿命。

普通对象的最后一个强引用释放后,通常进入清理流程。若对象之间形成不可达引用环,仅靠计数无法降到零,还需要循环GC。

计数也不等于源码中的变量个数,容器、属性和解释器临时引用都会参与。sys.getrefcount()调用本身可能增加临时引用;不朽对象和自由线程构建还有不同管理机制,读数不能当成所有对象的统一生命周期承诺。

# 11.3 什么是循环引用?为什么单靠引用计数无法回收循环引用?

假设A引用B,B又引用A,外部变量也引用A。删除外部变量后,A和B虽然已经没有外部使用者,但仍各自被环中的另一个对象引用,计数不会降到零。这就是循环引用难以通过引用计数释放的原因。

引用计数只反映“还有引用”,不能分辨引用是来自活跃代码,还是来自同一批废弃对象。循环GC进一步分析可达性,找到不再受外部引用支持的对象,再清理内部引用。

只要全局变量或其他活跃对象仍能到达A和B,它们就应当保留。形成环与成为垃圾是两个条件,可达的环不会因为互相引用就被回收。

# 11.4 CPython的循环垃圾回收机制是怎样的?分代回收解决了什么问题?

CPython循环GC补充引用计数,处理受跟踪对象中不可达的引用环。以传统GIL构建的简化流程来说,它需要区分候选对象受到的引用来自候选内部,还是来自外部。

  1. 选取候选对象,为分析准备临时引用信息。
  2. 排除候选之间的内部引用。从临时计数中扣除候选对象相互提供的引用,真实引用计数并不会因此被直接修改。
  3. 保留仍然可达的对象。仍有外部引用支持的候选是起点,沿它们的引用继续保留能够到达的其他候选;某个对象临时计数为零,也可能被这些活跃对象间接引用。
  4. 清理剩余不可达对象。解除它们的内部引用,配合对象清理和引用管理释放这批垃圾。

分代解决的是反复扫描的成本。新对象中大量是临时数据,生命周期很短;经历回收仍存活的对象往往还能继续存活,将它们放到较少扫描的代,可以让小规模回收不必反复检查全部长期对象。对象所在的代主要反映它经历回收后的存活情况,不能直接按创建时间的秒数理解。

具体布局随版本和构建变化:Python 3.11—3.13传统构建通常有0、1、2三代;3.14.0—3.14.4取消中间代,3.14.5起恢复generation 1和threshold2,相关行为回到3.13的方式。自由线程构建还采用不同机制,不能将传统构建的扫描过程套到所有构建。

# 11.5 del删除的是变量还是对象?执行后对象一定会立即释放吗?

del执行的是删除操作,目标不同,含义也不同:

  • del name删除名字绑定。
  • del obj.attr删除属性,可能调用对象的属性删除协议。
  • del items[index]删除容器元素,可能调用相应下标删除协议。

两个名字指向同一个列表时,删除其中一个名字,另一个仍持有它,列表不会消失。即使删除最后一个外部绑定,也还要看容器、闭包或后台任务有没有其他引用。

传统CPython中,普通对象失去最后强引用后通常进入释放路径;不可达循环引用可能等待GC,终结方法还可能让对象复活。其他实现和自由线程构建也有不同释放时机。

文件和连接用with或显式关闭来管理。对象是否已经销毁、外部资源是否已经关闭、底层内存是否归还系统,各有自己的条件,不能把正确性押在del立即析构上。

# 11.6 为什么删除大量对象并执行GC后,进程的内存占用可能没有下降?

对象已经释放后,分配器仍可能把内存保留起来,供下一次申请复用。因此,GC之后RSS不降,未必是对象没有被回收。排查时有几种来源需要区分:

  • 对象仍被持有:缓存、后台Task、异常traceback等保留了引用,GC不会清理仍可达的数据。
  • 分配区域和碎片:对象已释放,但空闲块还在分配器中。以pymalloc为例,一个较大区域里只要还有存活对象,就可能无法把整块区域归还。
  • 其他进程内存:RSS还包含线程栈、原生扩展、映射等,Python循环GC并不负责回收其中的所有分配。

重复稳定负载后,如果存活对象量回落,RSS停在高位并被后续请求复用,可能只是分配器行为;如果对象和内存持续积累,就要追查保留路径。tracemalloc的跟踪数据与RSS范围不同,单次gc.collect()后的读数不足以判断泄漏。

# 11.7 有垃圾回收,为什么仍然会发生内存泄漏?缓存、全局变量和后台任务会带来哪些问题?

垃圾回收依据对象是否仍然可达,不会判断业务上是否还需要它。全局字典里一直保存旧请求结果,这些结果对程序仍然可达,GC保留它们正是正常行为。

常见保留路径和处理方式包括:

  • 缓存、注册表、全局容器持续增长:设置容量、过期或淘汰策略,删除已经失效的记录。
  • 后台Task和完成记录保留请求上下文、异常或大文档:任务完成后移除记录,退出时取消并等待后台任务收尾;长期闭包也可能保留不再需要的数据。
  • 无界队列生产快于消费:限制容量,让生产者等待,及时处理和释放结果。

定位这类问题,要找到“谁仍持有对象,以及本来应在什么时机释放”。原生扩展未正确释放内存也会造成增长,但不一定进入Python循环GC管理范围,反复手动GC无法代替对应的资源管理。

# 11.8 如何使用tracemalloc等工具定位Python程序的内存增长?

tracemalloc记录Python分配的位置和调用栈,定位增长时主要比较同等工作阶段的两份快照。

  1. 在关键分配发生前启动跟踪,设置需要的栈深度。
  2. 完成预热,记录基线快照,减少初始化分配的干扰。
  3. 重复固定量的请求,等待预期业务清理完成后记录第二份快照。
  4. 用compare_to()按行或traceback看分配大小和数量的增量,找持续保留的热点。
  5. 结合缓存、Task数量和引用关系,追查这些分配为什么没有结束生命周期。

某行创建大量对象,不代表错误就在那一行。辅助函数生成结果,真正保留它们的可能是调用方的全局列表,分配栈与持有者都需要分析。

启动前分配和部分原生分配可能不在跟踪范围内。RSS持续上涨、所跟踪的Python分配却稳定时,应扩大到原生库、线程和映射等来源;加深记录栈也会增加跟踪开销。

# 12. CPython执行原理与性能优化面试题

# 12.1 Python代码从源文件到执行,经历了哪些步骤?

CPython运行源码前,会先将它编译成代码对象,再执行其中的指令。流程可以概括为:

  1. 读取源码并解析:解码文本,做词法和语法分析,形成AST。
  2. 分析并编译:处理作用域与符号,生成和优化中间指令,得到包含字节码、常量等信息的代码对象。
  3. 建立执行帧并运行:结合局部变量、模块全局命名空间等状态推进指令,完成运算、分支和函数调用。

compile()负责生成程序表示,exec()等机制才执行它。执行函数定义会创建函数对象,函数体通常到调用时执行;导入模块还可能利用有效编译缓存省去源码编译。

字节码和优化流程属于CPython实现,版本更新或可选JIT构建可以改变具体执行细节。

# 12.2 什么是字节码?.pyc文件有什么作用,能让程序运行得更快吗?

字节码是CPython使用的中间指令表示,.pyc保存编译后的代码对象缓存。它主要减少模块加载时的编译工作,进入同一段业务逻辑后,不会只因为用了pyc就自动加速计算。

首次导入源码模块时,解释器通常编译并运行模块,同时把缓存写入__pycache__。新解释器中再次导入,若缓存符合解释器版本和源文件校验要求,就可以直接加载代码对象;过期则重新编译。没有写缓存的权限或关闭缓存,通常仍能执行源码。

同一解释器里已导入模块的缓存属于另一层:通常直接复用模块对象,不必再加载pyc和执行模块。pyc也不是CPU机器码,通常不能跨Python版本通用。

# 12.3 如何使用dis查看字节码?它能帮助解释哪些代码行为?

调用dis.dis(function)可以查看函数字节码,dis.get_instructions()可以逐条读取指令信息。它把代码对象中的加载、运算、跳转、调用和返回过程显示出来。

比如分析短路求值,可以看某个条件怎样跳过右侧表达式;分析作用域和闭包,可以看变量从哪里读取。观察的是当前CPython版本的编译结果,指令名称、布局和特化方式都可能随版本改变。

字节码能辅助解释行为,但性能仍要测量实际调用次数和耗时;线程安全仍要确认同步保证。多条指令并不证明线程会在任意两条之间切换,指令少也不必然执行得更快。

# 12.4 CPython的小整数缓存和字符串驻留是什么?为什么不能依赖它们使用is比较值?

两者都通过复用对象减少创建和存储成本:

  • 小整数缓存:CPython常见地复用-5到256的整数对象,因此某些相等整数可能也是同一个对象。
  • 字符串驻留:部分名称等字符串会自动复用代表对象,也可以用sys.intern()显式取得驻留字符串。

这些身份表现会受解释器、创建方式、编译上下文和常量合并影响,不能当作Python语言的通用值比较规则。is判断对象身份,==判断相等;业务数字和文本用==,判断None等明确单例时用is。

# 12.5 Python程序运行缓慢时,如何区分算法、CPU和I/O问题?如何使用性能分析工具定位?

性能排查先用稳定输入记录总耗时、CPU情况和关键阶段耗时,再缩小范围:

  • 输入规模增加后耗时增长很快,检查算法复杂度和重复遍历。
  • CPU持续忙,定位计算热点和重复工作。
  • 总耗时高、CPU耗时低,检查网络、数据库、锁竞争和排队等待。

cProfile可以统计函数调用次数与耗时。tottime是函数自身耗时,cumtime包含下层调用;一个函数的累计时间很高,可能是它调用的网络接口或子函数耗时,需要继续沿调用链分析。

局部写法可以用timeit重复比较,服务请求则要记录本地处理、外部等待和结果处理等阶段。先减少算法与重复I/O成本,再评估缓存、批量操作和并发,修改前后用相同负载复测整体延迟与吞吐量,避免把微基准收益直接等同于服务收益。

# 12.6 functools.lru_cache的原理和适用场景是什么?缓存会带来哪些内存和数据一致性问题?

lru_cache以可哈希的调用参数组织缓存键,保存函数结果。LRU表示最近最少使用:例如容量为2,依次访问A、B、A,再插入C,A刚被访问过,淘汰的就是B。

CPython常见的有界实现用字典定位记录,用双向链表维护使用顺序。命中后把记录移到最近使用的一端;未命中则计算并保存;容量不足时移除最旧的记录。maxsize=None取消容量限制,也就没有有界淘汰。

它适合输入重复、结果可以复用的计算,比如固定配置下的解析或纯函数计算。普通async函数返回协程对象,已执行协程不能反复执行,不适合直接用它缓存。

缓存需要同时处理内存和一致性:

  • 缓存保留参数和返回值,方法里的self也可能被保活,大对象可能延长生命周期。
  • 返回可变对象后,调用者的修改可能影响后续命中;结果依赖数据库、时间或配置时,外部变化可能让缓存过期,lru_cache本身没有TTL。
  • 缓存结构支持线程安全更新,但两个线程首次同时未命中时,原函数仍可能执行两次。需要避免重复计算或重复副作用时,应另外协调。

最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。

img

上次更新: 10/2/2026