从会写 Python,到真正理解 Python
学习 Python 有些年头了,我一直觉得自己"挺懂"。直到有次 review 朋友的代码,看到一个函数用 lst=[] 当默认参数,我说这是坑,他反问我"为什么是坑?"。我张了张嘴,发现自己其实也讲不太清。
那之后我认真捋了一遍。所谓 Python 的"高级",从来不是语法写得多花,而是开始看懂它背后的对象模型、协议、执行机制和设计哲学。下面这八个点,是我真正入门之后才想明白的东西。
生成器最大的特点就一个字:懒。它不急着算,你 next 一次它算一次。所以它省内存——一个 range(10**9) 不会真的开一亿个格子。
坑在于,生成器只能走一遍。
1
2
3
gen = (x * x for x in range(3))
print(list(gen)) # [0, 1, 4]
print(list(gen)) # [] 已经耗尽了我之前写过一段"复用"生成器的代码,第二次遍历全空,查了半天。理解 yield 是"暂停并交出控制权",而不是"返回值",很多奇怪行为就通了。
装饰器剥开来看,就是一个接收函数、返回函数的高阶函数。
1
2
3
4
5
def log(func):
def wrapper(*args, **kwargs):
print(f"调用 {func.__name__}")
return func(*args, **kwargs)
return wrapper为什么老是多一层 wrapper?因为装饰器返回的那个函数,得在真正调用时再执行。带参数的装饰器,就是外面再套一层用来"收参数"的工厂函数,于是变成三层。
我第一次自己写装饰器时没加 functools.wraps,结果日志里所有函数都叫 wrapper,func.__name__、docstring 全丢了。就一行的事:
1
2
3
4
5
6
7
from functools import wraps
def log(func):
@wraps(func)
def wrapper(*args, **kwargs):
...
return wrapperwith 看着像语法糖,背后是 __enter__ 和 __exit__ 两个方法。进去执行前者,出来(含异常)一定执行后者。这就是它能保证资源释放的原因。
自己写一个也不难,适合封装数据库连接、文件锁这类"用完必须关"的东西。
有个隐蔽的坑:__exit__ 如果 return True,会把异常吞掉。我见过生产代码里有人为了让 with 不报错随手返回 True,结果异常全被吃了,排查起来想骂人。
Python 找名字的顺序是 LEGB:局部 → 闭包 → 全局 → 内置。
闭包最反直觉的一点:它捕获的是变量本身,不是那一刻的值。所以循环里造 lambda 会集体翻车:
1
2
funcs = [lambda: i for i in range(3)]
print([f() for f in funcs]) # [2, 2, 2]三个 lambda 都指向同一个 i,循环结束时 i 是 2。想各绑定一个,用默认参数固定住:
1
2
funcs = [lambda i=i: i for i in range(3)]
print([f() for f in funcs]) # [0, 1, 2]nonlocal 和可变默认参数的求值时机,都建立在这套机制上。
len(x) 调的是 x.__len__(),for 循环找 __iter__ 和 __next__。所谓"Python 数据模型",就是一堆这样的协议。
理解了这一点,你就不用死记每个魔术方法,而是想:我这个对象想支持什么行为?想能被 len 量,就实现 __len__;想能 for,就实现迭代协议。
坑在 __eq__ 和 __hash__:你重写了 __eq__,对象默认就变得不可哈希了,放进 set 或当 dict 的 key 会报错。要记得连 __hash__ 一起处理,或者用 dataclass(frozen=True)。
先说清楚:类型注解运行时不强制。写 def f(x: int),传个字符串进来照样跑,它只是给静态检查工具看的。
Protocol 是这个体系里我最喜欢的一块。它把 Python 骨子里的鸭子类型,变成了静态可检查的契约:
1
2
3
4
5
6
7
from typing import Protocol
class Flyable(Protocol):
def fly(self) -> None: ...
def takeoff(obj: Flyable) -> None:
obj.fly()只要某个类有 fly 方法,它就算 Flyable,不用显式继承。跑 mypy 时能帮你提前拦住"传错了对象"的问题,又不用牺牲鸭子类型的灵活。
这是重灾区,我一度也是蒙的。核心就一句:CPython 有个 GIL,同一时刻只有一个线程在跑 Python 字节码。
所以:
- IO 密集(网络、磁盘、数据库)→ 线程或 async,因为等 IO 时 GIL 会释放,其他线程能干活。
- CPU 密集(计算、图像处理)→ 用
multiprocessing开进程,绕开 GIL,真正并行。 - async/await 是单线程协程,靠事件循环调度,不是多线程。
我在 async 函数里调过一个阻塞的库函数,直接把整个事件循环卡死,所有并发请求全堵在那。后来学乖了:重活丢给线程池,用 asyncio.to_thread。
Python 里的变量,本质是名字绑定到对象,不是"盒子装值"。所以:
1
2
3
4
a = [1, 2, 3]
b = a
b.append(4)
print(a) # [1, 2, 3, 4] a 也跟着变了因为 a 和 b 指向同一个 list 对象。这解释了开头那个默认参数坑:lst=[] 在函数定义时就创建了一个 list,之后每次调用都复用同一个,于是状态累积。
浅拷贝和深拷贝也在此分野:
1
2
3
4
5
6
import copy
a = [[1], [2]]
b = copy.copy(a) # 浅拷贝:外层新 list,内层 list 共享
c = copy.deepcopy(a) # 深拷贝:连内层一起复制
b[0].append(99)
print(a) # [[1, 99], [2]] a 的内层也被改了理解了"引用 vs 对象",可变性、参数传递、拷贝,全部串成一条线。
这八个点,单看每一个都不难。难的是把它们串起来,看到 Python 是"一套协议加一个对象模型",而不是一堆语法。我现在看 Python 代码,不再只想"这段能不能跑",而是想"它背后发生了什么"。
会写是门槛,看懂才算入门。