简介:《Python面试题及答案共55道》面向准备Python岗位面试的初中级开发者与计算机专业学生,围绕语法基础、数据结构、函数作用域、装饰器、异常处理、字典与文件操作、正则及序列化等高频考点,提供题目、参考答案与输出解析,便于系统自查和查漏补缺。资源包仅含1个docx文件,大小约80KB,按题组集中编排,适合打印或导入笔记软件后分类复习。已有1108人学习下载,说明其内容对常见笔试题有一定参考价值。读者可通过55道题掌握易错点,例如装饰器自下而上执行、try-except-finally中finally必执行、函数内修改全局变量需用global、os.walk遍历目录、re.match与search差异及json对datetime的定制序列化,并通过代码片段理解答案推导过程,提升面试应答与现场编码的准确性。
1. 一份 docx 题库,为什么值得逐题跑一遍
面试前一晚把 docx 从头翻到尾,每道题都觉得眼熟,第二天手写代码却卡在a += 3上——这不是记忆力问题,是「看懂答案」和「能推演执行过程」之间差了一层。这份《Python 面试题及答案共 55 道》覆盖的作用域、装饰器、异常执行顺序、MRO、单例、栈队列链表手写,恰好是初面到二面之间最常被追问的那批。它不适合当八股背,适合当一份能上机跑的题库:每道题都要自己能写出输出、能说出为什么、能补一个反例。下面按「执行顺序 → 面向对象 → 手写结构 → 踩坑」推演,每段都给可复制运行的代码,方便边看边改。
2. 执行顺序那一组:作用域、装饰器、finally 到底谁先跑
这一章的四道题在题库里被标成「输出题」,看起来是背答案的活儿,实际上它们考的是同一件事:CPython 在什么时候决定一个名字属于谁、在什么时候把代码块挂到哪个对象上。把这条线捋顺,题库里剩下的输出题基本是同一个模型的变体。
2.1a += 3为什么直接抛 UnboundLocalError
先复现原始现象:
a = 1 def bar(): a += 3 # 编译期就把 a 判定成了局部变量 return a bar() # UnboundLocalError: local variable 'a' referenced before assignment很多人第一反应是「a不是全局的吗」。问题出在编译期而不是运行期:CPython 在编译函数体时扫描所有赋值目标,a += 3展开是a = a + 3,左边出现了对a的绑定,于是a被记进co_varnames,函数内所有对a的读取都走LOAD_FAST。执行到a + 3时局部槽位还没写进去,直接抛异常。
def bar(): a = 3 # 只要有赋值,a 就是局部的 return a print(bar.__code__.co_varnames) # ('a',),可以直接看到编译器怎么判的 print(bar.__code__.co_names) # (),说明没去全局找co_varnames是局部变量名元组,co_names是全局/属性名元组,这两个属性是排查作用域问题最直接的一手证据,比在纸上推快得多。想真的改全局,用global;想改闭包外层变量,用nonlocal:
def counter(): n = 0 def inc(): nonlocal n # 不加这行同样会 UnboundLocalError n += 1 return n return inc c = counter() print(c(), c(), c()) # 1 2 3nonlocal只向上找最近一层函数作用域,找不到会直接SyntaxError,不会退化成全局。这条规则也解释了为什么闭包计数器必须用可变容器或者nonlocal,用普通整数赋值一定翻车。
2.2 字典传进函数被改掉:引用传的是引用的副本
mydict = {'a': 1, 'b': 2} def func(d): d['a'] = 0 # 原地修改,动的是同一个 dict 对象 return d func(mydict) mydict['c'] = 2 print(mydict) # {'a': 0, 'b': 2, 'c': 2}Python 的参数传递常被描述成「传对象引用」,更准确的表述是:传的是指向对象的引用的副本。函数内外两个名字指向同一个 dict,d['a'] = 0是原地写,外部可见;如果函数体里写d = {'a': 0},只是让局部名字重新绑定到新对象,外部不受影响。
def rebind(d): d = {'a': 0} # 断链,外部 mydict 不变 d['x'] = 1 return d def mutate(d): d['a'] = 0 # 原地写,外部可见 print(id(mydict))用id()打印一下就能看清是重新绑定还是原地修改,这个习惯在排查「为什么我的配置没被改」时很省时间。同一个模型还派生出可变默认参数这个经典坑:
def append_item(x, bucket=[]): # 默认值只在函数定义时求值一次 bucket.append(x) return bucket print(append_item(1)) # [1] print(append_item(2)) # [1, 2],不是 [2]正确写法是bucket=None然后在函数体里if bucket is None: bucket = []。需要真复制的时候区分copy.copy(浅拷贝,嵌套对象还是共享)和copy.deepcopy(递归复制,遇到自引用要用memo参数)。
2.3 两个装饰器叠加:装饰期自下而上,调用期自上而下
def decorator_a(func): print('Get in decorator_a') # 装饰阶段执行,只跑一次 def inner_a(*args, **kwargs): print('Get in inner_a') # 调用阶段执行 return func(*args, **kwargs) return inner_a def decorator_b(func): print('Get in decorator_b') def inner_b(*args, **kwargs): print('Get in inner_b') return func(*args, **kwargs) return inner_b @decorator_b @decorator_a def f(x): print('Get in f') return x * 2 f(1)输出是Get in decorator_a、Get in decorator_b、Get in inner_b、Get in inner_a、Get in f。把语法糖展开就一目了然:
f = decorator_b(decorator_a(f))离函数最近的@decorator_a先包,decorator_b在外面再包一层。所以装饰期的打印顺序是自下而上。调用f(1)时,最外层包装inner_b先被调用,它内部再调inner_a,最后才到原函数,打印顺序自上而下。这两句话背下来没意义,展开成赋值表达式一次就记住了。
工程上还有一个必加的细节:
import functools def decorator_a(func): @functools.wraps(func) # 把 __name__ / __doc__ / __module__ 搬过来 def inner_a(*args, **kwargs): return func(*args, **kwargs) return inner_a不加wraps,f.__name__会变成inner_b,基于函数名做的路由注册、日志标记、inspect.signature取参数全部失真。functools.wraps还会顺手写一个__wrapped__属性指向原函数,调试时用inspect.unwrap()能一层层剥回去。
2.4 try/except/finally 里 return 到底谁说话
def test(): try: raise ValueError('something wrong') except ValueError as e: print('error occured') return 1 finally: print('ok') print(test())输出error occured、ok、1。finally在return真正交还控制权之前执行,所以打印在返回值落地之前。但如果在finally里也写return,它会覆盖掉try/except里的返回值和异常:
def swallow(): try: raise ValueError('boom') finally: return 0 # 异常被静默吞掉,调用方看到的是 0 print(swallow()) # 0,没有 traceback这是血泪经验里最贵的一条:线上排查一个「接口返回 0 但日志没有异常」的问题,最后定位到就是某个清理函数在finally里写了return。finally里只做释放资源,绝不放return或break。
顺带把with的执行模型对上:
class W: def __enter__(self): print('进入with语句') return self def __exit__(self, exc_type, exc, tb): print('退出with语句') return False # 返回 True 会吞掉异常,和 finally 里 return 一个性质 with W() as w: print('之前') print(w) print('之后')__exit__的三个参数在无异常时全是None;返回值决定异常是否继续往外抛。用contextlib.contextmanager写生成器版本时,yield之后的清理代码等价于__exit__,try/finally包住yield才安全。
3. 面向对象那一组:MRO、super、单例的三条主线
题库里 OOP 部分占了将近三分之一,看着散,其实是三条主线:多继承下方法从哪来(C3 与 MRO)、子类怎么正确复写父类逻辑(super)、怎么控制实例的创建次数(单例与元类)。这三条串起来,鸭子类型、isinstance、魔术方法那些零散题就能挂上去。
3.1 C3 与__mro__:多继承的方法到底从哪来
class A: def who(self): return 'A' class B(A): def who(self): return 'B' class C(A): def who(self): return 'C' class D(B, C): pass print([c.__name__ for c in D.__mro__]) # ['D', 'B', 'C', 'A', 'object'] print(D().who()) # B__mro__就是方法解析顺序,属性查找沿着这个元组从左到右走,第一个命中的胜出。C3 算法要同时满足三条约束:子类一定排在父类之前;多个基类之间保持声明顺序;整条序列保持单调(某个类的相对顺序不在不同分支里翻转)。
约束满足不了就会直接拒绝建类:
class A: pass class B(A): pass class C(A): pass class D(B, C): pass class E(C, B): pass class F(D, E): pass # TypeError: Cannot create a consistent method resolution # order (MRO) for bases B, CD的 MRO 要求 B 在 C 前,E的 MRO 要求 C 在 B 前,F同时继承两者,约束冲突。实话说这个报错在业务代码里极少遇到,遇到基本说明继承图设计得过于花哨,扁平化比调 MRO 划算。面试被问到能说出三条约束、能现场手推一个菱形继承的序列,就够用了。
3.2 super 不是「调父类」,是「调 MRO 上的下一个」
class A: def __init__(self): print('A') class B(A): def __init__(self): print('B') super().__init__() class C(A): def __init__(self): print('C') super().__init__() class D(B, C): def __init__(self): print('D') super().__init__() D() # D B C A如果super真是「调父类」,B.__init__里的super()应该调到A,那C就不会被打印。实际输出里C出现了,因为D的 MRO 是D → B → C → A,B里的super()在这个实例的 MRO 上找的是C。
d = D() print(type(d).__mro__)super()的零参数写法依赖编译期注入的__class__单元格和调用栈第一帧的self,所以在类体外、在嵌套函数里直接用会报RuntimeError: super(): no arguments。真要显式写,super(B, self).__init__()和super().__init__()在单继承下等价,在多继承下前者会跳过 MRO 中B之前的项,属于自己给自己挖坑。
还有一个容易忽略的点:所有基类必须配合super()形成一条完整的链,只要有某一层硬写A.__init__(self),链就断了,后面的兄弟类不再被初始化。写 mixin 的时候尤其要守住这条。
3.3 单例的两种写法:__new__与元类,锁放在哪
import threading class Singleton: _instance = None _instance_lock = threading.Lock() def __new__(cls, *args, **kwargs): if cls._instance is None: # 第一次检查,避免每次都抢锁 with cls._instance_lock: if cls._instance is None: # 第二次检查,抢到锁后再确认 cls._instance = super().__new__(cls) return cls._instance def __init__(self): pass a, b = Singleton(), Singleton() print(a is b) # True双检锁的关键在于两层检查各自的作用:外层挡住绝大多数并发请求,内层挡住「两个线程同时通过外层」的那一小段窗口。少了内层就会创建两个实例,少了外层每次构造都要抢锁,性能直接掉一个量级。
用hasattr(cls, '_instance')判断也可以,但hasattr会把None也当成「已有」,如果某个子类提前把_instance置成None,判断就失效。直接判is None更稳。
元类版本适合「一个元类管多个单例类」的场景:
class SingletonMeta(type): _instances = {} _lock = threading.Lock() def __call__(cls, *args, **kwargs): if cls not in cls._instances: with cls._lock: if cls not in cls._instances: cls._instances[cls] = super().__call__(*args, **kwargs) return cls._instances[cls] class Config(metaclass=SingletonMeta): pass class Cache(metaclass=SingletonMeta): pass print(Config() is Config(), Cache() is Cache()) # True True__call__挂在元类上,管的是「类被调用」这个动作,所以Config()走的是SingletonMeta.__call__。字典按cls分键,一个元类可以管住任意多个类。__new__版本则要么每个类写一遍,要么抽一个基类往下继承,灵活性差一截。
两种写法都要注意__init__每次构造都会重新执行一遍。单例只保证实例唯一,不保证初始化一次,需要「只初始化一次」就得自己加标志位,或者把初始化逻辑挪到__new__里。
3.4 上下文管理器与__call__:with 与 callable 的判定边界
from contextlib import contextmanager @contextmanager def opened(path, mode='w'): f = open(path, mode) try: yield f finally: f.close() # 不管 with 体内抛不抛异常都会关 with opened('/tmp/demo.txt') as f: f.write('hello')生成器版本把yield之前当__enter__、之后当__exit__,try/finally必须包住yield,否则体内抛异常时文件句柄泄漏。__exit__返回True会吞异常,返回None/False继续往外抛,这个行为和 2.4 节里的finally是同一套逻辑。
callable的判定标准只有一个:类型对象的tp_call槽位是否非空,等价于类里有没有定义__call__。
class Adder: def __call__(self, x, y): return x + y add = Adder() print(callable(add)) # True print(add(1, 2)) # 3 print(callable(Adder)) # True,类天然可调用 print(callable(len)) # True,内置函数 print(callable('abc')) # False类本身可调用是因为type上有tp_call,实例可调用则取决于类有没有__call__。callable()和hasattr(obj, '__call__')在绝大多数情况下等价,但对某些用 C 实现的类型,hasattr可能因为属性描述符行为而返回不同结果,判断「能不能调」优先用callable。
3.5isinstance与type:为什么子类判断会翻车
class A: pass class B(A): pass b = B() print(type(b) == A) # False print(type(b) is B) # True print(isinstance(b, A)) # True print(isinstance(b, (A, int))) # True,第二个参数可以是元组type(x)返回精确类型,==比较的是类型对象本身,不走 MRO;isinstance会沿__mro__往上找。判断「是不是某类家族」用isinstance,判断「是不是恰好这个类型」才用type(x) is T。
顺带一个反直觉的例子:isinstance(True, int)返回True,因为bool继承自int。写参数校验时如果只判isinstance(x, int),True会通过,需要额外排除bool。另一个边界是isinstance对typing泛型别名的行为,isinstance([], list[int])在低版本直接抛TypeError,跨版本代码别依赖这个。
4. 手写结构那一组:栈、队列、链表和九层循环
题库后半段是纯手写题:链表栈、双队列栈、单链表逆置、手写队列、九层循环的竖式谜题。这些题不考记忆,考的是能不能把复杂度算清楚、把边界写全。面试官追问的通常不是「能不能写出来」,而是「你这个实现 pop 是 O(1) 还是 O(n)」。
4.1 竖式谜题:九层循环怎么剪枝到能秒出
原题是九个字母各不相同的竖式加法,原始写法九层for加逐层判重,组合量在十的八次方量级,跑起来肉眼可见地卡。先看约束:
from itertools import permutations # 首字母 A/C/E/G/P 不能为 0,permutations(range(10), 9) 已经保证互不相同 cnt = 0 for A, B, C, D, E, F, G, H, P in permutations(range(10), 9): if 0 in (A, C, E, G, P): continue ab = A * 10 + B cd = C * 10 + D ef = E * 10 + F gh = G * 10 + H ppp = P * 100 + P * 10 + P if ab - cd == ef and ef + gh == ppp: cnt += 1 print(A, B, C, D, E, F, G, H, P) print('solutions:', cnt)permutations(range(10), 9)是 3628800 组,比九层嵌套加if in判重的写法小两个数量级。逻辑说明:permutations保证九个字母互不相同,省掉所有判重分支;首字母判 0 是因为两位数不能以 0 开头,三位数PPP更不能。
再往下压一层,用算术关系先把 P 定死:
# 两个两位数相加最大 98 + 98 = 196,唯一的 repdigit 三位数是 111 # 所以 P = 1,EF + GH = 111,且 EF >= 12、GH >= 12,EF 最大 98 for E in range(1, 10): for F in range(10): ef = E * 10 + F gh = 111 - ef if not (12 <= gh <= 98): continue G, H = divmod(gh, 10) if len({E, F, G, H, 1}) < 5 or 0 in (G,): continue # AB - CD = EF,AB <= 98,CD >= 12,所以 EF <= 86 if ef > 86: continue for A in range(1, 10): for B in range(10): ab = A * 10 + B cd = ab - ef if not (12 <= cd <= 98): continue C, D = divmod(cd, 10) if len({A, B, C, D, E, F, G, H, 1}) == 9 and 0 not in (A, C): print(A, B, C, D, E, F, G, H, 1)剪枝的依据是取值范围而不是玄学:PPP必须落在 111 到 196 之间且三位相同,只有 111 成立;EF和GH都是两位数,所以EF落在 12 到 98。先枚举量最小的一层,外层循环次数直接掉到百级。这种「先用等式关系把变量消掉,再枚举」的路子,在笔试里比优化循环体写法有用得多。
4.2 链表栈和双队列栈:复杂度账要算清楚
class Node: __slots__ = ('val', 'next') # 省掉 __dict__,节点内存小一半 def __init__(self, val): self.val = val self.next = None class LinkedStack: def __init__(self): self.top = None self._size = 0 def push(self, val): node = Node(val) node.next = self.top # 头插 self.top = node self._size += 1 def pop(self): if self.top is None: raise IndexError('pop from empty stack') node = self.top self.top = node.next self._size -= 1 return node.val def __len__(self): return self._sizepush和pop都是 O(1),peek也是 O(1)。代价是每个元素多一个指针字段和一次对象分配,内存密度远低于列表,遍历时缓存命中率也差。题库里那个pop返回None表示空栈的写法要改,空栈pop抛IndexError才是和list一致的语义,静默返回None会让调用方分不清「空栈」和「栈里存了个 None」。
class TwoQueueStack: def __init__(self): self.q_in = [] self.q_out = [] def push(self, val): self.q_in.append(val) # O(1) def pop(self): if not self.q_in and not self.q_out: raise IndexError('pop from empty stack') if not self.q_out: while self.q_in: # 把 q_in 全部倒进 q_out,顺序反转 self.q_out.append(self.q_in.pop()) return self.q_out.pop() # 摊还 O(1) def __len__(self): return len(self.q_in) + len(self.q_out)这个写法每次元素最多被搬两次,摊还 O(1);如果按题库里「每次 pop 都把 n-1 个元素倒来倒去」的写法,单次 pop 是 O(n)。两种实现对比如下:
| 实现 | push | pop | 额外内存 | 适用场景 |
|---|---|---|---|---|
| 链表栈 | O(1) | O(1) | 每元素一个指针 | 元素大小不均、频繁增删 |
| 双队列栈(懒倒) | O(1) | 摊还 O(1) | 两个列表 | 纯 Python、要缓存友好 |
list直接 append/pop | O(1) 摊还 | O(1) | 无 | 绝大多数业务场景 |
实话说纯 Python 里手写链表栈没有性能优势,list的append/pop()在尾部操作就是摊还 O(1),还省掉节点对象。手写题的意义在于能说清「为什么这里不用链表」,而不是真的拿去替换list。
4.3 单链表逆置:三指针模板和它的边界
class ListNode: def __init__(self, val=0, next=None): self.val = val self.next = next def reverse_list(head): prev = None cur = head while cur: nxt = cur.next # 先存后继,不然改完 next 就找不到路了 cur.next = prev # 当前节点指向前驱,完成反转 prev = cur # 前驱后移 cur = nxt # 当前后移 return prev # 循环结束时 cur 是 None,prev 是新头 def build(vals): dummy = ListNode() tail = dummy for v in vals: tail.next = ListNode(v) tail = tail.next return dummy.next def to_list(head): out = [] while head: out.append(head.val) head = head.next return out print(to_list(reverse_list(build([1, 2, 3, 4])))) # [4, 3, 2, 1] print(to_list(reverse_list(build([])))) # [] print(to_list(reverse_list(build([1])))) # [1]三个指针的语义要能一句话说清:prev是已反转部分的头,cur是待处理部分的头,nxt是临时保存的下一站。循环不变量是「prev之前的全部已反转,cur之后的全未处理」。空链表和单节点两种情况在while cur里自然覆盖,不需要额外判空,很多人习惯性加if not head or not head.next,加了也不错,但要知道不是必需的。
递归版本更短,但 Python 默认递归深度 1000 左右,链表超过 900 个节点就有RecursionError风险,线上代码不要用。用sys.setrecursionlimit调深度是治标,栈帧爆掉照样段错误。
4.4 目录遍历、正则、序列化:边界比主逻辑更容易挂
import os def walk_paths(root, skip=('.git', '__pycache__', 'node_modules')): for dirpath, dirnames, filenames in os.walk(root, topdown=True, followlinks=False): dirnames[:] = [d for d in dirnames if d not in skip] # 原地改,剪枝生效 for name in dirnames: yield os.path.join(dirpath, name) for name in filenames: yield os.path.join(dirpath, name)os.walk返回三元组(dirpath, dirnames, filenames),dirnames是列表,原地切片赋值才能让剪枝生效,重新绑定成新列表dirnames = [...]不会影响遍历。topdown=True是默认值,自顶向下;followlinks=False也是默认值,不跟随符号链接,在存在链接环的目录树上这是保命的,跟了会无限递归。
os.listdir递归版要自己判os.path.isdir,注意这个函数默认跟随符号链接,和os.walk的默认行为不一致,混用会得到两套结果。删除相关的三个函数边界差别很大:
import os, shutil os.remove('a.txt') # 只能删文件,删目录抛 IsADirectoryError os.rmdir('empty_dir') # 只能删空目录,非空抛 OSError shutil.rmtree('any_dir') # 递归删,非空也删,不进回收站,无法撤销shutil.rmtree是最需要加确认的那个,一次误调用就是不可恢复。生产脚本里我一般先print目标路径再等一个input(),宁可多一步。
正则这块,match和search的差别只有一句话:match从 0 位置开始匹配,search扫描整个字符串。用邮箱校验时锚定写清楚更保险:
import re EMAIL = re.compile(r'^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$') print(bool(EMAIL.match('1403179190@example.com'))) # True print(bool(EMAIL.match('x 1403179190@example.com'))) # False,开头有空格 print(bool(EMAIL.search('x 1403179190@example.com')))# True,search 会跳过前缀贪婪和非贪婪的区别落在量词上:.*尽量多吃,.*?吃到能匹配就停。解析 HTML 片段时用.*会把整个文档吞进去,这是最常见的翻车点,正确做法是.*?或者干脆别用正则解析 HTML。
json 序列化的三个高频参数:
import json from datetime import datetime class ComplexEncoder(json.JSONEncoder): def default(self, obj): if isinstance(obj, datetime): return obj.strftime('%Y-%m-%d %H:%M:%S') return super().default(obj) # 交给父类抛 TypeError d = {'name': 'alex', 'created': datetime(2024, 5, 18, 19, 52, 5)} print(json.dumps(d, cls=ComplexEncoder)) # 中文被转成 \uXXXX print(json.dumps(d, cls=ComplexEncoder, ensure_ascii=False)) # 保留中文 print(json.dumps(d, cls=ComplexEncoder, ensure_ascii=False, indent=2, sort_keys=True))cls指定自定义编码器,default只对标准编码器处理不了的对象调用,返回可序列化对象;ensure_ascii=False决定非 ASCII 字符是转义还是原样输出,写日志和对外接口时一般要关掉;sort_keys和indent主要影响可读性和 diff 稳定性,线上报文别加indent,白白多传输。
5. 避坑与排查:五条“答案对但理解错”的记录
这一章的五条都是实际跑代码时撞出来的,题库答案本身没错,错在对答案的解释。每条按现象、原因、解决顺序写,方便照着复现。
5.1a += 3在 REPL 里不报错,写进函数就炸
现象:在交互式环境里敲a = 1; a += 3一切正常,把同样两行放进函数体就抛UnboundLocalError。
原因:模块级和函数级是两套作用域规则。模块级的名字查找走LOAD_NAME,找不到就去内置命名空间兜底,赋值直接写进模块字典;函数体内只要出现绑定就进co_varnames,读取走LOAD_FAST,编译期就定死了。
解决:先print(func.__code__.co_varnames)确认编译器把哪些名字判成了局部,再决定加global还是nonlocal。临时验证用dis.dis(func)看字节码,LOAD_FAST和LOAD_GLOBAL一眼分清。
5.2 不加functools.wraps,路由注册和日志全乱套
现象:给视图函数套了两个装饰器之后,框架报「重复注册同名路由」,日志里的函数名全是inner_b。
原因:装饰器返回的是内层包装函数,__name__、__doc__、__module__都变成了内层的。基于func.__name__建索引的框架直接把所有被装饰的函数认成同一个。
解决:所有装饰器无条件加@functools.wraps(func)。已经上线的代码用inspect.unwrap(f)拿到原函数补救,但根治还是补上wraps。
5.3finally里写了return,异常被静默吞掉
现象:某个清理函数明明上游抛了异常,调用方却拿到正常返回值,日志里一条错误记录都没有。
原因:finally块里的return会覆盖try/except中的返回值和正在传播的异常,异常对象直接被丢弃。
解决:finally里只做资源释放,不放return、break、continue。用contextlib.contextmanager重写清理逻辑时,yield必须包在try/finally里,异常处理交给__exit__的返回值控制。补一条静态检查规则把finally里的return直接标红。
5.4 目录遍历跑不完,CPU 一直满
现象:对某个业务目录做os.walk,脚本跑十几分钟不出结果,内存还一直涨。
原因:目录树里存在指向祖先目录的符号链接,followlinks=True时会绕着环无限递归,路径字符串越来越长。
解决:确认os.walk(..., followlinks=False),这是默认值,但很多代码显式传了True。自己写的os.listdir递归版本要额外判os.path.islink,因为os.path.isdir默认跟随链接。再用os.path.realpath维护一个已访问 inode 集合兜底:
import os def walk_safe(root): seen = set() for dirpath, dirnames, filenames in os.walk(root, followlinks=False): real = os.path.realpath(dirpath) if real in seen: dirnames[:] = [] continue seen.add(real) yield from (os.path.join(dirpath, n) for n in filenames)5.5json.dumps之后中文全变成转义序列,日志没法看
现象:日志里写进去的报文全是\u4e2d\u6587这种,排查问题时得先手动解码一遍。
原因:json.dumps的ensure_ascii默认是True,所有非 ASCII 字符都会被转义成\uXXXX。
解决:写日志、写文件、给前端返回时统一ensure_ascii=False,同时确保输出流的编码是 UTF-8。序列化datetime报TypeError时不要用default=str糊过去,那会把datetime变成带空格的字符串,下游解析格式对不上,老老实实写一个JSONEncoder子类,default里按类型分支返回固定格式。
6. 进阶:把 55 道题做成可回归的自测脚本
零散地跑代码验证,过两周就忘。我的做法是把题库按考点拆成测试用例,用unittest的subTest做参数化,一条命令跑完,改坏了立刻知道。这样做的好处是把「我记得答案是 B」变成「代码跑出来必须是 B」,面试前过一遍比翻十遍 docx 都管用。
import unittest import copy class TestScope(unittest.TestCase): def test_unbound_local(self): def bar(): a += 3 # noqa: F821 with self.assertRaises(UnboundLocalError): bar() def test_global_fix(self): global g g = 1 def bar(): global g g += 3 bar() self.assertEqual(g, 4) class TestMutability(unittest.TestCase): def test_dict_passthrough(self): d = {'a': 1, 'b': 2} def func(x): x['a'] = 0 return x func(d) d['c'] = 2 self.assertEqual(d, {'a': 0, 'b': 2, 'c': 2}) def test_shallow_vs_deep(self): src = {'inner': [1, 2]} shallow = copy.copy(src) deep = copy.deepcopy(src) src['inner'].append(3) self.assertEqual(shallow['inner'], [1, 2, 3]) # 浅拷贝共享内层 self.assertEqual(deep['inner'], [1, 2]) # 深拷贝隔离 class TestDecoratorOrder(unittest.TestCase): def test_order(self): trace = [] def deco(name): def wrapper(func): trace.append(f'deco:{name}') def inner(*a, **kw): trace.append(f'call:{name}') return func(*a, **kw) return inner return wrapper @deco('b') @deco('a') def f(x): return x * 2 self.assertEqual(f(1), 2) self.assertEqual(trace, ['deco:a', 'deco:b', 'call:b', 'call:a']) class TestTryFinally(unittest.TestCase): def test_finally_runs_before_return(self): trace = [] def test(): try: raise ValueError('boom') except ValueError: trace.append('except') return 1 finally: trace.append('finally') self.assertEqual(test(), 1) self.assertEqual(trace, ['except', 'finally']) def test_finally_return_swallows(self): def swallow(): try: raise ValueError('boom') finally: return 0 self.assertEqual(swallow(), 0) # 异常被吞,这条断言本身就是警钟 if __name__ == '__main__': unittest.main(verbosity=2)这段脚本的关键不在断言写得多漂亮,在三个设计选择。第一,trace列表比print可靠,print的输出顺序要人眼比对,断言是机器比对,跑 CI 也不会漏。第二,test_finally_return_swallows这条用例故意断言「异常被吞」,把错误行为固化成文档,新人看到这条就知道为什么代码规范里禁finally里return。第三,subTest适合批量参数化,比如把十道正则题写成for pattern, text, expected in cases:加with self.subTest(pattern=pattern):,单条失败不影响其余用例继续跑。
如果要再进一步,可以把 OOP 那组也纳进来:用D.__mro__的元组做断言校验 C3 顺序,用a is b校验单例,用assertRaises(TypeError)校验 MRO 冲突的类定义。数据结构那组则适合用unittest加随机化:生成 1000 个随机操作序列,同一序列分别喂给list和手写栈,逐步比对pop结果,这类差分测试比手写十组用例更能抓边界。
从那以后我每次拿到这类题库文档,第一步都不是从头读到尾,而是先建一个test_quiz.py,把每道题的「预期输出」写成断言,跑通再回头补解释。解释写不出来说明只是背下了答案,断言写不出来说明连预期行为都没搞清楚。这个习惯帮我筛掉了不少「看着会、上手废」的知识点。希望帮到你。
本文还有配套的精品资源,点击获取