写 Python 三年这些坑我每个都踩过——今天一次性说清楚刚入职那会儿我自信满满地提交了一段“优雅”的代码用defaultdict管理数据用lambda做回调还顺手把用在了浮点数比较上。结果上线当天生产环境数据直接乱套被 Leader 拉去复盘到凌晨三点。那一刻我才明白Python 的友好只是表象它布下的陷阱往往藏在最浅显的语法里。后来我专门花了两年时间把自己踩过的、帮同事排查的坑全部整理成这份避坑指南。今天不讲大道理全是真实场景和修复方案——看完能让你少加无数个通宵班。陷阱一可变默认参数的“幽灵状态”def add_item(item, cache[]): cache.append(item) return cache很多新手第一次看到这个函数都会觉得没问题不给cache传参时它应该生成一个空列表。但实际上这个列表在函数定义时就被创建了并永久绑定在函数对象上。连续调用三次add_item(1)、add_item(2)、add_item(3)返回值分别是[1]、[1,2]、[1,2,3]——你以为是每次独立的新列表其实它们共用同一个。避坑方案永远不要用可变对象列表、字典、集合做默认参数。正确写法是def add_item(item, cacheNone):然后在函数体里if cache is None: cache []。None 是不可变单例每次调用都会重新生成新列表。这条规则简单到极致但很多五年经验的工程师偶尔也会手滑。陷阱二闭包里的“变量捕获”骗局funcs [] for i in range(5): funcs.append(lambda: i) print([f() for f in funcs]) # 输出 [4,4,4,4,4]大多数人希望输出0,1,2,3,4但实际全是最后一个i的值。原因很扎心Python 闭包捕获的是变量的引用而不是变量当前时刻的值。当循环结束时i已经变成 4所有闭包访问的都是同一个i。避坑方案使用默认参数立即绑定当前值——lambda ii: i。或者改用functools.partial。更现代的写法是利用operator.itemgetter或列表推导式里的:海象运算符。核心思想是闭包延迟求值如果你想立即捕获必须把值“冻结”成默认参数或局部变量。陷阱三 与 is——你以为相等其实可能不是同一个对象a 256 b 256 print(a is b) # True (小整数缓存) c 257 d 257 print(c is d) # False (超出缓存范围)Python 为了性能会缓存-5到256的小整数对象所以在这个范围内is返回True。但一旦超出每次赋值都会新建对象。is 比较的是内存地址 比较的是值。很多人在判断None时习惯用if x is None这是对的因为None是单例。但在判断字符串时直接用is会随机踩雷短字符串有 intern 机制长字符串则不一定。避坑方案数值比较永远用判断空值/单例None, True, False用is字符串比较也用除非你确定 intern 行为。千万别把“值相等”和“对象相同”混为一谈否则你会在多线程环境下发现明明相等却进不了 if 分支。陷阱四浮点数精度——你以为的 0.1其实是无限循环小数print(0.1 0.2) # 0.30000000000000004 print(0.1 0.2 0.3) # False这不是 Python 的错而是 IEEE 754 双精度浮点数的先天缺陷。0.1 在二进制里是无限循环小数只能近似存储。当你累加 0.1 十次结果并不是精确的 1.0而是一个略小于 1.0 的数。对金融、统计、科学计算来说这是致命陷阱。避坑方案金融场景立刻改用decimal.Decimal并设置精度上下文。科学计算用math.isclose比较浮点数。更残酷的事实是即使你用了 Decimal如果从 float 初始化精度已经丢失了。正确的做法是用字符串初始化Decimal(0.1)。另外round()是银行家舍入四舍六入五凑偶不是数学四舍五入需要自己实现Decimal.quantize并设置 rounding 参数。陷阱五循环里修改列表——你永远想不到下次迭代会跳过谁lst [1, 2, 3, 4, 5] for item in lst: if item % 2 0: lst.remove(item) print(lst) # [1, 3, 5] 实际是 [1, 3, 5]表面看是对的但如果换成lst [1, 2, 2, 3, 4]删除所有偶数结果会变成[1, 2, 3, 4]——有一个 2 被跳过了。原因在于 for 循环内部维护了一个隐式索引remove 后后面的元素前移索引却继续前进。避坑方案绝对不要在迭代时修改正在遍历的列表。正确的姿势有几种创建新列表[x for x in lst if x % 2 ! 0]从后往前遍历for i in range(len(lst)-1, -1, -1)或者用list(itertools.filterfalse(...))。核心原则是迭代和修改必须分离。陷阱六字符串拼接—— 号看似简单实则 O(n²)result for i in range(100000): result str(i)这段代码慢到你怀疑人生。Python 字符串是不可变的每次 都会创建新字符串把旧内容复制一遍。循环 n 次总复杂度 O(n²)。生产环境里我曾见过一段代码用拼了 5 万次耗时超过 20 秒改成join后耗时 0.01 秒。避坑方案永远用.join(iterable)来批量拼接。如果需要在循环中动态追加可以用io.StringIO或者先收集到列表里再join。还有一个陷阱很多人以为str.join只接受字符串列表其实任意可迭代对象都可以但元素必须是字符串——非字符串元素会抛出 TypeError因此需要提前用 map 或生成器转换。陷阱七类变量与实例变量——你改了一个影响了所有class Dog: tricks [] def add_trick(self, trick): self.tricks.append(trick) a Dog() b Dog() a.add_trick(roll over) print(b.tricks) # [roll over] —— 以为只属于 a结果 b 也被改了类变量是所有实例共享的。当你通过self.tricks修改时如果实例没有自己的tricks属性Python 会先到类中找然后直接修改类变量。于是另一个实例也被影响了。很多初学者会误以为self.tricks永远是实例独有的。避坑方案可变类型永远不要在类体中初始化而是在__init__里用self.tricks []。如果你确实需要一个类级别的大列表使用类方法的约定通过类名访问并且只读不改。另外注意不可变类变量如int、str共享无风险因为修改时会创建新对象并绑定到实例不影响原类变量。陷阱八捕获异常时忘记具体类型——你吞掉了所有错误try: risky_code() except: pass这可能是 Python 里最恶心的写法——bare except 会捕获 BaseException包括 KeyboardInterrupt、SystemExit 甚至内存错误。某次同事写了这样的代码导致 CtrlC 无法终止程序最后只能强行 kill -9。更常见的是捕获 Exception 但连日志都不打出了 bug 根本无从查起。避坑方案明确捕获你预期的异常类型比如except ValueError:。如果确实需要处理多个异常用元组except (ValueError, TypeError):。永远不要在 except 块里裸 pass至少要记录日志。如果你使用except Exception as e:打印traceback.format_exc()而不是只打印str(e)后者会丢失堆栈信息。另外注意else和finally的配合——else只在 try 无异常时执行finally无论如何都执行常用于资源清理。陷阱九字典迭代中删除 key——RuntimeError 随时会来d {a: 1, b: 2, c: 3} for k in d: if k b: del d[k] # RuntimeError: dictionary changed size during iteration和列表一样字典在迭代时不能改变其结构增删键否则立即抛异常。Python 的字典迭代器基于哈希表修改大小会导致迭代器失效。避坑方案先复制 keys再迭代副本。for k in list(d.keys()):或者for k in d.copy():。更优雅的方式是使用字典推导式构建新字典{k: v for k, v in d.items() if condition}。如果必须原地删除可以用keys_to_delete [k for k in d if condition]然后再for k in keys_to_delete: del d[k]。陷阱十浅拷贝与深拷贝——你以为复制了其实还在引用import copy original {a: [1, 2, 3], b: 10} copied copy.copy(original) # 浅拷贝 copied[a].append(4) print(original[a]) # [1, 2, 3, 4] —— 变了浅拷贝只复制第一层内部可变对象仍然是共享引用。如果你需要完全独立的副本必须用copy.deepcopy。但深拷贝也有坑对象里如果有循环引用会无限递归深拷贝极其慢而且无法拷贝某些特殊对象如文件句柄、socket、线程等。避坑方案明确你的拷贝深度需求。对于只读共享的情况浅拷贝足够需要完全独立的嵌套结构用copy.deepcopy但要注意性能。另一种思路是使用pickle序列化-反序列化来实现“原型模式”。还有一个冷知识列表切片[:]是浅拷贝字典的.copy()也是浅拷贝。如果你要拷贝自定义对象考虑实现__copy__和__deepcopy__方法来自定义行为。陷阱十一lambda 的默认参数绑定——循环里的 lambda 永远在“抓瞎”actions [lambda x: x i for i in range(3)] print([a(10) for a in actions]) # [12, 12, 12] 而不是 [10, 11, 12]这和前面闭包的陷阱本质相同i是自由变量所有 lambda 共享同一个i循环结束后i为 2所以每个 lambda 都加 2。你以为创建了三个不同的函数其实它们都引用着最后一个 i 的值。避坑方案把循环变量作为默认参数立即求值lambda x, ii: x i。或者改用functools.partialactions [functools.partial(lambda x, i: x i, ii) for i in range(3)]。更现代的做法是使用operator.add配合 map 或列表推导式。记住闭包里的“延迟求值”是 Python 最阴险的特性之一而默认参数是打破延迟的利器。陷阱十二多线程与 GIL——你以为并行其实在串行import threading import time count 0 def increment(): global count for _ in range(1000000): count 1 threads [threading.Thread(targetincrement) for _ in range(2)] for t in threads: t.start() for t in threads: t.join() print(count) # 远小于 2000000甚至每次结果不同GIL全局解释器锁保证同一时刻只有一个线程执行 Python 字节码所以 CPU 密集型任务用多线程纯属自欺欺人。更坑的是count 1不是原子操作它分三步LOAD、ADD、STORE线程切换会导致数据竞争。避坑方案CPU 密集型用多进程multiprocessingI/O 密集型用多线程或 asyncio。如果需要线程安全用threading.Lock或者改用queue.Queue。一个少有人知的技巧如果你只是做原子计数用threading.AtomicInteger不存在但你可以用multiprocessing.Value(i, lockTrue)不过那是进程的。Python 3.8 后可以用threading.ThreadLocal做线程隔离。更简单改用collections.Counter的update方法需要额外同步。总之多线程共享数据就是坑能避开就避开。陷阱十三导入循环依赖——你以为模块已经加载其实还在半路# a.py from b import B class A: pass # b.py from a import A class B: pass执行python a.py会抛出ImportError: cannot import name B from partially initialized module b。原因Python 在导入 a.py 时发现需要从 b 导入 B于是开始导入 b.py但 b.py 又需要从 a 导入 A此时 a.py 还没执行完A 还不存在。避坑方案如果只是类型提示用from __future__ import annotations开启延迟求值Python 3.7。或者将类型提示写在字符串中def method(self) - A:。如果确实是运行时依赖必须重构将公共部分提取到第三个模块或者将 import 移到函数内部惰性导入。绝不要用from X import它会加剧循环依赖问题的排查难度。另外__init__.py里的__all__可以控制导出但无法解决循环导入。陷阱十四文件句柄不关闭——你以为函数结束了资源却没释放def read_first_line(path): f open(path) return f.readline()这个函数看起来很 clean但如果它在读取过程中抛出异常f.close()永远不会执行文件描述符泄漏。更隐蔽的是在交互式环境或者长时间运行的脚本里打开太多不关闭的文件会达到系统限制导致后续所有文件操作失败。避坑方案用with语句。with open(path) as f:可以确保无论正常还是异常文件都会正确关闭。with的上下文管理器不仅限于文件锁、网络连接、数据库游标等都支持。如果你需要同时管理多个资源可以嵌套 with 或用contextlib.ExitStack。一个加粗的教训除非你写的是简单的脚本否则永远不要手动调用f.close()因为你能手动调就能忘记调。陷阱十五字符串判断空或非空的“反直觉”行为if some_string: # 判断非空这个写法没问题Python 会把空字符串视为 False。但如果some_string是None呢if some_string:会把None也视作 False但你本意可能是“字符串不为空”而不是“不是 None”。更糟糕的是用户输入可能会是0或False这些非空字符串当然是 True但逻辑上你可能想当作 False。条件判断与布尔语义混在一起是Python最常见的语义陷阱。避坑方案显式判断if some_string is not None and some_string ! 。或者如果你只关心非空可以用if some_string:但前提是你确定some_string不是0、None等假值。对于可能是 None 的情况用if some_string is not None:再单独判断空字符串。企业级写法是定义一个工具函数def is_non_empty_string(s): return isinstance(s, str) and s然后用它。陷阱十六is和的字符串 intern 迷局a hello b hello print(a is b) # True 短字符串 intern c hello 100 d hello 100 print(c is d) # False 长字符串无 intern字符串 interning 是 Python 的优化手段短的、符合标识符规则的字符串会被缓存但你不能依赖这个行为。不同版本、不同实现CPython vs PyPy的 intern 规则完全不同。在某些情况下a 和 b 是同一个对象在另一些情况下不是。如果你用is比较字符串代码可能今天跑得对明天换个环境就崩。避坑方案字符串比较永远用只有判断单例None、True、False才用is。如果你想强制 intern可以用sys.intern(s)但除非是为了节省内存否则不推荐。一句话牢记is不是“值相等”而是“同一个对象”。陷阱十七args和kwargs的传参陷阱def wrapper(args, kwargs): # 想要直接转发 func(args, kwargs) # 错误应该用 func(args, kwargs)这个错误非常经典args是一个元组kwargs是一个字典直接传进去函数接收到的是两个参数一个元组、一个字典而不是展开后的各个参数。类似地如果混用位置参数和关键字参数也容易出错。避坑方案转发时一定要记得拆包func(args, kwargs)。如果你要混合固定参数和可变参数顺序要遵循位置参数、args、关键字参数、kwargs。还有一个冷门陷阱kwargs中的 key 必须全是字符串因为 Python 的函数调用只接受字符串作为关键字参数名。如果你从外部接收的 dict 有非字符串 key 会抛出 TypeError。陷阱十八除了 Exception 还有 BaseException——捕获范围失控try: import sys; sys.exit(0) except Exception: print(caught) # 不会捕获 SystemExit很多新手以为 Exception 能处理所有“异常”但实际上SystemExit、KeyboardInterrupt和GeneratorExit继承自BaseException不是Exception。所以except Exception:无法阻止程序退出或中断。反过来except BaseException:又会捕获到用户按 CtrlC 的信号导致程序无法优雅关闭。避坑方案永远明确你要捕获的异常范围。如果你只是想捕获所有非致命错误用except Exception:。如果你需要在退出前做清理可以同时捕获BaseException但必须在清理后重新抛出except BaseException as e: ... ; raise否则会吞掉系统退出。一个黄金法则尽可能缩小捕获范围除非你知道自己在做什么。陷阱十九列表推导式的变量泄漏Python 2 遗留在 Python 2 中列表推导式里的循环变量会泄漏到外部作用域# Python 2 x outer nums [ x for x in range(3) ] print(x) # 2 x 被覆盖了Python 3 修复了这个问题列表推导式的变量只在推导式内部的局部作用域存在不会污染外部。但生成器表达式和字典推导式在 Python 3 中也是封闭的只有集合推导式与列表推导式行为一致。如果你在维护 Python 2/3 兼容代码要特别注意这个差异。避坑方案尽量用 Python 3 写新代码。如果必须兼容 Python 2列表推导式里的循环变量不要和外部变量重名。或者改用显式的for循环。提醒async for推导式不会泄漏变量但容易与其他 async 陷阱混淆不在本次讨论范围。陷阱二十sys.setrecursionlimit与默认递归深度def recurse(n): if n 0: return 0 return 1 recurse(n-1) recurse(2000) # RecursionError: maximum recursion depth exceeded默认递归深度是 1000可以调整但你调高了递归限制后可能依然会触发段错误Segmentation Fault因为 C 栈空间有限。Python 的递归会比 C 函数更耗栈因为每个递归调用要创建新的 frame 对象。避坑方案除非你明确知道栈深度可控否则避免深度递归。用迭代代替递归或者使用functools.lru_cache优化但无法解决深度问题。对于算法题可以用sys.setrecursionlimit(1000000)临时提高但只适用于对栈大小有足够信心的场景。生产环境建议用stackless或尾递归优化Python 不支持原生尾递归。一个更安全的做法是将递归改成显式的栈模拟。陷阱二十一datetime的时区陷阱——本地时间不等于 UTCfrom datetime import datetime print(datetime.now()) # naive datetime无时区信息datetime.now()返回的是不带时区的“朴素”时间。如果你存储这个时间到数据库然后换一台机器默认是 UTC比较时会发现时间错乱。更可怕的是datetime.utcnow()也是 naive 的它只是用 UTC 时间填充但对象本身不含时区信息。当你做加减运算时Python 会假设两个 naive datetime 在同一时区但实际并不一定。避坑方案永远使用带时区的 aware datetime。用datetime.now(timezone.utc)或datetime.now(tz... )。存储到数据库时优先存 UTC 时间戳float 或 int。如果你必须用 naive datetime确保整个系统约定使用 UTC并在所有输入输出时转换。pytz库已经过时推荐用 Python 3.9 内置的zoneinfo或第三方dateutil。一句加粗时区问题不解决时间比较永远是玄学。陷阱二十二staticmethod与classmethod混淆class Date: staticmethod def is_valid(date_str): # 无自动参数 pass classmethod def from_string(cls, date_str): # cls 是类本身 obj cls() # ... return obj很多人搞不清两者的区别。staticmethod只是一个函数不包含类信息classmethod会传入类对象。如果你在子类中重写classmethod它会自动调用子类的cls实现多态。但staticmethod则不行。避坑方案除非你明确不需要类信息如工具函数否则尽量使用classmethod。例如工厂方法应该用classmethod因为子类继承后可以正确创建子类实例。staticmethod更适合那些逻辑上属于类、但不需要访问类状态的函数。注意classmethod可以被子类覆盖而staticmethod本质上只是一个普通的函数子类中覆盖它会变成一个普通方法容易引发混淆。陷阱二十三try-finally与 return 的执行顺序def test(): try: return 1 finally: return 2 # 返回 2这段代码返回 2而不是 1。finally块在执行 try 块中的 return 之前被执行而且如果 finally 里也有 return它会覆盖之前的 return。更隐晦的是如果 finally 块里出现了异常它会覆盖 try 块的异常导致原始异常丢失。避坑方案不要在 finally 块中使用 return、break、continue 等控制流语句。finally 的设计目的是做资源清理不是返回结果。如果确实需要在清理后返回应该把返回值存在局部变量里finally 之后返回。如果你在 except 块中再次抛出异常finally 块依然会执行但异常会在 finally 后继续传播。记住 finally 是最后一道防线不是主逻辑。陷阱二十四生成器的close()与异常传播def gen(): try: yield 1 yield 2 except GeneratorExit: print(closed) raise # 必须重新抛出否则 TypeError g gen() next(g) g.close() # 触发 GeneratorExit生成器被显式 close 或在垃圾回收时会在 yield 处注入GeneratorExit异常。如果你在生成器中捕获了 GeneratorExit 却没有重新抛出Python 会抛出 RuntimeError: generator ignored GeneratorExit。而且生成器一旦处理不当还可能泄漏资源。避坑方案除非你知道在做什么否则不要在生成器里捕获GeneratorExit。如果你必须做清理工作使用try-finally块不要手动捕获 GeneratorExit。推荐使用上下文管理器来管理生成器资源的生命周期例如contextlib.contextmanager装饰器会自动处理 GeneratorExit。陷阱二十五__init__返回不是 None 的神奇问题class MyClass: def __init__(self): return 42 # TypeError: __init__() should return None__init__必须是None返回否则 Python 会抛出 TypeError。很多其他语言允许构造函数返回对象但 Python 的__init__是初始化不是真正的构造函数。真正的构造函数是__new__它返回实例对象。避坑方案永远不要在__init__中返回非 None 值。如果你需要控制对象的创建重写__new__方法。__new__是静态方法第一个参数是类返回实例。常见场景单例模式。__init__不要做任何返回Python 解释器会自动 return None。一句话你可以在__init__里写return但不带值或直接不写——这两种都是返回 None。陷阱二十六property的无限递归class Person: property def name(self): return self._name name.setter def name(self, value): self.name value # 这里写成了 self.name 而不是 self._name当你在 setter 里写self.name value时它会再次调用 setter造成无限递归直到栈溢出。这和self._name是不同的前者通过属性访问后者直接访问实例字典。避坑方案在 property 的 setter 里始终使用下划线前缀的实例变量如self._name来存储值。getter 里也返回self._name。如果你使用__slots__也要确保 slots 里没有和 property 同名的变量否则会冲突。一个更进阶的技巧可以用name.deleter实现删除逻辑但同样不要用属性名直接删除。陷阱二十七lru_cache与可变参数的不兼容from functools import lru_cache lru_cache(maxsizeNone) def add(a, b): return a b add([1], [2]) # TypeError: unhashable type: listlru_cache使用参数哈希作为缓存键因此所有参数都必须是可哈希的。列表、字典、集合等可变类型作为参数会抛出异常。而且即使你用了元组可哈希也要注意元组内部如果包含列表也是不可哈希。避坑方案将不可哈希参数转换为可哈希形式比如把列表转成元组字典转成 frozenset( dict.items() )。或者使用cachePython 3.9它和 lru_cache 类似。对于复杂参数考虑用pickle.dumps或自定义哈希函数但缓存命中率可能降低。另一个实践不要缓存带有大对象的函数因为 lru_cache 默认不限制内存可能导致 OOM。陷阱二十八sys.stdout.write与 print 的缓冲import sys import time for i in range(5): sys.stdout.write(str(i)) time.sleep(1) # 你可能期望每 1 秒打印一个数字但实际全部在最后一起输出默认情况下输出到终端时是行缓冲遇到换行符才刷新但如果是重定向到文件则是块缓冲4096 字节才刷新。sys.stdout.write不自动添加换行符不会触发缓冲刷新。而print默认在末尾加换行且flush参数默认为False所以如果你不显式调用flushTrue也可能滞后。避坑方案用print(..., flushTrue)来强制每次写入立即刷新。或者使用sys.stdout.flush()手动刷新。对于长时间运行的脚本如日志建议用logging模块它默认会在每条记录后刷新取决于 handler 配置。sys.stderr默认是无缓冲的所以错误信息总是立即输出。陷阱二十九with语句与异常抑制class Managed: def __exit__(self, exc_type, exc_val, exc_tb): return True # 返回 True 会抑制异常 with Managed(): raise ValueError(出错了) # 异常被静默吞噬不会传播__exit__返回True会抑制上下文管理器中抛出的任何异常。很多开发者不知道这一点在自定义上下文管理器时无意返回了 True导致异常被吞掉bug 很难追踪。避坑方案除非你明确需要抑制特定类型的异常否则__exit__应该返回None或False。如果你需要在__exit__里做一些清理工作可以用try-finally包裹但不要返回 True。一个常见的反模式是contextlib.suppress(Exception)它专门用来抑制异常使用前请确保你确实想忽略它。陷阱三十__del__方法的不可靠执行class Resource: def __del__(self): # 清理资源 pass__del__在对象被垃圾回收时调用但你无法精确控制它何时执行甚至可能不执行例如程序崩溃、循环引用导致无法回收。此外在__del__中访问全局变量可能会引发异常因为解释器可能正在关闭模块已被清理。避坑方案永远不要依赖__del__来释放关键资源。使用上下文管理器with或显式的close()方法。如果一定要在对象消亡时做清理考虑使用weakref.finalize或atexit.register它们更可靠。__del__的另一个陷阱如果__del__抛异常会向sys.stderr打印警告而不是传播异常导致错误静默丢失。结语避开陷阱的唯一方法是理解陷阱为何存在我整理这三十个陷阱的目的不是为了吓唬你放弃 Python而是想告诉你每一个“坑”的背后都对应着 Python 的设计哲学——动态、灵活、信任程序员。可变默认参数体现了“定义时求值”的机制闭包延迟捕获体现了词法作用域的精确实现列表推导变量泄漏是语言演进的遗迹——这些不是 bug而是特性。但特性用错了就成了坑。检验是否真正掌握了 Python不是看你会多少装饰器或元类而是看你在遇到异常时能否准确说出原因并修复。下次遇到诡异现象别急着加try-except或重启打开pdb跑一下dis.dis()看字节码或者把__dict__打出来看看。你会发现绝大多数“玄学”在 Python 的底层机制面前都是确定的。最后送大家三句话写在键盘贴纸上可变对象做默认参数 定时炸弹闭包里的循环变量一定要用默认参数冻结is只适合 None适合一切祝你从此在 Python 的坑洼路上一马平川。