五个方面,十道题
内置类型,以及各自会在哪儿翻车
2 道题面试官很少想听教科书那句「元组不可变」。他们想听的是后果:不可变才带来可哈希,可哈希才使它能当字典的键、能放进集合。先说机制、再给一个真正用得上的场景,两句话就说完了别人绕一整段还没说清的事。
通常怎么问:「list 和 tuple 有什么区别?」·「为什么 list 不能当字典的键?」
第 1 题
除了「一个可变一个不可变」,list 与 tuple 在实践中真正的区别是什么?
- Atuple 永远更快,所以处处优先用它
- Btuple 可哈希(前提是内部元素也可哈希),因而能当字典键、能进集合;list 不行
- Clist 能装混合类型,tuple 不能
- Dtuple 不能嵌套进其他容器
▶看答案与解析
正确答案:B. tuple 可哈希(前提是内部元素也可哈希),因而能当字典键、能进集合;list 不行
🐱 不可变是原因,可哈希才是你真正用得上的结果。d[(1, 2)] = 'x' 可以,d[[1, 2]] = 'x' 会抛 TypeError: unhashable type。还有一个能把好答案拉开差距的补充:元组只有在内部元素全都可哈希时才可哈希——hash((1, [2])) 照样报错。两者都能装混合类型;速度差异小到不足以成为选型理由。
第 2 题
这段代码打印什么?为什么?
a = [1, 2, 3]
b = a[:]
c = a
a.append(4)
print(len(b), len(c))
- A4 4
- B3 4
- C3 3
- D4 3
▶看答案与解析
正确答案:B. 3 4
🐱 b = a[:] 创建了一个新列表(浅拷贝);c = a 只是给同一个对象再绑了个名字。通过 a 追加元素,c 看得见、b 看不见,所以答案是 3 4。追问几乎必来:切片拷贝是浅的——如果列表里装的是子列表,b 里的子列表仍然和 a 里是同一批对象,这时才需要 copy.deepcopy。
函数、默认参数与闭包
3 道题这一组是面试官区分「用过 Python」和「读过 Python」的地方。两个坑反复出现:默认参数在定义时求值一次(所以可变默认值会被所有调用共享),闭包按引用捕获变量而非按值(所以循环里造出来的 lambda 看到的是循环变量的最终值)。能把这两个机制讲清、并说出各自的修法,这一组的问题基本就覆盖了。
通常怎么问:「def f(x=[]) 有什么问题?」·「讲讲装饰器」·「为什么这一串 lambda 打印的是同一个数?」
第 3 题
这段代码打印什么?
def add(item, target=[]):
target.append(item)
return target
print(add(1))
print(add(2))
- A先 [1],后 [2]
- B先 [1],后 [1, 2]
- C先 [1],后 []
- D抛出 TypeError
▶看答案与解析
正确答案:B. 先 [1],后 [1, 2]
🐱 那个默认的 [] 只在执行 def 那一行时创建一次,不是每次调用都新建。于是两次调用改的是同一个列表,得到 [1] 和 [1, 2]。面试官想听的修法:默认值写 None,进函数体内再建(if target is None: target = [])。注意这个坑只咬可变默认值;def f(x=0) 没问题,因为整数无法原地修改。
第 4 题
这段代码打印什么?
fns = [lambda: i for i in range(3)]
print([f() for f in fns])
- A[0, 1, 2]
- B[2, 2, 2]
- C[3, 3, 3]
- D抛出 NameError
▶看答案与解析
正确答案:B. [2, 2, 2]
🐱 每个 lambda 闭包捕获的是变量 i 本身,而不是创建时刻的值。等到任何一个 lambda 真正被调用时,推导式早已结束、i 是 2,于是三个都返回 2。标准修法是在定义时把值绑住:lambda i=i: i——默认参数会立刻求值,正是上一题那条「默认值在定义时求值一次」的规则,这次被反过来利用了。
第 5 题
用一句面试官会认可的话解释:装饰器是什么?
- A一种能改变 Python 编译方式的注释
- B一个接收函数、返回替代品的可调用对象,用 @ 语法应用
- C运行时强制执行的类型注解
- D让函数在独立线程中运行的手段
▶看答案与解析
正确答案:B. 一个接收函数、返回替代品的可调用对象,用 @ 语法应用
🐱 def f 上面写 @log,等价于 f = log(f)——没有比这更玄的东西。把这个等价关系说出口,后续追问就都好答了:多个装饰器自下而上生效(离 def 最近的先包),以及包装函数应带 functools.wraps 以保住原函数名和文档字符串。只会背「给函数增加功能」的人,通常卡在叠加顺序那一问上。
同一性、相等性与拷贝
1 道题is 比较同一性(内存中是不是同一个对象),== 比较值——这句人人都会说。分数拉开在后半句:你在小整数或短字符串上见过的那些反直觉的 is 结果,来自 CPython 把它们驻留了,那是实现细节,因此绝不能写依赖它的代码。一句「那是 CPython 的实现细节,不是语言保证」,能把这道题收得漂亮。
通常怎么问:「is 和 == 有什么区别?」·「为什么 256 is 256 和 257 is 257 表现不同?」
第 6 题
什么时候该用 is 而不是 ==?
- A比较数字时都该用,因为更快
- B与 None、True、False 这些单例比较时
- C比较字符串时用,可避免编码问题
- D永远不用;
is 已废弃
▶看答案与解析
正确答案:B. 与 None、True、False 这些单例比较时
🐱 当你真正想表达「就是同一个对象」时,is 才是对的工具:if x is None 是判空的惯用写法,因为 None 是单例,而自定义类完全可以重写 __eq__ 让 x == None 说谎。比较值就用 ==。在数字或字符串上用 is,在交互式解释器里看着能用(因为驻留),一旦碰到缓存范围之外的值就崩——这正是本题要抓的 bug。
执行模型:GIL 与生成器
2 道题GIL 这道题真正在问的是:你知不知道它拖累的是哪类活。一句话足够:全局解释器锁意味着同一时刻只有一个线程在执行 Python 字节码,所以多线程加速不了 CPU 密集型任务——但对 I/O 密集型有用,因为等待网络或磁盘时锁会被释放。接着说出逃生门:CPU 密集要并行就上 multiprocessing(或原生扩展)。生成器则是内存侧的对应物:一次产出一个元素,而不是先把整个列表建出来。
通常怎么问:「GIL 是什么?它怎么影响你的代码?」·「列表推导式还是生成器?为什么?」
第 7 题
你的脚本大部分时间都在等 HTTP 响应。用 Python 多线程能加速吗?
- A不能——GIL 阻止了一切并发
- B能——等待 I/O 时 GIL 会被释放,多个线程的等待因此重叠起来
- C只有在运行时关掉 GIL 才行
- D只能用多进程;线程从来没用
▶看答案与解析
正确答案:B. 能——等待 I/O 时 GIL 会被释放,多个线程的等待因此重叠起来
🐱 这正是本题要考的分野。GIL 串行化的是字节码执行,不是等待:线程阻塞在 socket 上时会释放锁,其他线程照跑。所以 I/O 密集型任务用线程(或 asyncio)能很好地并行。选项 D 是典型的矫枉过正——听到「GIL 让线程没用」就套到所有场景,包括线程恰恰是对的工具的这一种。
第 8 题
你要把一个一千万行日志文件里的字节数求和。哪种写法更好?为什么?
# A
total = sum([int(line) for line in f])
# B
total = sum(int(line) for line in f)
- AA——列表推导式总是更快
- BB——生成器表达式避免了在内存里建出一千万元素的列表
- C两者等价;Python 会把 A 优化成 B
- DB 在 sum() 里是语法错误
▶看答案与解析
正确答案:B. B——生成器表达式避免了在内存里建出一千万元素的列表
🐱 A 先把所有值都实体化进一个列表再求和——一千万个整数同时占着内存。B 一次只产出一个值,内存占用是平的。可以直接说出的通则:需要保留或多次使用元素时才建列表;只打算迭代一次时用生成器——而 sum 恰恰只迭代一次。Python 不会替你把 A 改写成 B。
类:共享状态的坑
1 道题类属性这道题之所以被问,是因为一个具体的 bug:可变类属性被所有实例共享,于是一个对象上的 append 会出现在所有对象上。它是「可变默认参数」的面向对象版表亲;面试官爱考它,因为背下了定义的人照样会栽在代码版本上。至于双下方法,能落地的短答案是:__repr__ 给开发者看、应当无歧义;__str__ 给用户看、应当易读——只定义一个的话就定义 __repr__,因为 str() 会回退到它。
通常怎么问:「类属性和实例属性的区别?」·「__str__ 和 __repr__ 有什么不同?」
第 9 题
这段代码打印什么?
class Team:
members = []
def add(self, name):
self.members.append(name)
a, b = Team(), Team()
a.add('Ann')
print(len(b.members))
- A0
- B1
- C抛出 AttributeError
- D2
▶看答案与解析
正确答案:B. 1
🐱 members 是类属性——所有实例共享同一个列表。a.add('Ann') 改的就是那个共享列表,所以 b 也看得见,答案是 1。修法是把每个实例自己的状态建在 __init__ 里:def __init__(self): self.members = []。有个值得一提的细节:重新绑定(self.members = [...])会创建实例属性、不影响其他对象——真正漏出去的是通过共享引用做的原地修改。