Python
1. 列表和元组的内部实现
list 本质上是一个 over-allocate 的 array。tuple 和 list 相似,本质也是一个 array,但是空间大小固定。不同于一般 array,Python 的 tuple 做了许多优化,来提升在程序中的效率。
举个例子,当 tuple 的大小不超过 20 时(对应 CPython 源码中的 PyTuple_MAXSAVESIZE=20,Python 3.7~3.14 均如此),Python 就会把它缓存在内部的一个 free list 中,且每种大小的 tuple 最多缓存 2000 个(缓存上限的常量:Python 3.13 及以前叫 PyTuple_MAXFREELIST,3.14 起重构 freelist 后更名为 Py_tuple_MAXFREELIST)。这样,如果你以后需要再去创建同样大小的 tuple,Python 就可以直接从缓存中载入,提高了程序运行效率。
2. Python 中对象的比较和拷贝
比较操作符'=='表示比较对象间的值是否相等,而'is'表示比较对象的标识是否相等,即它们是否指向同一个内存地址。
比较操作符'is'效率优于'==',因为'is'操作符无法被重载,执行'is'操作只是简单的获取对象的 ID,并进行比较;而'=='操作符则会递归地遍历对象的所有值,并逐一比较(对 list、dict 等容器类型而言;自定义类型则取决于其 eq 的实现,默认回退为身份比较)。
浅拷贝中的元素,是原对象中子对象的引用,因此,如果原对象中的元素是可变的,改变其也会影响拷贝后的对象,存在一定的副作用。
深度拷贝则会递归地拷贝原对象中的每一个子对象,因此拷贝后的对象和原对象互不相关。另外,深度拷贝中会维护一个字典,记录已经拷贝的对象及其 ID,来提高效率并防止无限递归的发生。
3. 变量及其赋值的基本原理
Python 中参数的传递既不是值传递,也不是引用传递,而是赋值传递,或者是叫对象的引用传递。
需要注意的是,这里的赋值或对象的引用传递,实质是让变量名绑定到一个具体的对象(CPython 中 id(obj) 返回的就是对象的内存地址;变量本身只是名字到对象的一个绑定,存放在命名空间字典或栈帧的局部变量槽里)。
如果对象是可变的,当其改变时,所有指向这个对象的变量都会改变。
如果对象不可变,简单的赋值只能改变其中一个变量的值,其余变量则不受影响。
清楚了这一点,如果你想通过一个函数来改变某个变量的值,通常有两种方法。一种是直接将可变数据类型(比如列表,字典,集合)当作参数传入,直接在其上修改;第二种则是创建一个新变量,来保存修改后的值,然后将其返回给原变量。在实际工作中,我们更倾向于使用后者,因为其表达清晰明了,不易出错。
4. 装饰器的使用场景
所谓的装饰器,其实就是通过装饰器函数,来修改原函数的一些功能,使得原函数不需要修改。
而实际工作中,装饰器通常运用在身份认证、日志记录、输入合理性检查以及缓存等多个领域中。合理使用装饰器,往往能极大地提高程序的可读性以及运行效率。
5. 迭代器和生成器
容器是可迭代对象,可迭代对象调用 iter() 函数,可以得到一个迭代器。迭代器可以通过 next() 函数来得到下一个元素,从而支持遍历。
生成器是一种特殊的迭代器(注意这个逻辑关系反之不成立)。使用生成器,你可以写出来更加清晰的代码;合理使用生成器,可以降低内存占用、优化程序结构、提高程序速度。
生成器在 Python 2 的版本上,是协程的一种重要实现方式;而 Python 3.5 引入 async await 语法糖后,生成器实现协程的方式就已经落后了(基于生成器的协程自 Python 3.8 起被弃用,
@asyncio.coroutine装饰器已在 Python 3.11 中被移除)。
6. Python 的协程
协程和多线程的区别,主要在于两点,一是协程为单线程;二是协程由用户决定,在哪些地方交出控制权,切换到下一个任务。
协程的写法更加简洁清晰,把 async / await 语法和 create_task 结合来用,对于中小级别的并发需求已经毫无压力。
写协程程序的时候,你的脑海中要有清晰的事件循环概念,知道程序在什么时候需要暂停、等待 I/O,什么时候需要一并执行到底。
7. Asyncio 解析
不同于多线程,Asyncio 是单线程的,但其内部 event loop 的机制,可以让它并发地运行多个不同的任务,并且比多线程享有更大的自主控制权。
Asyncio 中的任务,在运行过程中不会被打断(单线程事件循环、协作式调度),因此不会出现线程间抢占式切换导致的 data race。但注意,这不代表完全没有 race condition:协程在 await 处会让出控制权,多个任务在 await 点交错执行时仍可能产生逻辑上的竞态(如先检查后修改共享状态),共享状态仍需加锁或使用 asyncio.Lock 等同步原语(官方文档亦如此建议)。尤其是在 I/O 操作 heavy 的场景下,Asyncio 比多线程的运行效率更高。因为 Asyncio 内部任务切换的损耗,远比线程切换的损耗要小;并且 Asyncio 可以开启的任务数量,也比多线程中的线程数量多得多。
8. CPython 引入 GIL 的原因
一是设计者为了规避类似于内存管理这样的复杂的竞争风险问题(race condition);
二是因为 CPython 大量使用 C 语言库,但大部分 C 语言库都不是原生线程安全的(线程安全会降低性能和增加复杂度)。
版本说明:以上是 GIL 存在的历史原因。Python 3.13 起(PEP 703)支持 free-threaded 构建,可在编译时通过
--disable-gil禁用 GIL,使多线程真正并行利用多核(运行时也可用PYTHON_GIL=0或-X gil=0强制关闭);该特性自 Python 3.14 起已不再是实验状态——PEP 779 被指导委员会接受,free-threaded 构建进入「官方支持但仍然可选」的 Phase II,单线程性能损失约 5%~10%——默认构建(GIL 版)依然启用 GIL,也不会自动替换。
9. Python GC
垃圾回收是 Python 自带的机制,用于自动释放不会再用到的内存空间;
引用计数是其中最简单的实现,不过切记,仅有引用计数并不足够(它不是充分条件),因为循环引用无法仅靠引用计数回收,需要通过不可达判定来确定是否可以回收;
Python 的自动回收算法包括标记清除和分代收集,主要针对的是循环引用的垃圾收集;
10. dict 的实现原理
dict 底层是哈希表:Python 3.6 起改为「紧凑 dict」(索引数组 + 按插入顺序排列的 entries 数组),3.7 起把「保持插入顺序」写进了语言规范,这就是它看起来「有序」的原因。
查找、插入、删除的平均时间复杂度是 O(1),最坏 O(n)(所有键哈希值都相同时);键必须可哈希(即支持
__hash__和__eq__),所以 list / dict / set 不能当键。装载率(已用条目数 / 哈希表长度)超过 2/3 时触发扩容,扩容会重新哈希全部键,是 O(n) 的操作(CPython 源码中
USABLE_FRACTION即 2/3,GROWTH_RATE为已用条目数 × 3)。
11. 小整数池和字符串驻留
CPython 预创建了 -5~256 的整数对象(源码中
_PY_NSMALLNEGINTS=5、_PY_NSMALLPOSINTS=257),这个范围内复用同一批对象,因此a = 256; b = 256; a is b为 True;超出范围就可能为 False(同一代码块内的常量还会被合并,进一步干扰判断)。ASCII 且形如标识符(只含字母、数字、下划线)的字符串字面量会被驻留(intern),同一运行内复用同一对象,也可以用
sys.intern()手动驻留。这些都是 CPython 的实现细节而非语言保证:判断值相等一律用
==,is只用来和 None 这类单例对象比较(呼应第 2 题)。
12. 可变默认参数的陷阱
def f(x, lst=[])中的默认值只在函数定义时求值一次,保存在函数对象的__defaults__里,之后每次调用共享同一个列表,于是上一次调用的数据会累积到下一次。正确写法是把默认值设为 None、在函数体内再创建:
def f(x, lst=None): lst = [] if lst is None else lst。同一机制还有另一个坑:默认值在定义时就求值完毕,所以不要把「期望每次调用重新计算」的表达式(如
time.time()、读环境变量)写进默认值。
13. 多线程、多进程与协程的选型
默认构建受 GIL 限制,多线程跑 CPU 密集的字节码只能串行;要真正吃满多核,用多进程(
multiprocessing、concurrent.futures.ProcessPoolExecutor)。I/O 密集型(网络、磁盘)用多线程或 asyncio 都可以:等待 I/O 时都会交出控制权。区别在于线程栈是 MB 级、协程只有 KB 级,成千上万并发连接时 asyncio 的内存优势明显;但协程跑在单个事件循环上,一段 CPU 密集代码就会阻塞整个循环,得丢给线程池 / 进程池。
已经在 C 层释放 GIL 的库(numpy、hashlib、部分 C 扩展)照样能并行,这类计算放线程池同样有效。
想彻底绕开 GIL:用 Python 3.13+ 的 free-threaded 构建(见第 8 题),或把热点用 C / Rust 扩展(Cython、numpy)实现。
14. MRO 与 super() 的原理
多继承下方法查找顺序由 C3 线性化算法计算,可通过
Cls.__mro__(或Cls.mro())查看;它保证子类排在父类之前、且不破坏各基类的相对顺序。super()并不是「调用父类」,而是「在实例的 MRO 上,从当前类之后继续往下找」:菱形继承(A → B、C → D)中若每一层都用super().__init__(),A 只会被初始化一次,D.__mro__为[D, B, C, A, object]。因此在协作式多继承中应当层层都用
super();直接写Base.__init__(self)会跳过 MRO 上的其他类,并可能造成重复初始化。
15. 闭包与延迟绑定
闭包是内层函数引用了外层函数的变量,外层函数返回后这些变量依然存活(存在 cell 对象中,可通过
f.__closure__查看)。内层函数是调用时按名字取值(late binding),所以循环中创建的一批闭包共享同一个变量、最后都取到循环结束时的值;要固定当时的值,用默认参数
lambda i=i: i、functools.partial,或再套一层函数。内层函数对外层变量重新赋值必须声明
nonlocal(模块级变量用global),否则会被当成内层的新局部变量。
最后更新于