
面试的时候Python基础部分最容易出现一种尴尬问题都听得懂答案也背得下来但就是说不透。我这些年面试别人也被人面试发现一个规律——提问越“基本”越是照妖镜。今天的20个问题就是那种“无语八股”看起来每道题都见过实际上能一次答全的人不多。这篇文章不只是给答案而是把每个问题背后的原理、坑位和实际项目里怎么用它全部讲透适合准备Python岗位、以及想把自己基础扎牢的开发同学收藏着反复刷。1. 基本盘数据类型与比较机制这个板块是Python面试的第一道关也几乎是所有后续问题的地基。面试官在这里不是想听你背文档而是想确认你脑子里的Python对象模型是不是清晰的。这几个问题中的任何一道答不好后面基本就凉了一半。1.1 is 和 到底比的是什么这是Python面试里出现频率最高的问题没有之一。很多人能说出“比较值is比较内存地址”但一追问就含糊了。其实严谨一点说is比较的是两个对象是不是同一个对象也就是它们的身份标识id是否一致而比较的是两个对象的值是否相等这个“相等”是由对象的__eq__方法决定的。看一下最简单的例子a [1, 2, 3] b [1, 2, 3] print(a b) # True值相等 print(a is b) # False两个list对象id不同真正容易翻车的地方在于某些情况下is会意外返回True让候选人以为自己懂了其实掉进了对象的驻留机制里。比如小整数、短字符串Python会把一些高频对象缓存起来。这时候a is b可能为True但不是因为你的判断逻辑对而是因为解释器在背后帮忙。面试里我推荐你直接给结论业务代码里判断值相等一律用只有判断None或者单例对象时才用is。能说出这一层面试官就知道你不是死记硬背。1.2 可变对象和不可变对象边界到底在哪里Python中不可变对象包括int、float、str、tuple、frozenset、bytes可变对象包括list、dict、set以及绝大多数自定义类实例。这个划分不是考试知识点那么简单它直接影响参数传递、并发安全、字典key的合法性等很多实际设计。最典型的坑是tuple很多人以为tuple完全不可变。其实tuple只是不能增删元素、不能替换元素的引用但如果tuple里放了一个list这个list的内容是可以被修改的t (1, 2, [3, 4]) t[2].append(5) print(t) # (1, 2, [3, 4, 5])更实际的问题为什么字典的key不能用list但可以用tuple因为dict的key必须可哈希而可变对象改变了内容后哈希值会变整个哈希表就乱了。所以你只需要记住可变对象不能作为dict的key也不能放进set。面试官如果让你设计一个“可变但可哈希”的对象那基本是在考你对哈希机制的理解深度。1.3 小整数缓存为什么256和257待遇不一样Python面试里有一道常考题a 256; b 256; a is b结果是什么换成257结果又是什么在CPython中-5到256之间的整数会被预先创建并缓存所以这两个变量引用的是同一个对象is判断为True。而257超出了缓存范围通常会在不同位置创建出两个对象is判断为False。为什么边界恰好是-5到256这个其实不是语言规范而是CPython实现层面的选择。256这个数字在二进制里是2的8次方开发者认为这个小范围内的整数在真实程序里使用频率极高缓存它们能省下大量重复创建对象的内存和时间。不过我得提醒你千万别在代码里依赖这个机制。我在实际项目里见过有人用is去比较整数结果测试环境一切正常到生产环境因为数据范围不同就出问题。这不是玄学是驻留规则在不同场景、不同解释器实现下可能不同。面试时能说出“这是CPython的缓存策略不是Python语言规范强制要求的”就已经比大多数人强了。1.4 深拷贝和浅拷贝同样copy一个改了一个没改浅拷贝copy.copy只复制最外层对象如果对象内部还有可变子对象子对象仍共享引用。深拷贝copy.deepcopy则会递归复制所有层级的可变对象得到完全独立的副本。这个区别在嵌套结构里特别容易踩坑。看这段代码import copy origin [[1, 2], [3, 4]] shallow copy.copy(origin) deep copy.deepcopy(origin) origin[0].append(99) print(shallow) # [[1, 2, 99], [3, 4]]子列表共享 print(deep) # [[1, 2], [3, 4]]完全隔离实际开发中最常见的场景是配置字典的复制、对象快照、以及并发环境下数据隔离。很多时候你以为copy了一份就能随便改了结果浅拷贝只复制了外壳内部嵌套的数据还是同一份导致线上bug。判断该用哪种拷贝先问自己一个问题这份数据是只有一层结构还是存在嵌套的可变对象如果是后者想彻底隔离就必须deepcopy。另外如果对象内部有不可变元素浅拷贝时Python会直接复用这些不可变对象因为反正也改不了复制一份纯属浪费。面试时你主动提到这一点会很加分。1.5 字符串驻留为什么“hello”是True而“hello world”是False字符串也有类似的缓存机制叫字符串驻留。CPython会缓存一部分短字符串尤其是看起来像合法标识符的字符串方便复用。所以在我常用的Python版本下s1 hello; s2 hello; s1 is s2这类判断经常是True。但如果字符串里带了空格或其他特殊字符比如hello world驻留就不一定触发了is结果可能变成False。这里有个很关键的面试点字符串驻留没有写入语言规范它的触发条件在不同Python版本、不同交互环境和脚本环境下都可能不同。比如某些长字符串、运行时拼接出来的字符串驻留行为就不稳定。与其去背什么条件下驻留不如记住一条铁律所有字符串的值比较都必须用绝对不要用is。哪怕你在代码里发现is对某个字符串碰巧返回True那也只是巧合不是你可以依赖的契约。这个原则同样适用于整数比较。面试官问这道题核心就是看你是否理解“实现细节”和“语言规范”的区别。1.6 为什么判断None必须用is而不是这道题看起来简单背后却牵扯到Python对象模型的一个核心设计None是单例对象。整个解释器进程中只有一个None对象所以用is去判断身份是否相同天然就是最准确、最高效的方式。但这不是全部原因。另一个容易被忽略的点是的判断结果可以被重载。假设你有一个自定义类重写了__eq__方法甚至把__eq__实现得特别离谱那么obj None完全可能返回True或False完全绕过你预期的逻辑。而is比较的是id自定义类无法通过魔法方法去改变它。class Weird: def __eq__(self, other): return True w Weird() print(w None) # True因为__eq__被重载 print(w is None) # False身份完全不同在真实项目中用is None判断可选参数有没有被传入、函数返回值是否为空结果都是标准写法。PEP 8也明确推荐用is比较单例对象。面试里如果让你写一个判断变量是否为空的函数记得把if x is None和if not x区分开前者判断“没有值”后者判断“值为假”比如0、空字符串、空列表它们在if not x里都是True。2. 函数与作用域最容易翻车的雷区函数是Python的一等公民这个板块的题目不仅考语法更考执行机制。我面试过太多候选人基础语法刷得飞起一到闭包、默认参数、装饰器就露出原形。这个板块的每一题都有对应的线上事故可以追溯。2.1 参数传递到底是值传递还是引用传递这是Python面试里的“月考题”。网上答案众说纷纭有说值传递的有说引用传递的其实都不全对。准确说法是Python参数传递传递的是对象引用也叫“传对象引用”或者说“共享传参”。你可以理解为函数拿到的不是对象的拷贝而是指向同一个对象的“钥匙”。判断标准很简单如果参数是不可变对象函数内部重新赋值不会影响外部变量如果参数是可变对象函数内部原地修改会直接影响外部变量。def change(x, lst): x 20 # x指向新对象外部a不受影响 lst.append(4) # lst原地修改外部b变了 a 10 b [1, 2, 3] change(a, b) print(a) # 10 print(b) # [1, 2, 3, 4]面试时建议用“对象引用”这个说法开头然后立刻用可变/不可变举例。千万别说什么“Python是引用传递”因为严格来说Python的变量不像C的引用那样可以重新绑定到另一个对象也不像指针那样可以改变指向的地址。底层的本质是变量名只是指向对象的标签函数传参就是给这个对象多贴了一个标签。2.2 默认参数不能是可变对象这个坑年年有人踩下面这道题如果你是Python老手一定见过def append_item(item, lst[]): lst.append(item) return lst print(append_item(1)) # [1] print(append_item(2)) # [1, 2]出问题了问题出在函数定义时默认参数[]只被创建一次然后被保存在函数的__defaults__属性里。每次调用如果没传lst用的都是同一个列表对象。第二次调用时这个列表里已经有上次的1了于是变成了[1, 2]。不只是listdict、set以及任意可变对象作为默认参数都有同样的坑。正确写法是默认参数用None占位在函数内部创建新对象def append_item(item, lstNone): if lst is None: lst [] lst.append(item) return lst面试里如果你能主动说出“默认参数是在函数定义时求值的不是在调用时求值的”这句话本身就是得分点。而且这个理解不只在面试有用在真实项目里写缓存、写配置默认值、写日志处理器时都容易碰到类似的坑。2.3 *args和**kwargs参数打包和解包的本质*args和**kwargs是Python函数定义里的“收纳袋”。*args把多余的位置参数打包成元组**kwargs把多余的关键字参数打包成字典。但很多人只记住了定义侧的用法忽略了它们在调用侧的作用。在定义侧def func(a, b, *args, **kwargs): print(a, b, args, kwargs) func(1, 2, 3, 4, x5, y6) # 1 2 (3, 4) {x: 5, y: 6}在调用侧它们的作用是解包params [1, 2, 3] kwargs {name: 张三, age: 25} func(*params, **kwargs)面试里还常追问参数顺序。Python 3中函数参数的定义顺序一般是位置参数、默认参数、args、关键字专用参数keyword-only、**kwargs。其中keyword-only参数是args后面定义的普通参数它强制调用时必须使用关键字传参。能把这个细节讲清楚说明你对函数签名体系有完整的认识。实际项目中继承父类方法、写装饰器包裹函数、处理插件系统都离不开*args和**kwargs因为只有这样才能写出参数无关的通用包装代码。2.4 闭包延迟绑定列表里的lambda为什么全是同一个值这也是面试中的经典“送命题”。看看下面这段代码你猜输出是什么funcs [lambda: i for i in range(3)] print([f() for f in funcs])很多人脱口而出[0, 1, 2]但实际输出是[2, 2, 2]。原因在于lambda里捕获的i不是循环时的值而是变量i本身。循环结束后i的最终值是2所有lambda再被调用时拿到的都是这个最终值。这就是闭包延迟绑定闭包变量在调用时取值而不是在定义时取值。解决方案有三个方向。第一用默认参数把当前值绑定进函数funcs [lambda ii: i for i in range(3)]第二用functools.partialfrom functools import partial funcs [partial(lambda x: x, i) for i in range(3)]第三用生成器函数闭包每一轮循环的值。其中第一种最常用。这个问题的价值在于提醒所有人闭包捕获的是变量不是值。在异步编程、事件回调、GUI按钮绑定里这个坑会导致所有回调都读到最后一个值非常隐蔽。2.5 装饰器到底是怎么“装饰”的装饰器是Python里最能体现“函数是一等公民”这一特性的语法。本质是装饰器接收一个函数返回一个新函数原函数被替换成新函数。最简单的实现import time import functools def timer(func): functools.wraps(func) def wrapper(*args, **kwargs): start time.time() result func(*args, **kwargs) print(f{func.__name__} took {time.time() - start:.4f}s) return result return wrapper timer def slow_add(a, b): time.sleep(0.1) return a b注意里面的functools.wraps它不是装饰器语法里的必备品但非常重要。如果不加这行wrapper函数的__name__、__doc__等元信息会覆盖原函数的元信息导致调试和日志里看到的是wrapper而不是slow_add。面试里常见的进阶问法是让你实现一个带参数的装饰器比如一个可以指定重试次数的装饰器。这时候需要三层嵌套最外层接收参数中间层接收函数内层是真正的wrapper。另外还要知道类也可以做装饰器靠的是__call__方法。实际项目中日志记录、鉴权、接口重试、缓存、事务管理到处都能用装饰器把横切逻辑抽出来。面试时如果你能主动举一个自己项目里的装饰器例子面试官会很买账。2.6 LEGB作用域Python的变量查找顺序Python作用域遵循LEGB规则Local局部作用域、Enclosing外层函数作用域、Global全局作用域、Built-in内建模块作用域。查找变量时按这个顺序从内到外找。很多人搞不清的是global和nonlocal的区别。global用于在函数内部声明要修改的全局变量nonlocal用于在嵌套函数里修改外层函数局部变量。看个例子def outer(): x 10 def inner(): nonlocal x x 20 inner() print(x) # 20另一个高频坑是Python没有块级作用域。if、for、while里面的变量在代码块结束后依然存在它们属于函数或模块作用域。所以for i in range(3)结束之后i依然是2。这个特性不同于很多其他语言。面试里如果出一道“读代码输出”题经常就在这里埋伏笔。3. 容器与性能数据结构和推导式那点事Python的容器类数据结构是日常开发中使用频率最高的工具。这个板块的题不会让你手写红黑树但会考你对底层结构、时间复杂度和使用场景的理解。一个能把list、dict、set用清楚的人写出来的代码性能差距是数量级的。3.1 列表、字典、集合的底层实现差异列表底层是动态数组内存连续支持随机索引按下标访问是O(1)但查找一个元素是否存在是O(n)。字典底层是哈希表插入、查找、删除平均都是O(1)但哈希冲突极端情况下会退化。集合底层也是哈希表只是只存key不存value。这是三者的核心区别。知道这个原理后很多面试题就有了解答思路。比如“有一个一万个元素的列表频繁判断某个元素是否存在怎么优化”答案不是用list的in操作而是转成set或dict把O(n)降为O(1)。再比如“为什么dict和set的元素必须可哈希”因为哈希表要靠哈希值定位存储桶。Python 3.7开始dict保持插入顺序成为语言规范这之前3.6版的CPython已经以实现方式支持了。但set没有顺序保证你看到的遍历顺序和插入顺序没有必然关系。在项目里如果需要去重同时保留顺序就得用dict.fromkeys(seq)这种技巧它利用了dict保序的特性。3.2 迭代器和生成器为什么生成器能省内存迭代器是实现了迭代器协议的对象也就是有__iter__和__next__方法。任何能用for循环遍历的对象本质上都是for循环内部不断调用next()直到遇到StopIteration异常为止。生成器是迭代器的一种通过带有yield的函数或生成器表达式创建。def count_up_to(n): i 0 while i n: yield i i 1 for num in count_up_to(3): print(num)生成器最大的价值是惰性求值它不会一次性把全部结果加载到内存而是每次next时计算一个值。比如要处理一个几千万行的日志文件逐行读出来处理就是用迭代器省内存的典型场景。如果用列表把所有行都装下来内存可能直接爆炸。面试里有个常考细节生成器是一次性对象遍历完了就没了。想再遍历只能重新创建。代码里如果对一个生成器调用了list()再对同一个生成器调用list()第二次拿到的永远是空列表。很多候选人对这个不敏感导致实际项目里出现“数据只输出一次”的诡异bug。3.3 列表推导式和生成器表达式只看一个括号列表推导式用方括号生成器表达式用圆括号。看起来只差一个括号实际上是一个立即创建完整列表一个惰性生成。看代价nums [i for i in range(10000000)] # 立即创建1000万元素的列表 gen (i for i in range(10000000)) # 不占内存逐个生成在求和、取最大值、组装参数等场景里生成器表达式的内存优势非常显著。比如sum(i for i in range(10000000))比sum([i for i in range(10000000)])少了完整的列表内存开销。面试里会出现一种对比题列表推导式里的变量会不会泄漏在Python 3里推导式内的循环变量有自己的作用域不会泄漏到外部。但在Python 2里会泄漏这是版本差异偶尔会被当作历史题考一下。另外如果推导式逻辑过于复杂写了三四个for和if可读性就崩了。面试时可以提一句超过两层嵌套的推导式不如拆成普通for循环代码是给人读的。3.4 字典合并的四种写法你选哪种Python 3.9开始支持用|运算符合并字典这是最直观的写法d1 {a: 1, b: 2} d2 {b: 3, c: 4} d3 {**d1, **d2} # 3.5可用 d4 d1 | d2 # 3.9可用还有update方法和dict(d1, **d2)两种方式。这四种写法看起来都能合并但有几个关键区别。update是在原字典上原地修改其他写法都是生成新字典。dict(d1, **d2)有个限制d2的key必须是字符串否则会抛TypeError。{**d1, **d2}和|运算符在重复key时右侧的值会覆盖左侧的值。面试追问经常是“合并顺序和覆盖规则”以及“如果key存在性需要特殊处理怎么办”。项目里我更喜欢用|运算符因为它表达式风格清晰不会误改原字典。而在需要循环合并多个字典时用update累加会更方便。这道题本身不难但能反映你对Python版本特性的熟悉程度。3.5 sorted和sort稳定排序的妙用sort是列表的原地排序方法sorted是内建函数返回新列表。两者都可以传key参数指定排序依据例如sorted(students, keylambda s: s[age])。还有一个很容易忽略的点Python的排序算法是稳定的这意味着当key相同时元素的原始相对顺序会保留。稳定排序有什么实际价值最常见的场景是多条件排序。比如先按年龄排序年龄相同的再按姓名排序可以分两次排students.sort(keylambda s: s[name]) students.sort(keylambda s: s[age])第二次排序时年龄相同的学生保留了第一次排序后的顺序也就是按姓名排好的顺序于是整体效果就是先按年龄、再按姓名。很多人不知道这个技巧非要写一个复杂的key函数比如lambda s: (s[age], s[name])其实效果一样但分两次排序的思路更通用也能应对更复杂的业务规则。另一个常用技巧是key函数返回负值来反向排序比如按绝对值排序时用sorted(nums, keylambda x: -abs(x))或者直接用reverseTrue参数控制整体降序。这个板块的题考的是对标准库的熟练度面试时多提真实项目里的排序需求会让回答更有说服力。4. 并发与高级机制把“高级”讲明白这个板块的问题往往被求职者当成“加分题”但其实它们是区分“会用Python”和“理解Python”的分水岭。GIL、上下文管理器、new__和__init每一个都会在特定业务场景里跳出来卡你一下。4.1 GILPython多线程为什么“假并行”GIL是全局解释器锁CPython在同一个进程内同一时刻只允许一个线程执行Python字节码。这就是为什么Python多线程没法利用多核CPU去并行计算密集任务。GIL的存在主要是因为CPython的内存管理使用了引用计数而引用计数本身不是线程安全的干脆用一把全局锁来简化保护。面试里容易把这个问题答成“Python不支持多线程”这是不准确的。多线程在I/O密集型任务里依旧有效比如网络请求、文件读写、数据库查询因为线程在等待I/O时会释放GIL让其他线程运行。但如果是CPU密集型的计算比如循环算一万次多线程会把时间都耗在锁竞争上甚至比单线程还慢。解决方案有几个方向CPU密集任务用multiprocessing或ProcessPoolExecutor每个进程有独立的解释器和GIL可以真正并行I/O密集任务用asyncio协程单线程内通过事件循环处理大量并发还可以把重计算交给numpy之类的C扩展库很多这类库在执行耗时统计算法时会释放GIL。面试里能把这三种方案的适用场景说清楚比单纯背概念强得多。4.2 上下文管理器with后面的协议with语句本质上是在调用两个魔法方法进入时执行__enter__无论正常结束还是抛出异常退出时都会执行__exit__。最常见的例子是文件操作和数据库连接class ManagedFile: def __init__(self, path): self.path path def __enter__(self): self.file open(self.path, w) return self.file def __exit__(self, exc_type, exc_val, exc_tb): self.file.close() # 返回False异常会继续抛出返回True异常会被吞掉这里有个细节值得单独拎出来说__exit__方法接收exc_type、exc_val、exc_tb三个参数分别对应异常类型、异常实例、回溯信息。如果with块内部没有异常这三个参数都是None。如果__exit__返回Truewith块内的异常就不会继续向上传播等于被吞掉了。这是不少人踩过的坑自己写了一个上下文管理器退出时不小心返回了True结果上游永远捕获不到异常。更简单的写法是用contextlib.contextmanager把一个生成器函数变成上下文管理器在yield前后分别处理进入和退出的逻辑。这在项目里封装数据库事务、分布式锁、临时目录时特别方便。面试时如果能主动对比两种实现方式并说出业务场景里更倾向用哪个就是加分表现。4.3new__和__init单例模式的真正入口new__负责创建实例它第一个参数是类本身也就是cls返回的是一个实例对象。init__负责初始化实例第一个参数是实例本身也就是self。调用顺序是先执行__new再执行__init而且如果__new__返回的不是当前类的实例__init__就不会被调用。很多人第一次接触这个机制是在写单例模式时class Singleton: _instance None def __new__(cls, *args, **kwargs): if cls._instance is None: cls._instance super().__new__(cls) return cls._instance def __init__(self, value0): self.value value这个单例实现里有个细节每次调用Singleton()时__init__都会执行所以self.value会被重新赋值但对象始终是同一个。如果不希望初始化被重复执行就得加一个标志位记录是否初始化过。面试官经常在这里设陷阱问你这两种情况下实例的id和属性分别是什么。同理在自定义不可变对象子类时比如继承tuple因为对象创建后就不能改属性了所以初始化必须放到__new__里做而不是__init__。元类里的__new__则控制类的创建过程。理解这个顺序能帮你从根源上理解Python对象的生命周期而不是机械地背几个设计模式。5. 面试实战这些问题怎么答才加分讲完20道题还有一个比“记住答案”更实际的问题这些八股题到底应该怎么答才能在有限时间里让面试官觉得你确实理解了我作为面试官的角度给你一些实操建议。5.1 面试官问八股其实在考察什么很多人以为面试官问is和、可变对象、默认参数就是想听标准答案。其实不是。面试官真正想看的是你面对一个问题时的思维路径能不能分情况讨论、能不能举出实际场景、能不能说出这个知识点的边界。比如问“Python参数是值传递还是引用传递”你光说“是对象引用传递”只算及格。如果你能接着说“不可变对象传进来重新赋值不会影响外部可变对象传进来原地修改会影响外部所以写函数时要注意不要误改调用方传入的list或dict”这就是高分回答。因为后面这段话说明你踩过坑、在真实代码里思考过。答题结构我建议用三段式先给结论再用两三句话解释原理最后补一个自己在项目中遇到的例子或坑。哪怕是纯八股题只要你有自己的具体经历就立刻和背答案的人拉开了差距。5.2 20个问题速查表常考点和必答点为了方便你复习我把这20个问题整理成一张表格左边是核心考点右边是必答点和加分点。刷题的时候先看问题自己说一遍答案再看表格对照效果会好很多。问题必答点加分点is和比值is比idNone判断用is__eq__可重载可变/不可变区分list/dict/set与tuple/str/int可变对象不能做dict key小整数缓存-5到256被缓存这是CPython实现不是语言规范深浅拷贝浅拷贝只复制顶层深拷贝递归复制嵌套可变对象必须deepcopy字符串驻留部分短字符串被缓存值比较一律用None判断None是单例if x is None 和 if not x 语义不同参数传递传对象引用可变/不可变行为要举例默认参数陷阱默认值在函数定义时求值用None占位创建新对象*args和**kwargs位置参数打包成tuple关键字打包成dict调用侧解包、参数顺序规则闭包延迟绑定捕获变量而非值默认参数或partial解决装饰器接收函数返回函数functools.wraps、带参数装饰器LEGB局部、外层、全局、内建nonlocal修改外层变量容器底层列表动态数组字典/集合哈希表查找复杂度差异迭代器/生成器迭代器协议yield惰性求值生成器一次遍历后失效推导式对比列表推导式立即生成生成器表达式惰性数据量大用生成器表达式字典合并运算符3.9update原地改排序稳定性稳定排序key指定排序依据两次排序实现多条件排序GIL同一时刻一个线程执行字节码多进程、asyncio、C扩展方案上下文管理器enter/__exit__协议__exit__返回True吞异常new__和__init先建对象再初始化单例、不可变子类应用5.3 复盘基础不牢后面全是连锁反应这几年的面试经验让我有个很深的感受八股题不是没有价值的死记硬背它是对语言核心机制的一次集中筛查。一个人如果能在毫无准备的情况下把这20个问题回答得深入、有条理说明他在写业务代码的同时真的去想过“这行代码在解释器里到底是什么状态”。我自己带团队时最怕的不是候选人基础差而是基础差还不承认遇到线上问题就靠试错、靠复制粘贴。反而是那类愿意把is和、默认参数、闭包这些“小问题”扣清楚的人在生产环境遇到诡异bug时往往能更快定位出问题。因为大部分“诡异问题”底层都是这些基础机制在搞事。如果你正在准备面试我的建议是不要只背答案把这20个问题当成索引每个都亲手写代码验证一遍看着输出想一遍原因。比刷十套题都有用。