Python 编程的常见陷阱与奇巧淫技
Posted on Tue 27 January 2026 in Tech
| Abstract | Python 编程的常见陷阱与奇巧淫技 |
|---|---|
| Authors | Walter Fan |
| Category | tech note |
| Status | v1.0 |
| Updated | 2026-01-27 |
| License | CC-BY-NC-ND 4.0 |
Python:最容易上手,也最容易写烂的语言
Python 大概是世界上"入门门槛最低"的编程语言之一。
不信你看:
- 不用声明类型
- 不用写分号
- 不用编译
- 缩进即作用域
- 标准库啥都有
一个从没写过代码的人,花半小时就能跑起一个"Hello World",花一下午就能写一个爬虫。这是 Python 的魅力:它让编程变得"不那么可怕"。
但问题也来了:入门容易,精通很难;能跑起来,跑稳很难。
我见过太多这样的代码:
- 测试环境跑得好好的,上线就崩
- 小数据量没问题,数据一大就卡死
- 逻辑看着没毛病,但结果就是不对
- 代码能用,但没人敢改
这些问题往往不是"不会写",而是踩了 Python 的隐藏陷阱。
Python 的设计哲学里有很多"和直觉不一样"的地方:
| 你以为的 | 实际上 |
|---|---|
| 默认参数每次调用都初始化 | 只在函数定义时初始化一次 |
is 和 == 差不多 |
一个比地址,一个比值 |
| 拷贝就是独立副本 | 浅拷贝只复制一层 |
| lambda 里的变量是"快照" | 是引用,循环结束才求值 |
这些坑,不踩不知道,踩一次记一辈子。
所以这篇文章的目的很明确:
- 陷阱:帮你避开那些"看起来应该行,但就是不行"的经典坑
- 奇巧淫技:教你一些"原来还能这么写"的骚操作
- 工程建议:给你一套可落地的"写好 Python"的检查清单
读完你会发现:Python 很简单,但写稳定、高效、可维护的 Python 一点都不简单。
一、10 大陷阱:这些坑你迟早会踩
陷阱 1:可变默认参数(新手杀手 No.1)
def append_to_list(item, target=[]):
target.append(item)
return target
你以为每次调用都会得到一个新列表?
print(append_to_list(1)) # [1]
print(append_to_list(2)) # [1, 2] ???
print(append_to_list(3)) # [1, 2, 3] ??????
原因:默认参数只在函数定义时计算一次,不是每次调用时。target=[] 那个 [] 是同一个对象。
正确写法:
def append_to_list(item, target=None):
if target is None:
target = []
target.append(item)
return target
经验法则:默认参数永远不要用可变对象(list、dict、set)。用
None代替,函数内再初始化。
陷阱 2:闭包变量绑定(循环里创建 lambda 的噩梦)
funcs = []
for i in range(3):
funcs.append(lambda: i)
print([f() for f in funcs]) # [2, 2, 2] 不是 [0, 1, 2]
原因:lambda 里的 i 不是"创建时的值",而是对变量 i 的引用。循环结束时 i == 2,所以三个 lambda 都返回 2。
正确写法:用默认参数"捕获"当时的值
funcs = []
for i in range(3):
funcs.append(lambda i=i: i) # 注意 i=i
print([f() for f in funcs]) # [0, 1, 2]
或者用 functools.partial:
from functools import partial
funcs = [partial(lambda x: x, i) for i in range(3)]
print([f() for f in funcs]) # [0, 1, 2]
陷阱 3:is vs ==(小整数缓存的坑)
a = 256
b = 256
print(a is b) # True
a = 257
b = 257
print(a is b) # False ???
原因:Python 会缓存 -5 到 256 之间的小整数。is 比较的是对象身份(内存地址),不是值。
规则:
- 比较值:用
== - 比较身份(是不是同一个对象):用
is - 判断 None:用
is None(因为 None 是单例)
# 正确
if x is None:
pass
# 错误(虽然通常也能用,但不规范)
if x == None:
pass
陷阱 4:浅拷贝 vs 深拷贝(嵌套结构的隐形炸弹)
import copy
original = [[1, 2], [3, 4]]
shallow = original.copy() # 或 list(original) 或 original[:]
deep = copy.deepcopy(original)
original[0][0] = 999
print(shallow) # [[999, 2], [3, 4]] 被改了!
print(deep) # [[1, 2], [3, 4]] 没被改
原因:浅拷贝只复制"第一层",嵌套的对象还是共享引用。
什么时候用深拷贝:
- 嵌套结构(list of list、dict of dict)
- 你要完全独立的副本
什么时候浅拷贝就够:
- 扁平结构(一维列表、简单字典)
- 只读访问
陷阱 5:列表乘法创建引用(又一个新手杀手)
# 想创建一个 3x3 的二维数组
matrix = [[0] * 3] * 3
print(matrix) # [[0, 0, 0], [0, 0, 0], [0, 0, 0]] 看起来没问题?
matrix[0][0] = 1
print(matrix) # [[1, 0, 0], [1, 0, 0], [1, 0, 0]] 三行都被改了!
原因:[[0] * 3] * 3 创建的是三个指向同一个列表的引用,不是三个独立的列表。
正确写法:
# 用列表推导式,每次都创建新列表
matrix = [[0] * 3 for _ in range(3)]
matrix[0][0] = 1
print(matrix) # [[1, 0, 0], [0, 0, 0], [0, 0, 0]] 正确!
记住:
*复制的是引用,不是对象本身。
陷阱 6:字典迭代时修改(运行时炸弹)
d = {'a': 1, 'b': 2, 'c': 3}
for key in d:
if d[key] == 2:
del d[key] # RuntimeError: dictionary changed size during iteration
正确写法:先收集要删的 key,再删
# 方法一:收集后删除
to_delete = [k for k, v in d.items() if v == 2]
for k in to_delete:
del d[k]
# 方法二:构造新字典
d = {k: v for k, v in d.items() if v != 2}
陷阱 7:字符串拼接的性能陷阱
# 错误:每次 += 都会创建新字符串,O(n²)
result = ""
for s in many_strings:
result += s
# 正确:用 join,O(n)
result = "".join(many_strings)
字符串是不可变的,+= 会创建新对象。当 many_strings 很大时,性能差距是数量级的。
陷阱 8:浮点数精度问题(钱算错了别怪我)
print(0.1 + 0.2) # 0.30000000000000004 ???
print(0.1 + 0.2 == 0.3) # False ??????
原因:浮点数在计算机里是二进制表示的,0.1 在二进制里是无限循环小数,存储时会有精度损失。
解决方案:
# 方法一:用 decimal(金融计算必用)
from decimal import Decimal
print(Decimal('0.1') + Decimal('0.2')) # 0.3
# 方法二:用 math.isclose 比较(Python 3.5+)
import math
print(math.isclose(0.1 + 0.2, 0.3)) # True
# 方法三:用整数运算(以分为单位存储金额)
price_in_cents = 100 + 200 # 3 块钱
涉及钱的计算,永远不要直接用 float。
陷阱 9:except 捕获太宽(吞掉所有异常)
# 错误:什么异常都吞了,包括 KeyboardInterrupt、SystemExit
try:
do_something()
except:
pass
# 也不好:Exception 太宽
try:
do_something()
except Exception:
pass
# 正确:只捕获你能处理的
try:
do_something()
except (ValueError, KeyError) as e:
handle_error(e)
我的原则:捕获异常要像开枪一样精准,别用散弹枪。
陷阱 10:GIL 与多线程(以为能加速,结果更慢)
import threading
counter = 0
def increment():
global counter
for _ in range(1000000):
counter += 1
threads = [threading.Thread(target=increment) for _ in range(2)]
for t in threads:
t.start()
for t in threads:
t.join()
print(counter) # 不是 2000000,而是一个随机的小于 2000000 的数
原因:
- GIL(全局解释器锁):CPython 同一时刻只有一个线程执行 Python 字节码,多线程不能利用多核 CPU
counter += 1不是原子操作:包含读取、加 1、写回三步,线程可能在任何一步被切换
解决方案:
# CPU 密集型:用多进程
from multiprocessing import Process, Value
# 或者用 threading.Lock 保护共享变量
import threading
counter = 0
lock = threading.Lock()
def increment():
global counter
for _ in range(1000000):
with lock:
counter += 1
# IO 密集型:多线程或 asyncio 仍然有效
import asyncio
记住:Python 多线程适合 IO 密集型(网络请求、文件读写),CPU 密集型用多进程或者换语言。
二、10 小窍门:让代码更优雅的骚操作
窍门 1:海象运算符 :=(Python 3.8+)
# 之前
line = file.readline()
while line:
process(line)
line = file.readline()
# 之后
while (line := file.readline()):
process(line)
# 在条件表达式中避免重复计算
if (n := len(some_list)) > 10:
print(f"List is too long ({n} elements)")
特别适合"赋值并判断"的场景。
窍门 2:字典合并(Python 3.9+)
# 旧写法
merged = {**dict1, **dict2}
# 新写法(更清晰)
merged = dict1 | dict2
# 就地更新
dict1 |= dict2
窍门 3:collections 里的宝藏
from collections import defaultdict, Counter, namedtuple, deque
# defaultdict:自动初始化
word_count = defaultdict(int)
for word in words:
word_count[word] += 1 # 不用先 if word not in word_count
# Counter:计数神器
counter = Counter(words)
print(counter.most_common(3)) # 前 3 个高频词
# namedtuple:轻量级数据类
Point = namedtuple('Point', ['x', 'y'])
p = Point(1, 2)
print(p.x, p.y) # 比用 dict 或 tuple 更清晰
# deque:高效的双端队列
q = deque(maxlen=3) # 固定长度,自动弹出旧元素
q.append(1)
q.append(2)
q.append(3)
q.append(4) # 1 被自动弹出
print(q) # deque([2, 3, 4], maxlen=3)
窍门 4:itertools 里的黑魔法
from itertools import chain, groupby, islice, cycle, combinations
# chain:扁平化多个可迭代对象
for item in chain([1, 2], [3, 4], [5]):
print(item) # 1, 2, 3, 4, 5
# groupby:分组(注意要先排序!)
data = [('a', 1), ('a', 2), ('b', 3), ('b', 4)]
for key, group in groupby(data, key=lambda x: x[0]):
print(key, list(group))
# a [('a', 1), ('a', 2)]
# b [('b', 3), ('b', 4)]
# islice:切片迭代器(不用全部加载到内存)
first_10 = islice(huge_iterator, 10)
# combinations:组合
list(combinations([1, 2, 3], 2)) # [(1, 2), (1, 3), (2, 3)]
窍门 5:上下文管理器(with 的威力)
# 自定义上下文管理器(类方式)
class Timer:
def __enter__(self):
self.start = time.time()
return self
def __exit__(self, *args):
self.elapsed = time.time() - self.start
print(f"耗时: {self.elapsed:.2f}s")
with Timer():
do_something_slow()
# 更简洁:用 contextlib
from contextlib import contextmanager
@contextmanager
def timer():
start = time.time()
yield
print(f"耗时: {time.time() - start:.2f}s")
with timer():
do_something_slow()
窍门 6:__slots__ 省内存的大杀器
# 普通类:每个实例都有一个 __dict__
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
# 使用 __slots__:省掉 __dict__,内存占用大幅下降
class Point:
__slots__ = ['x', 'y']
def __init__(self, x, y):
self.x = x
self.y = y
当你要创建百万级对象时,__slots__ 能省 40%-50% 内存。
代价:不能动态添加属性。
窍门 7:functools 的实用工具
from functools import lru_cache, partial, reduce
# lru_cache:自动缓存(适合纯函数)
@lru_cache(maxsize=128)
def fibonacci(n):
if n < 2:
return n
return fibonacci(n-1) + fibonacci(n-2)
# partial:固定部分参数
def power(base, exponent):
return base ** exponent
square = partial(power, exponent=2)
print(square(5)) # 25
# reduce:累积操作
from functools import reduce
product = reduce(lambda x, y: x * y, [1, 2, 3, 4]) # 24
窍门 8:一行流(Pythonic 但别过度)
# 交换变量
a, b = b, a
# 条件表达式
result = "yes" if condition else "no"
# 链式比较
if 0 < x < 10:
pass
# 多重赋值
a = b = c = 0
# 解包
first, *middle, last = [1, 2, 3, 4, 5]
# first=1, middle=[2,3,4], last=5
# 字典推导式反转 key-value
inverted = {v: k for k, v in original.items()}
# any / all
if any(x > 10 for x in numbers):
print("有大于10的")
if all(x > 0 for x in numbers):
print("全是正数")
窍门 9:dataclasses(Python 3.7+)
from dataclasses import dataclass, field
@dataclass
class User:
name: str
age: int
tags: list = field(default_factory=list) # 可变默认值要用 field
def is_adult(self) -> bool:
return self.age >= 18
user = User("Alice", 25)
print(user) # User(name='Alice', age=25, tags=[])
比 namedtuple 更灵活,比手写 __init__ 更省事。
窍门 10:f-string 的高级用法
name = "Alice"
score = 95.5678
# 基本用法
print(f"Hello, {name}!")
# 格式化数字
print(f"Score: {score:.2f}") # 95.57
# 对齐
print(f"{name:>10}") # 右对齐,宽度10
print(f"{name:<10}") # 左对齐
print(f"{name:^10}") # 居中
# 调试神器(Python 3.8+)
x = 10
y = 20
print(f"{x=}, {y=}") # x=10, y=20
# 表达式
print(f"{2 + 2 = }") # 2 + 2 = 4
三、性能速查表
| 场景 | 慢 | 快 |
|---|---|---|
| 字符串拼接 | += 循环 |
"".join(list) |
| 成员检查 | x in list |
x in set |
| 计数 | 手写 dict | Counter |
| 缓存计算 | 每次重算 | @lru_cache |
| 大量小对象 | 普通 class | __slots__ 或 namedtuple |
| 读大文件 | readlines() |
逐行迭代 for line in file |
TL;DR(太长不看版)
10 大陷阱速记:
| # | 陷阱 | 正确做法 |
|---|---|---|
| 1 | 可变默认参数 | 用 None,函数内初始化 |
| 2 | 闭包变量绑定 | lambda i=i: i 捕获值 |
| 3 | is vs == |
比值用 ==,判 None 用 is |
| 4 | 浅拷贝嵌套结构 | copy.deepcopy() |
| 5 | 列表乘法创建引用 | 列表推导式 [[] for _ in range(n)] |
| 6 | 迭代时修改字典 | 先收集再删,或构造新字典 |
| 7 | 字符串 += 拼接 |
"".join(list) |
| 8 | 浮点数精度 | Decimal 或整数运算 |
| 9 | except 太宽 |
只捕获你能处理的异常 |
| 10 | GIL 多线程 | CPU 密集用多进程,IO 密集用 asyncio |
10 小窍门速记:
| # | 窍门 | 一句话 |
|---|---|---|
| 1 | 海象运算符 := |
赋值并判断 |
| 2 | 字典合并 \| |
比 {**a, **b} 更清晰 |
| 3 | collections |
Counter、defaultdict、deque |
| 4 | itertools |
chain、groupby、islice |
| 5 | 上下文管理器 | @contextmanager 简化 with |
| 6 | __slots__ |
百万对象省 40% 内存 |
| 7 | functools |
lru_cache、partial |
| 8 | 一行流 | 解包、链式比较、any/all |
| 9 | dataclasses |
比 namedtuple 更灵活 |
| 10 | f-string | f"{x=}" 调试神器 |
四、工程建议:写出"能上线"的 Python
知道陷阱和技巧还不够,真正的挑战是把它们融入日常习惯。这里给你一套可落地的建议。
4.1 代码风格:让别人能接手
# 推荐的工具链
pip install black isort flake8 mypy
# 格式化
black your_code.py
isort your_code.py
# 静态检查
flake8 your_code.py
mypy your_code.py
- black:自动格式化,别再纠结缩进和换行
- isort:自动整理 import 顺序
- flake8:检查代码风格和常见问题
- mypy:类型检查(配合 type hints)
我的原则:让机器能检查的,就别靠人肉 review。
4.2 类型提示:给未来的自己留条活路
# 不推荐
def process(data):
return data.get('name')
# 推荐
from typing import Optional
def process(data: dict[str, str]) -> Optional[str]:
return data.get('name')
类型提示的好处:
- IDE 能自动补全
- mypy 能提前发现错误
- 三个月后你还能看懂自己写的代码
4.3 测试:别等上线才发现问题
# 用 pytest,简单直接
def test_append_to_list():
result = append_to_list(1)
assert result == [1]
# 验证不会有"可变默认参数"的问题
result2 = append_to_list(2)
assert result2 == [2] # 应该是新列表,不是 [1, 2]
最小测试覆盖:
- 核心逻辑:必须测
- 边界条件:空值、None、超大数据
- 已知陷阱:可变默认参数、闭包变量等
4.4 日志:出了问题能查
import logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
def do_something(data):
logger.info(f"开始处理,数据量: {len(data)}")
try:
result = process(data)
logger.info(f"处理完成,结果: {result}")
return result
except Exception as e:
logger.exception(f"处理失败: {e}")
raise
线上出问题时,日志是你唯一的眼睛。
4.5 依赖管理:别让环境变成玄学
# 推荐用 poetry 或 pip-tools
pip install poetry
poetry init
poetry add requests
# 或者用 pip-tools
pip install pip-tools
pip-compile requirements.in
pip-sync requirements.txt
- 锁定依赖版本
- 区分开发依赖和运行时依赖
- 用虚拟环境隔离项目
Checklist:写 Python 前/后自检
写代码时(对照 10 大陷阱)
- [ ] 陷阱 1:函数的默认参数有没有用可变对象?(用
None代替) - [ ] 陷阱 2:循环里创建 lambda/闭包有没有正确捕获变量?(
i=i) - [ ] 陷阱 3:比较值用
==,比较 None 用is None? - [ ] 陷阱 4:嵌套数据结构需要独立副本时用了
deepcopy? - [ ] 陷阱 5:用
[[]] * n创建二维数组了吗?(改用列表推导式) - [ ] 陷阱 6:迭代时没有修改正在迭代的 dict/list?
- [ ] 陷阱 7:字符串大量拼接用
"".join()了? - [ ] 陷阱 8:涉及金额计算用了
Decimal而不是float? - [ ] 陷阱 9:异常捕获是否足够精准?(别用裸
except) - [ ] 陷阱 10:CPU 密集型任务用多进程而不是多线程?
提交前
- [ ] 跑了
black/isort格式化? - [ ] 跑了
flake8/mypy检查? - [ ] 核心逻辑有单元测试覆盖?
- [ ] 关键路径有日志输出?
- [ ] 依赖版本锁定了(requirements.txt / poetry.lock)?
Code Review 时
- [ ] 有没有"我以为"的隐式假设?
- [ ] 边界条件处理了吗(空值、None、超大数据)?
- [ ] 异常处理合理吗?
- [ ] 性能敏感的地方考虑过复杂度吗?
- [ ] 有没有用到本文提到的陷阱写法?
扩展阅读
- Fluent Python — Python 进阶必读,深入理解 Python 的数据模型
- Effective Python — 90 条 Python 编程建议(第二版)
- Python Cookbook — 实用代码食谱,拿来就能用
- Python 官方文档 - itertools — 迭代器工具库
- Python 官方文档 - functools — 函数工具库
- Real Python — 高质量 Python 教程网站
@startmindmap
* Python 陷阱与技巧
** 常见陷阱
*** 可变默认参数
*** 闭包变量绑定
*** is 与 == 区别
*** 浅拷贝深拷贝
*** 列表乘法引用
** 实用技巧
*** 海象运算符
*** 字典合并操作
*** collections 模块
*** itertools 模块
*** 上下文管理器
** 性能优化
*** join 拼接字符串
*** set 检查成员
*** lru cache 缓存
** 工程实践
*** 代码格式化工具
*** 类型提示
*** pytest 测试
@endmindmap

本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。