Python面试核心考点:可变对象、装饰器与垃圾回收机制
简介:面向Python技术面试的基础知识整理,属于Python开发学习教程类资源,无论对于应届毕业生还是转型工程师,都能帮助建立清晰的知识框架,适合在考前集中复习。压缩包大小约33.16MB,便于离线下载后按模块逐项查阅;目前已有508人学习。其中梳理了十大核心知识域,包括Python语法与类型、列表/字典/集合等数据结构、冒泡与快速排序等算法、类和继承等面向对象特性、异常处理、文件操作、正则与常用标准库、socket网络编程、SQLite数据库操作,以及项目实践要点,对于基础知识常见考点均有覆盖。内容以要点式总结为特色,结合面试常见问题帮助读者快速回顾和查漏补缺,可有效支撑编程题练习与真实面试应答,是一份实用的考前速查手册,能为顺利通过技术面试打下坚实基础。
1. 从一份“面试知识清单”说起:Python 技术面试到底在考什么
很多人以为 Python 技术面试靠刷题就能过关,真正把候选人分开的,往往是一些日常开发里被 IDE 补全和框架封装掩盖住的语法边界。可变对象到底能不能做字典键、装饰器套在类方法上为什么多绕了一层、GIL 锁住的究竟是什么、循环引用怎么回收——这些才是面试官反复追问的角落。这份 Python 开发学习教程类的基础知识整理,把散落的语法点和对象机制串成了一张可自查的清单,按优先级排好了语言模型、函数式特性、面向对象、并发与内存管理,缺哪块直接补哪块。它不适合零基础的人从零学语法,更适合正在准备中高级 Python 岗位面试的开发者,也适合团队拿来做新人入职摸底。
2. 语言地基:可变性、作用域与哈希约束
2.1 可变与不可变对象:面试官真正想听什么
面试里最容易被一句话带过去、实际却占一半分的问题,是is和==的区别。is比较的是对象身份,也就是内存地址;==比较的是值是否相等。很多人在实战里不会触发问题,是因为小整数和短字符串有缓存机制,让is意外返回True,但把变量放大到缓存范围之外,立刻就翻车。
a = [1, 2] b = a b.append(3) print(a is b) # True,b 和 a 指向同一个列表对象 print(a == [1, 2, 3]) # True,值相等这里要理解的不是“输出是什么”,而是“赋值到底发生了什么”。b = a没有创建新对象,只是把a的引用复制了一份。修改b等于修改a,这就是别名问题。面试官顺着这个问题往下问,通常就是“那怎么才能复制一份独立的对象”,于是引出浅拷贝和深拷贝,这部分我在第 5 章避坑里单独展开。
再看整数缓存这个经典现象:
x = 256 y = 256 print(x is y) # True,CPython 对小整数做了缓存 x = 257 y = 257 print(x is y) # False,超过缓存范围CPython 默认缓存了 -5 到 256 的整数对象,所以这两个is的结果不同。我一般会跟人说,别背这个结果,要把结论记成“永远不要用is去比较数值”,数值比较统一走==。面试里能主动说出这层原因,比只答对结果要加分。
可变与不可变的下一层考点,是哈希约束。Python 里不可变对象大多可哈希,所以能当字典的键、能进集合;可变对象比如list、dict、set都不可哈希。一个很刁钻的考法是这样的:
d = {} d[[1, 2]] = "value" # TypeError: unhashable type: 'list'原因很简单:字典靠哈希值定位存储位置,如果键可变,哈希值变了就找不到原来的项。但要注意,元组里有不可变元素时可哈希,元组里塞进列表就不可哈希了。这类边界题,面试官考的就是你有没有把“不可变”和“可哈希”真正关联起来。
2.2 作用域与 LEGB 规则:global 和 nonlocal 用得对吗
作用域是 Python 面试里出现频率极高的基础题,因为它能用一个简单例子同时考到编译规则和闭包意识。先看这个:
def outer(): count = 0 def inner(): count += 1 return count return inner这段代码运行时会报UnboundLocalError: local variable 'count' referenced before assignment。原因是 Python 在编译函数体时,只要发现函数内部对某个名字做了赋值,就把这个名字当成局部变量,count += 1里的count就被提前判成了局部变量,所以读取时发现还没绑定。
这就是 LEGB 规则的实际表现:局部(Local)→ 外层(Enclosing)→ 全局(Global)→ 内置(Built-in)。inner想去读外层count是可以的,但要去修改它,就必须显式声明nonlocal:
def outer(): count = 0 def inner(): nonlocal count count += 1 return count return inner加了nonlocal之后,inner里对count的操作会直接作用到外层变量上。类似的,模块级变量在函数里改也要加global。我给新人的建议是,写代码时尽量避免在函数内部修改外层状态,能通过参数传入、返回值传出的,就不要依赖nonlocal和global,测试和排错都会省力很多。
2.3 容器类型:字典与集合的哈希约束
面试里容器类型的考法有两类,一类是了解各自的时间复杂度,另一类是掌握它们对元素类型的约束。列表和字典的增删查改时间复杂度要背熟:列表按索引访问是 O(1),但按值查找是 O(n);字典查找在平均情况下是 O(1),靠的就是哈希表。
集合去重是基本功,但面试官会追问一句“把列表去重还能保持原来的顺序吗”。直接用set()会把顺序打乱,因为集合的存储顺序取决于哈希值和插入冲突情况。想要保序去重,最简单的方式是借助字典键的有序性:
def dedup(items): return list(dict.fromkeys(items)) nums = [3, 1, 3, 2, 1, 5] print(dedup(nums)) # [3, 1, 2, 5]dict.fromkeys(items)会把列表元素作为字典的键,字典从 Python 3.7 开始保证插入顺序,所以这里能同时做到去重和保序。这个写法比set后再排序更通用,因为排序只能处理可比较的数值,而dict.fromkeys对任何可哈希对象都有效。如果面试官问“那不可哈希的对象怎么去重”,可以顺势把话题引向自定义__hash__和__eq__,这就是从基础题跳到对象模型题了。
3. 函数进阶:闭包、装饰器与延迟绑定
3.1 闭包与延迟绑定陷阱
闭包的定义本身不难:内层函数引用了外层函数的变量,并且这个内层函数被返回出去,外层函数结束后,这个变量仍然被内层函数持有。难的是面试官常拿循环里的 lambda 来考延迟绑定,这个题的输出结果会让很多人当场懵。
funcs = [lambda: i for i in range(3)] for f in funcs: print(f())输出不是 0、1、2,而是 2、2、2。原因是闭包捕获的不是变量当时的“值”,而是变量所在的“单元”。循环结束后i停在 2,所有 lambda 取到的都是同一个最终值。修复方式是用默认参数把当前值固定下来:
funcs = [lambda i=i: i for i in range(3)] for f in funcs: print(f()) # 0 1 2lambda i=i: i在定义时就把i的当前值作为默认参数绑定到了函数上,后续外层i怎么变都不影响。这个技巧在给回调函数传参数时特别有用,比如tkinter或schedule库里的循环注册回调,遇到“所有回调拿到的都是最后一个值”的问题,翻回头想就是延迟绑定。
3.2 带参数装饰器:三层结构怎么拆
装饰器的本质是“接收一个函数,返回一个新函数”。不带参数的装饰器是两层:外层接收函数,内层包装。带参数的装饰器是三层,最外层先接收装饰器参数,返回一个真正的装饰器。听起来绕,拆开看代码就清楚了。
import functools def repeat(times=2): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): results = [] for _ in range(times): results.append(func(*args, **kwargs)) return results return wrapper return decorator @repeat(times=3) def greet(name): """向某人打招呼""" return f"Hello, {name}" print(greet("张三")) # ['Hello, 张三', 'Hello, 张三', 'Hello, 张三'] print(greet.__name__) # greet这里的repeat(times=3)先执行,返回decorator,Python 再把greet传给decorator,得到wrapper。times参数在闭包链里被传递了三层,最终在内层循环里决定执行次数。需要注意functools.wraps(func)这行,它把原始函数的__name__、__doc__复制到wrapper上。如果不加,greet.__name__会变成wrapper,很多日志系统和 IDE 调试就会显示错误的名字。
参数说明:times控制函数执行次数,*args和**kwargs把原函数的所有参数透传进去。这个模式可以扩展成重试装饰器,比如网络请求失败自动重试times次,每次间隔递增,代码结构完全一样,只是wrapper里加个异常捕获。
3.3 用装饰器做缓存:复刻 lru_cache 的核心思路
缓存装饰器是面试中出现频率很高的进阶题,因为它在实际项目里直接对应性能优化。Python 标准库提供了现成的functools.lru_cache,面试考的是你能不能说出它的适用场景和边界。
from functools import lru_cache @lru_cache(maxsize=128) def fib(n): if n < 2: return n return fib(n - 1) + fib(n - 2) print(fib(40))不加缓存时fib(40)要跑很久,加了缓存后瞬间出结果,因为每个n只真正计算一次,其余全部命中缓存。maxsize控制缓存的最大条目数,超过之后按 LRU 策略淘汰最久没用的记录。lru_cache有个硬性前提:被装饰函数的参数必须是可哈希的,否则会报TypeError。所以如果参数是字典或列表,要么先转成元组,要么自己写一个基于 id 的缓存,但那种做法要自己处理内存泄漏,我一般不建议在生产环境里用。手写一个简化版缓存是面试里常见的白板题,核心就是用一个字典保存(参数元组 -> 结果)的映射:
def cache(func): store = {} @functools.wraps(func) def wrapper(*args): if args not in store: store[args] = func(*args) return store[args] return wrapper这个简化版没有淘汰策略,store会无限增长。真正要落地还得加容量上限和淘汰时间,这就引出了缓存设计的三个参数:容量、过期时间、淘汰策略。能把这三个点自行展开说清楚,比单纯背出lru_cache的用法更有说服力。
4. 面向对象机制:从 new 到 MRO 再到垃圾回收
4.1 初始化魔法:单例模式的正确写法
__new__和__init__的区别是面向对象部分最直接的考点。__new__是构造方法,负责创建并返回对象实例,在__init__之前执行;__init__是初始化方法,负责给实例设置属性。日常开发几乎不用重写__new__,但单例模式必须用它。
class Singleton: _instance = None _init_done = False def __new__(cls, *args, **kwargs): if cls._instance is None: cls._instance = super().__new__(cls) return cls._instance def __init__(self, name): if not self._init_done: self.name = name self._init_done = True s1 = Singleton("A") s2 = Singleton("B") print(s1 is s2) # True print(s1.name) # A,第二次 init 被跳过这里的逻辑是:__new__里判断类变量_instance是否已存在,不存在才真正创建,存在就直接返回旧实例。但只重写__new__会有个隐藏坑:每次调用Singleton("B")都会执行__init__,把name覆盖成B。所以额外加一个_init_done标记,保证初始化只执行一次。这个处理是面试里的加分点,因为很多人写了单例却说不清这个坑。
4.2 super() 与 MRO:C3 线性化怎么排
多继承是 Python 面试的深水区,面试官爱考的不是“能不能多继承”,而是“多个父类方法冲突时按什么顺序解析”。Python 用 C3 线性化算法计算方法解析顺序,结果是每个类只出现一次,且保证了单调性。看这个经典菱形继承:
class A: def hello(self): print("A") class B(A): def hello(self): print("B") super().hello() class C(A): def hello(self): print("C") super().hello() class D(B, C): def hello(self): print("D") super().hello() d = D() d.hello()输出顺序是 D、B、C、A。用D.__mro__可以验证:D -> B -> C -> A -> object。super()并不是“调用父类方法”,而是“按 MRO 顺序调用下一个类的方法”。所以B里的super().hello()调用的是C的方法,而不是A的,这跟很多人直觉上“super 就是找父类”完全不同。理解这一点后,在复杂继承体系里写super()才不容易乱。
面试里遇到这个题,我一般建议先把继承关系画成一棵树,再按“深度优先、从左到右、子类优先”的口诀推一遍。C3 算法的细节不需要手算,但要知道它保证子类始终在父类之前、多重继承时保持基类顺序。能答到这个粒度,已经超过大多数候选人。
4.3 垃圾回收:引用计数与循环引用
Python 内存管理的基础是引用计数,每个对象维护一个引用计数,归零立即回收。但引用计数有个拿手好戏解决不了的问题——循环引用。两个对象互相引用,外部引用全部删除后,它们的引用计数都不会归零,于是只能靠辅助的垃圾回收器处理。
import gc class Node: def __init__(self): self.child = None self.parent = None a = Node() b = Node() a.child = b b.parent = a del a, b print(gc.collect()) # 回收掉循环引用对象gc.collect()可以手动触发一次完整回收,返回被回收的对象数量。生产代码里一般不需要主动调用,但面试里要能说出:Python 的垃圾回收是引用计数为主、分代回收为辅,分代回收专门处理循环引用,对象被分为三代,越老的对象回收频率越低。
有一个细节值得答出来:weakref模块可以创建不增加引用计数的弱引用,专门用来打破循环引用。比如缓存场景里,如果缓存的值又引用回键,形成一个环,可以让缓存这一侧的引用用weakref,这样对象能被正常回收。这个补充能体现你真正处理过内存问题,而不只是背概念。
5. 高频翻车点避坑指南:现象背后的原因
5.1 可变默认参数:谁动了我的列表
现象:一个函数定义了默认参数为[],连续调用两次,第二次调用时列表里残留了第一次的数据,结果莫名叠加。
原因:默认参数在函数定义时就被创建成对象,之后每次调用都复用同一个列表对象。函数体内的.append()修改的是这个共享对象,所以第二次调用看到的是同一个列表。
解决:默认参数用None做哨兵,函数体内再初始化。
def append_to(elem, target=None): if target is None: target = [] target.append(elem) return target从这以后我写任何函数,只要默认参数是容器类型,一律先写None再判空。这个习惯帮我挡掉了不少隐蔽的 bug。
5.2 浅拷贝翻车:嵌套列表的修改地狱
现象:用copy.copy()复制了外层列表,修改复制品的某个子列表元素,结果原列表也跟着变了。
原因:浅拷贝只复制最外层容器,内层子列表仍是同一个对象引用。
解决:需要完全独立的对象树时,用copy.deepcopy()。但如果对象里有文件句柄、数据库连接这类不能深度复制的资源,就要重写__deepcopy__或手动控制复制范围。
import copy matrix = [[1, 2], [3, 4]] shallow = copy.copy(matrix) shallow[0][0] = 99 print(matrix[0][0]) # 99,原对象被改了 deep = copy.deepcopy(matrix) deep[0][0] = 88 print(matrix[0][0]) # 99,原对象不受影响这个坑在配置管理里尤其常见,比如从一份基础配置复制多份实例配置,浅拷贝后所有实例共享同一个嵌套字典,改一个全部遭殃。
5.3 浮点数精度:0.1 加 0.2 不等于 0.3
现象:0.1 + 0.2 == 0.3返回False,判断金额是否相等时经常踩雷。
原因:二进制浮点数无法精确表示十进制的 0.1,存储的是一个近似值,两个近似值相加后误差累积。
解决:涉及金额计算用decimal.Decimal,涉及误差容忍的比较用math.isclose()。
import math print(0.1 + 0.2 == 0.3) # False print(math.isclose(0.1 + 0.2, 0.3, rel_tol=1e-9)) # Truerel_tol是相对容差,表示允许的相对误差比例。工程判断里我一般用rel_tol=1e-9,如果对绝对误差敏感,再加abs_tol参数。这个细节答出来,能直接证明你有浮点运算的实际经验。
5.4 循环导入与命名空间污染
现象:项目变大后,两个模块互相import,启动时抛ImportError,排查半天才发现是循环依赖。
原因:模块 A 导入模块 B,B 在顶层又导入 A,此时 A 还没执行完,属性还不存在。
解决:把导入语句挪到函数内部,延迟加载;或者重新设计模块边界,把公共依赖抽到第三个模块。面试里提到“延迟导入”就够了,但能补充“在函数内导入”会让面试官觉得你见过真实项目结构。
6. 面试自查:一道综合题加一套两小时复习流程
最后分享一个我常用的自查方法,拿一道综合题在纸上写,不借助 IDE 和搜索引擎,写完再对着清单逐条验证。这道题覆盖面足够广,很适合作为面试前的自测基准。
题目:写一个函数,接收一段英文文本,返回出现频率最高的 10 个单词,忽略大小写和标点符号。
from collections import Counter import re def top_words(text, n=10): words = re.findall(r"[a-zA-Z']+", text.lower()) freq = Counter(words) return freq.most_common(n) sample_text = "Python is powerful. Python is simple. Python is fun!" print(top_words(sample_text))这道题串起了五个考察点:正则表达式提取、字符串大小写归一化、Counter的用法、most_common的参数含义、以及如果要求不用Counter改用defaultdict手写统计的能力。写完以后再追问自己三个变体:文本里有数字怎么办、文件特别大怎么用迭代器逐行处理、倒序输出怎么改参数。能把这套变体答顺,说明这五个点真打通了。
我建议的复习流程很朴素:前四十分钟按本章顺序默写一遍关键结论,可变与不可变、LEGB、闭包、装饰器三层结构、MRO 顺序、垃圾回收机制,能默写出来才算真正记住;中间四十分钟做三道手写题,就按上面的综合题难度来;最后四十分钟对照资源清单查漏,重点看错题涉及的那个章节,不要重新泛读。
这套流程我从第一次准备技术面试时就开始用,后来每次带新人做入职前的摸底,也按同样的节奏来。从那以后,我每次面试前都会强制走一遍“先默写、再手写、后查漏”的顺序,基本没有在基础知识环节翻过车。希望你也能在这份知识整理里找到自己的节奏,把零散的知识点变成真正能拿出手的体系,祝顺利。
本文还有配套的精品资源,点击获取