
# 这是你的代码吗不这是你正在燃烧的CPU时间。 # 试着运行下面这段“优雅”的Python——它会在你等待时把每秒千万次的内存分配变成一场灾难。 def process(items): result [] for item in items: temp [x 2 for x in item] # 每次循环都生成一个新列表 result.extend(temp) return result如果你看不出这段代码有什么问题那么恭喜你已经踩中了Python性能陷阱的第一课。大多数性能瓶颈并非来自算法本身的复杂度而是源于那些被忽视的语言特性和隐含开销。本文不讨论如何用C扩展或PyPy加速只聚焦于纯Python开发中五个最具杀伤力的陷阱以及你可以在不牺牲可读性的前提下直接套用的规避策略。陷阱一把属性查找当成免费午餐Python的“一切皆对象”带来了极致灵活性但这份礼物的价格就是你每次写self.name、module.func()时解释器都要执行一系列字典查找。一个简单的obj.attr底层会触发__getattribute__先查类型、再查实例字典、再查类字典必要时还要遍历MRO。你在循环里每调用一次self.method()就重复支付一次完整的查找费用。更隐蔽的是全局变量和局部变量之间的性能差异可达数倍因为全局变量存储在字典中而局部变量在栈帧里用索引直接访问。规避方法把循环内高频访问的方法和属性绑定到局部变量。例如在循环外写好m self.method循环内调用m()对于math.sqrt这类模块函数先sqrt math.sqrt再在循环里用能轻松获得20%到50%的提升。再看len()这种内建函数看似很快但如果你在循环体内反复调用len(some_list)每次依然要经历全局名字查找。最好的做法是把所有会重复使用的模块函数、对象方法、类属性在进入循环之前“缓存”为局部变量。# 坏味道 for i in range(1000000): result math.sqrt(i) len(data) # 规避后 sqrt math.sqrt n len(data) for i in range(1000000): result sqrt(i) n这里的关键认知是属性查找的性能成本是真实且可量化的在百万次迭代中每一微秒都会放大成秒级差距。不要轻视这种“风格化”改动它不是过早优化而是对Python执行模型的理解。陷阱二循环内隐式创建容器很多开发者习惯在循环里写result []然后逐项append。这本身没错但当你写的不是简单append而是new_list [x 2 for x in old_list]这样的推导式时每一次迭代都会生成一个全新的列表对象。如果循环体本身又迭代了上万次你就会创建上万个中间列表给GC带来巨大压力。更糟糕的是用来拼接字符串或列表每一次list1 list2都会生成新列表并复制所有元素——这是O(n)的复制操作放在循环里就是O(n²)的灾难。规避方法理解容器创建的时机尽量让容器生成发生在外层内层只做元素变换。例如上面那个坏代码改成生成器表达式并不够因为extend仍会重建整个列表更好的做法是改用itertools.chain.from_iterable组合生成器或者直接写双层推导式。对于字符串拼接务必使用.join(part_list)而不是因为join只在最终时刻分配一次内存。另外避免不必要的列表中间结果能用迭代器就用迭代器能用元组就把列表转成元组元组的存储开销更小。但这不代表放弃推导式的可读性——你需要的是“只创建一次结果容器”而不是在循环内部反复创建临时容器。# 坏味道循环内创建生成器并extend def flatten(rows): result [] for row in rows: result.extend(x 2 for x in row) # 每次生成一个新的生成器对象 return result # 规避后一次性推导 def flatten(rows): return [x 2 for row in rows for x in row]一旦循环体内出现[]、{}、()字面量Python在字节码层面就会创建对应容器对象。如果你的循环体只是条件判断那些看似无害的[]字面量实际上消耗着时间和内存。把临时空容器放在循环外复用是极端优化但至少要保证容器类型不会随心在循环中“产生”。陷阱三用Python循环处理数值计算看过太多人用纯Python写矩阵乘法或欧氏距离计算然后抱怨“Python太慢”。事实上Python的原生循环比C语言慢几十倍因为每一次迭代都要完成层层的类型检查和指令分派。当你写for i in range(10000): total a[i] b[i]时解释器不仅要执行a[i]的索引操作触发__getitem__还要乘法、加法、赋值每一步都涉及动态类型确认。这种代码无论怎么优化局部变量都无法突破语言本身的桎梏。规避方法把数值密集计算交给C语言实现的扩展模块最典型的就是NumPy。用import numpy as np向量化表达式如np.dot(a, b)或a b底层是BLAS的C程序速度提升是数量级的。如果你不想引入NumPy至少用内置的sum和map结合生成器表达式这比手动for循环快两倍左右但依然受限于Python浮点对象的开销。真正的本质是Python的循环适合做逻辑控制不适合做数值遍历。遇到需要对每个元素做纯数学运算的场景思考能否用数组切片或广播操作替代。一个典型的例子是计算像素点距离np.sqrt((x1-x2)2 (y1-y2)2)而不是嵌套循环。# 坏味道纯Python计算距离 def dists(xs, ys, x0, y0): out [] for i in range(len(xs)): out.append(((xs[i]-x0)2 (ys[i]-y0)2)0.5) return out # 规避后向量化 def dists(xs, ys, x0, y0): return np.sqrt((np.asarray(xs)-x0)2 (np.asarray(ys)-y0)2)请记住凡是能写成“对整个数组做同样操作”的逻辑就不应该用Python循环逐个元素处理。这不仅让代码更简洁还能让性能提升几十倍。如果你的代码里出现了for i in range(len(arr))再手动索引那便是一个强烈的信号你正在用低级工具做高级工作。陷阱四忽略正则表达式及其他预编译的重要性正则表达式是Python开发中常见的性能杀手。很多人会在循环内调用re.search(pattern, text)每次都传入相同的pattern字符串。re模块内部会先缓存编译后的模式但缓存查找本身也有开销更重要的是如果模式包含复杂的回溯逻辑比如嵌套量词、分组回溯那么即使已编译每次匹配也可能消耗指数级时间。另一个常见陷阱是在热路径中动态构造正则模式比如re.compile(f{prefix}(\\d))——这不仅会重新编译还会导致缓存膨胀。规避方法将正则表达式预编译为模块级别的常量。不要用re.search(pattern, s)直接用compiled_pattern.search(s)。同时避免使用过度复杂的正则表达式尤其是含有嵌套量词或条件回溯的模式。如果可能优先使用字符串方法startswith()、endswith()、find()来替代简单匹配因为字符串方法在C层实现且没有正则引擎的状态机开销。真正的正则威力是处理复杂模式而不是查找字符。# 坏味道每次调用均隐式编译 def extract_nums(texts): out [] for t in texts: m re.search(rID:(\\d), t) # 每次循环都做模式查找/解析 if m: out.append(m.group(1)) return out # 规避后预编译 _ID_PATTERN re.compile(rID:(\\d)) def extract_nums(texts): return [m.group(1) for t in texts if (m : _ID_PATTERN.search(t))]把预编译模式放在模块顶层不仅能复用还让模式的定义远离业务逻辑。如果模式会动态变化至少使用re.compile并传入缓存字典避免无限制编译。正则的回溯优化是一项单独学问但基础原则是别在性能关键路径上让解释器替你重复编译模式。一个未预编译的正则匹配其成本是预编译的3到5倍而复杂模式下可能更糟糕。陷阱五用list模拟队列和栈时选择错误的数据结构Python内置的list是动态数组支持O(1)的尾部追加和弹出但list.pop(0)或list.insert(0, x)是O(n)操作因为所有元素都要移动。很多开发者在实现BFS队列、滑动窗口或高频插入删除时直接使用list然后惊讶于数据量超过十万后程序变得奇慢。这也涉及到循环中的另一个陷阱在遍历列表时删除元素for i in list: if ...: list.remove(i)这不仅是O(n²)还会导致迭代器错乱。规避方法根据操作模式选择正确的容器。若需要频繁从头两端弹出/插入使用collections.deque它的两端操作都是O(1)。若需要在任意位置多次插入删除考虑blist或sortedcontainers第三方实现。若只是用栈list.append和pop()是最佳方案但不要用list模拟队列。对于“边遍历边删除”的需求正确做法是用列表推导式创建新列表或从后向前倒序删除。# 坏味道用list作为队列 queue [] while queue: item queue.pop(0) # O(n)数据越大越悲剧 # 规避后deque from collections import deque queue deque() while queue: item queue.popleft() # O(1)列表推导式的性能不仅在于避免显式循环更在于它避开了在迭代过程中修改容器的陷阱。当你想要过滤一个列表时[x for x in data if condition(x)]远比“遍历remove”快也安全得多。要记住Python的list优化点全部集中在尾端操作任何对头部或中间的操作都要警惕它们的时间复杂度远高于你的直觉。如果数据结构需要按键访问且频繁插入删除改用dict或set它们的哈希表结构在平均情况下能保持O(1)的查找。陷阱之外如何培养性能直觉上述五个陷阱不是孤立的存在它们共同指向一个内核Python的性能模型遵循“局部性”和“批量性”原则——数据在局部变量中访问最快操作在批量函数中执行最快。开发时每当写下for循环问自己三个问题每轮循环是否都执行了不必要的新对象创建是否有嵌套的属性查找能否把循环内的整个操作提升为内置函数的调用不要盲目相信“Python太慢”的论断很多慢代码的根源是开发者在用C语言的思维写Python却得不到C语言的速度。一个实用的做法是先写出正确、朴素的版本然后使用cProfile定位热点函数。你会发现性能瓶颈往往集中在少数几行那些行通常就是命中了上面某个陷阱。例如一个爬虫程序可能慢在正则预编译一个数据分析脚本可能慢在纯Python循环一个异步服务可能慢在全局锁竞争和重复装饰器调用。最终优化代码时优先处理“热点中的热点”而不是全面铺开重构。在你动手优化之前用timeit验证假设创建一个测试用例分别运行“坏版本”和“好版本”得到量化数据再行动。这样你既避免了过早优化也能确保你的改动确实有效。另外注意性能陷阱有时藏在函数装饰器、属性装饰器、property访问、__slots__缺失等问题中。如果你的类拥有大量实例且每个实例都有相同的属性可以考虑定义__slots__来关闭字典存储这会节省大量内存并提高属性访问速度。但不要滥用__slots__它限制了动态添加属性的灵活性只有在你确认实例数量巨大且属性固定时才使用。同一原理适用于数据类dataclass方便但每个字段访问都经过__getattribute__如果追求极致普通类配合__slots__更高效。让性能成为设计的一部分而不是救火手段你的代码最终会运行在真实设备上面对真实数据量。不要在写第一版时就纠结微观优化但也不要在瓶颈出现后才慌不择路。最好的规避方法是在设计数据结构时就想清楚操作的复杂度在选择API时就想清楚是否会产生隐藏的中间对象。例如当你决定用拼接字符串前想想是否有更优雅的join用sum对浮点数求和时想想math.fsum是否更精确用list做去重时想想set是否更合适。这些决策并不复杂它们只是需要你对Python执行模型有真实的理解。把上面的五个陷阱总结成一张检查单循环里有没有全局查找、临时容器数值计算是否本可用向量化正则模式是否预编译数据结构是否匹配操作方式是否用list模拟了deque。每次写完一段关键的循环就对照检查一遍。坚持这样做你的Python代码将不再是“可运行的伪代码”而是具有接近底层扩展性能的工程代码。性能提升不是魔法而是对每个细节的掌控。现在回到开头那段代码你是否看到了一个更好的写法使用列表推导式在任何数据规模下都可能快上四五倍你的CPU会感谢你的这次阅读。