这个系列写到第十二篇,我发现一件挺有意思的事:真正拉开水平差距的,从来不是谁会背更多的语法糖,而是谁对 Python 运行时的行为边界更清楚。你可能已经把装饰器、推导式、生成器用得滚瓜烂熟,但一旦遇到内存不降、属性赋值失效、异步任务悄悄卡死这类问题,还是得回到最底层的对象模型和协议约定上找答案。
这篇不打算再罗列"十个好用的小技巧"那种清单。我把最近一年在真实项目里反复用得上的六个方向重新整理了一遍:从对象内存布局、描述符协议、上下文管理器契约,到生成器与协程的边界、类创建钩子、缓存与分派的失效场景。内容偏运行时机制,适合已经写过至少几千行 Python、能独立维护一个模块或服务的同学。如果你还在纠结怎么装环境、怎么写第一个函数,这篇可以收藏起来晚点再看。下面所有代码都在 CPython 3.11 上跑过,个别版本差异我会单独标出来。
1. 先算清内存这本账:slots、弱引用与循环引用的真实代价
__slots__大概是 Python 里最被滥用也最被误解的特性。很多人在面试里能一字不差地答出"用来节省内存、限制实例属性",转头在项目里加上去,用sys.getsizeof一测发现数字几乎没动,于是判定"这东西没用"。问题出在测量方法本身——sys.getsizeof只算对象自身那一块连续内存,根本不会把你挂在对象上的实例字典算进去,而__dict__恰恰是内存的大头。
1.1 用 sys.getsizeof 单测实例,等于什么都没测
一个不带__slots__的普通实例,它自己那点内存通常在几十字节量级,真正的开销是背后那个独立分配的实例字典。字典即使一个键都没插,也要占几十字节,插入几个键之后还会按 2 的幂次扩容,键和值的指针各自占位。所以正确的测量方式至少要写成两段相加:
import sys class Point: def __init__(self, x, y): self.x = x self.y = y p = Point(1, 2) print(sys.getsizeof(p)) # 只有实例头 print(sys.getsizeof(p.__dict__)) # 真正的开销在这里但即使这样也还是不够准。字典的值指向的对象(比如字符串、嵌套对象)本身的内存不在里面,容器类对象的容量也不等于实际元素大小。真要较真,用pympler.asizeof.asizeof(p)递归统计,或者用tracemalloc抓两份快照做差值——后者是我更推荐的方式,因为它能告诉你内存是在哪个调用栈上被分配的,而不是只给一个总数。
1.2slots的三个失效条件
我在代码评审里见过最多的写法是"给子类加__slots__,父类不管"。这种改法基本等于白干,因为__slots__的省内存效果依赖整条继承链都参与了。
- 继承链上有一环没写
__slots__。只要某个基类没定义__slots__,它就会自动带上__dict__,子类即使声明了__slots__,实例上依然存在那个字典,只是新字段改存到 slot 描述符里。省下的只是新增字段那部分,收益大幅缩水。 - 类里用了
functools.cached_property,或者代码里出现了self.__dict__、vars(self)。带__slots__的实例没有__dict__,这些写法会直接抛AttributeError。同理,那些喜欢往实例上动态挂临时属性的框架代码也会当场炸掉。 - 实例需要被
weakref引用,但__slots__里没写'__weakref__'。默认情况下定义了__slots__的类,实例是不可弱引用的,除非显式把'__weakref__'加进去,或者某个基类已经提供了这个槽位。这一点在写缓存、监听器列表时特别容易踩。
还有一些细节值得一并记住:__slots__里的名字不能和类变量重名,否则类创建阶段就直接报ValueError;旧版本 Python 里对带__slots__的类做 pickle 需要自己实现__getstate__和__setstate__,如果项目里有序列化环节,改之前一定要先跑一遍相关测试。
1.3 weakref 和 gc:什么时候该怀疑循环引用
"Python 靠引用计数,所以循环引用必然泄漏"这句话对一半。CPython 的确以引用计数为主,但分代垃圾回收器专门负责处理循环垃圾,绝大多数循环引用会被自动清掉。真正需要警惕的是两种情况:一是对象被gc判定为不可回收(比如旧版本里带__del__的循环,PEP 442 之后已经好很多),二是对象其实可达,只是你以为它该被释放了——比如被某个全局缓存或者异常回traceback 悄悄持有。
排查路径我一般这么走:先用gc.get_objects()或者objgraph看某一类实例的数量是不是持续上涨,再用gc.get_referrers(obj)反查是谁在引用它。objgraph.show_backrefs能直接画出一张引用链图,比手动翻代码快得多。sys.getrefcount也要会用,但记住它会把参数本身那一次引用也算进去,看到的数字通常比真实值多 1,别被吓到。
至于弱引用本身,weakref.WeakValueDictionary是我最常用的一个:拿它做实例级缓存,键是某个标识,值是对象,对象一旦没别的地方引用就自动从字典里消失,完全不需要手动清理。WeakSet适合做监听器集合。需要注意的是,int、str、tuple这些内置类型不支持弱引用,用之前先确认目标类型有没有__weakref__。
2. 描述符协议:property 只是它的一个特例
property用得太顺手,导致很多人根本没意识到它背后是一套通用协议。你写一个类,属性访问obj.x到底走哪条路,Python 有一套非常明确的优先级规则,理解它之后,很多"为什么我的 setter 没被调用""为什么子类属性把父类的覆盖了"之类的问题就不用猜了。
2.1 数据描述符和非数据描述符的分水岭
判断标准只有一条:这个描述符类型有没有定义__set__或__delete__。只有__get__的,叫非数据描述符;同时具备__set__或__delete__的,叫数据描述符(也有资料叫覆盖型描述符)。
这个区别的直接后果是:数据描述符会优先于实例字典,非数据描述符会被实例字典覆盖。最典型的例子就是函数——函数是非数据描述符,所以你可以在实例上写obj.method = some_callable,实例字典里的这个值会盖掉类上的方法。而property是数据描述符,所以一旦你给属性定义了 setter,就算实例字典里真有同名键,也会被 setter 接管。
2.2 属性查找的完整优先级顺序
把顺序写清楚,遇到问题直接对号入座:
- 在
type(obj).__mro__里找同名、且是数据描述符的属性,找到就调用它的__get__; - 找
obj.__dict__,有就直接返回; - 回到
type(obj).__mro__里找非数据描述符或普通类属性,找到就调用__get__或直接返回; - 前三步都失败,才轮到
__getattr__。
注意到__getattr__是最后兜底的,而__getattribute__是包住整个流程的总入口——想拦截所有属性访问只能重写后者,代价是性能下降且极易写出无限递归。有个小技巧:如果你只想在常规查找失败后做点事,用__getattr__,它只在失败时触发,开销可以忽略。还有一点容易混,__getattr__和__getattribute__的调用不会互相触发对方的兜底逻辑,重写一个不会自动影响另一个。
2.3set_name:让描述符知道自己叫什么
写描述符时经常需要知道"我被赋值给了哪个属性名",以前只能靠传参或者事后反射扫描,很别扭。Python 3.6 加了__set_name__,在类对象创建阶段由type.__new__自动调用,参数是拥有者和属性名:
class Field: def __set_name__(self, owner, name): self.name = name def __get__(self, obj, objtype=None): if obj is None: return self return obj.__dict__.get(self.name) def __set__(self, obj, value): obj.__dict__[self.name] = value class User: name = Field()这段代码有个坑值得记一下:Field作为数据描述符,__get__里却直接读obj.__dict__,如果不小心写成getattr(obj, self.name)就会无限递归。所以我养成了一个习惯,描述符内部一律走obj.__dict__而不是getattr,把这一层隔离干净。另外__set_name__只在类创建时调用一次,之后动态改类属性不会再触发,别指望它做运行时校验。
2.4 cached_property 为什么不能和slots搭配
functools.cached_property的实现思路很巧妙:它是一个非数据描述符,第一次访问时计算值,然后把这个值写进obj.__dict__。第二次访问时,实例字典里有这个键,而非数据描述符会被实例字典覆盖,所以__get__根本不会再被调用——缓存就这样生效了,零额外查询开销。
这也解释了两个限制。一是它必须有实例字典可写,带__slots__的类直接不能用;二是如果类重写了__setattr__并做了拦截,第一次写入可能失败。还有一个版本差异要注意,3.12 起cached_property移除了内部锁,多线程并发第一次访问时可能重复计算,如果这个计算有副作用(比如打日志、发请求),得自己在外面加锁保护。
3. with 语句背后的契约:上下文管理器与 ExitStack 的正确用法
上下文管理器看起来简单,但它和异常处理、资源释放、嵌套顺序强绑定,是生产事故的高发区。我在线上见过最典型的一类 bug,是"锁在线程异常退出时没释放",根因几乎都是手写acquire/release而没用with,或者在__exit__里悄悄吞掉了异常。
3.1exit的返回值决定了异常要不要继续抛
__exit__(exc_type, exc_val, exc_tb)的返回值是有语义的:返回真值表示异常已被处理,不再向外传播;返回None或假值表示继续抛出。很多手写的上下文管理器忘了写return,或者在不该返回True的地方返回了True,结果把一个本该让调用方感知的错误吞掉了——这种 bug 最难查,因为日志里什么都没有,程序看起来"正常运行"。
还有一个隐蔽点:如果__exit__内部自己抛了异常,它会替换掉 with 体里的原始异常,原异常会被挂在__context__上。所以清理逻辑本身必须足够健壮,别在finally里做可能失败的操作。像加锁、开事务这类场景,正确姿势是把释放动作放在finally里,而不是依赖返回值。
class Transaction: def __enter__(self): self.conn.begin() return self.conn def __exit__(self, exc_type, exc_val, exc_tb): if exc_type is None: self.conn.commit() else: self.conn.rollback() return False # 明确不吞异常3.2 @contextmanager 里最容易写漏的 try/finally
contextlib.contextmanager把带yield的生成器改造成上下文管理器,代码短很多,但它的执行模型必须理解透:yield之前的代码相当于__enter__,yield之后的代码相当于__exit__。如果 with 体里抛异常,这个异常会被抛回到生成器的 yield 处。
所以正确写法必须是包一层try/finally,否则 yield 之后那句清理代码压根不会执行:
from contextlib import contextmanager @contextmanager def timed(label): start = time.perf_counter() try: yield finally: print(f"{label}: {time.perf_counter() - start:.3f}s")另外几个约束记一下:生成器必须yield恰好一次,yield 之后不能再来第二个 yield,否则退出时会报RuntimeError: generator didn't stop;不要在finally里写return,那会干扰异常的传递;如果 with 体里发生了异常,你又想在yield处捕获它,用except包住 yield 即可,但要判断好是放行还是消化。
3.3 ExitStack:资源数量不确定时的解法
with的问题是它要求资源个数在写代码时就固定。一旦遇到"根据配置动态打开 N 个文件""按条件注册若干清理动作",嵌套 with 就会写成一团糟。这时候用contextlib.ExitStack:
from contextlib import ExitStack with ExitStack() as stack: files = [stack.enter_context(open(p)) for p in paths] stack.callback(logger.info, "资源已全部关闭") process(files)ExitStack内部维护一个回调栈,退出时按后进先出的顺序依次执行,这正好对应资源释放的安全顺序。enter_context用来接管上下文管理器,callback用来注册普通清理函数,push可以接管一个__exit__方法。它还有个挺实用的技巧:临时把一个上下文管理器转成装饰器用的时候,ExitStack能帮你省掉手写一层包装类。
有一点必须强调,ExitStack自身不是线程安全的,多个线程共用一个栈迟早出问题。异步场景请用contextlib.AsyncExitStack,它的enter_async_context和push_async_callback对应异步版本,aclose时会按顺序 await。
3.4 可重入与线程安全:用计数器解决老大难
一个常见需求是"同一个锁在同一线程里可以重复进入",标准库给了threading.RLock,但如果你自己实现一个需要可重入的上下文管理器(比如自定义连接池、采集器),就得自己维护重入计数。思路很直接:进入时计数加一,只有从 0 变 1 时才真正获取底层资源;退出时计数减一,归零时才真正释放。
class ReentrantGuard: def __init__(self): self._depth = 0 self._lock = threading.RLock() def __enter__(self): self._lock.acquire() self._depth += 1 return self def __exit__(self, *exc): self._depth -= 1 if self._depth == 0: self._lock.release() self._lock.release() return False这段是示意结构,真实实现里计数器本身的读写也要在锁保护下进行,否则多线程下会出现计数错乱、锁永远不释放的情况。我个人经验是:能复用标准库就别自己写,自己写就必须补一组并发测试,光靠单线程跑通不算数。
4. 生成器不是协程:yield from、send 与 asyncio 的三条边界线
yield这个词在 Python 里背了两层职责。一层是生成器,用来惰性产出一串值;另一层是协程,用来做双向通信。两套语义共用同一个关键字,导致很多概念被混在一起讲。把边界划清楚,再去看 asyncio 的调度行为就会顺很多。
4.1 yield 是表达式,不是语句
y = yield x这个写法说明yield有返回值,这个返回值来自外部的send()。规则有两条必须记牢:生成器刚创建还没启动时,只能next()或者send(None),传非 None 会直接报TypeError;send的值会成为挂起的那个yield表达式的结果。
def accumulator(): total = 0 while True: x = yield total if x is None: return total += x g = accumulator() next(g) # 启动,返回 0 print(g.send(5)) # 5 print(g.send(3)) # 8另外close()会在挂起点抛入GeneratorExit,生成器必须就此退出,如果它试图在异常处理里再yield一个值,会得到RuntimeError。throw()则可以往生成器里注入任意异常,这是早期手写协程调度器的基础。理解这三件套之后,你再看那些基于生成器的状态机实现就不会觉得玄乎了。
4.2 yield from 帮你转发了什么
yield from sub不只是把sub的值一个个吐出来,它还会自动转发三样东西:调用方的send会转发给子生成器,throw会被注入子生成器,close会关闭子生成器。更要紧的是,子生成器return的值会成为yield from表达式的结果,所以return (yield from sub)是一种很自然的写法——这就是把子生成器的结果继续往上传的标准套路。
还有一点:yield from会自动处理子生成器的StopIteration,把它转换成表达式的值而不是让它冒泡出去。如果你在写一些定制的迭代器组合逻辑(比如分块遍历、多路合并),用yield from会比手写循环转发干净很多,也不用担心异常语义出错。
但请记住,带 yield 的生成器并不是 asyncio 意义上的协程。它没有__await__、不能被事件循环调度,也无法被await。真要桥接,得用types.coroutine装饰或者__await__协议显式实现,这只是历史兼容路径,新代码不要往这个方向走,直接写async def就对了。
4.3 asyncio 里最常踩的三个坑
第一个坑是把阻塞调用写进了async def。这是生产环境最高频的事故源:在协程函数里调time.sleep、requests.get、又或者一段几百毫秒的 CPU 计算。事件循环是单线程的,这些调用会把整个循环堵住,所有并发任务一起停滞,表现就是"QPS 上不去、延迟莫名其妙飘高"。正确做法是 IO 阻塞交给asyncio.to_thread(或者直接用异步版客户端),CPU 密集交给loop.run_in_executor配ProcessPoolExecutor。判断标准很简单:这个函数会不会让出控制权,不会让出的就不该直接放在协程里。
第二个坑是asyncio.gather的异常语义。默认情况下,只要有任意一个子任务抛异常,gather会立刻把异常向上抛,而其他任务既不会被取消也不会被等待,它们还在后台跑,抛出的异常可能以"Task exception was never retrieved"的形式出现在日志里。想拿到全部结果用return_exceptions=True;想要结构化并发和自动取消,用 3.11 引入的asyncio.TaskGroup,它在退出时会等待所有任务并统一抛出ExceptionGroup。
第三个坑是取消不是立即停止。取消操作实际上是在下一个await点向协程注入CancelledError,所以同步代码段是拦不住的,执行到一半的清理逻辑必须写在finally里。注意CancelledError从 3.8 起继承自BaseException而不是Exception,那些宽泛的except Exception拦不住它,但如果你写了except BaseException又默默 pass,就会把取消信号吞掉,任务变成永不结束的僵尸。超时控制建议直接用asyncio.timeout(3.11+),比旧的wait_for语义清晰得多。
5. 元类与init_subclass:注册表模式到底该用哪个
"什么时候该用元类"是 Python 社区被问得最多的问题之一,而标准答案其实是"绝大多数时候都不该用"。真正需要元类的场景非常少,Python 3.6 之后又多了两条替代路径,把门槛进一步拉低了。下面把三种方案的适用边界摆清楚,最后给一个完整的插件注册表实现。
5.1 type() 的三参数形式和元类的真实职责
type(name, bases, namespace)是动态创建类的底层接口,你平时写的class语句本质就是编译器把它翻译成了一次type(...)调用。元类就是"类的类"——继承自type,用它来控制类的创建过程。两个钩子要分清:__new__负责构建并返回类对象,__init__负责初始化已经建好的类。要修改命名空间内容(增删属性)必须在__new__阶段做,__init__里改已经晚了。
元类还有个绕不开的限制叫"元类冲突":如果多个基类的元类不是同一条继承链上的,创建子类时会直接报TypeError: metaclass conflict。这在混用多个第三方库的时候很容易撞上,而且报错信息通常不够直白。所以我给的建议是,只在两个条件同时满足时才考虑元类——需要拦截类的创建本身(不只是子类化),以及这套逻辑要在多个互不相关的类之间共享。
5.2init_subclass能吃掉大部分元类需求
__init_subclass__是 3.6 加的类方法,在子类创建完成后被父类隐式调用,能拿到子类对象以及class语句里传来的关键字参数。它最擅长三件事:自动注册子类、强制子类实现某些方法、给子类批量注入属性。跟元类相比,它不需要你理解type.__new__的调用协议,也不会引发元类冲突。
有一个执行顺序的细节值得记住:在类创建过程中,描述符的__set_name__先被调用,然后才轮到__init_subclass__。这意味着如果你在__init_subclass__里读取某些描述符的name属性,它已经被设置好了,这个顺序在写框架时很有用。
5.3 一个插件注册表的完整实现和两个坑
class PluginBase: registry = {} def __init_subclass__(cls, *, key=None, **kwargs): super().__init_subclass__(**kwargs) if key is not None: if key in cls.registry: raise ValueError(f"插件键冲突: {key}") cls.registry[key] = cls class JsonPlugin(PluginBase, key="json"): ... print(PluginBase.registry) # {'json': JsonPlugin}这段代码能用,但有两个坑必须提前知道。第一,注册动作发生在模块导入时,如果某个插件所在的模块从头到尾没被import,它就不会出现在注册表里。解决方案是显式 import 对应的包,或者用pkgutil.iter_modules扫描目录强制导入。这个问题很隐蔽,因为本地测试时模块恰好被别的路径导入了,一上生产就"插件丢失"。
第二,抽象中间类也会被触发注册。如果你写了一个半成品基类是为了被继承而不是被使用,它同样会走__init_subclass__。解决办法是给它加一个标记位,在注册前检查:
if getattr(cls, "__abstract_plugin__", False): return用abc.ABC配合__abstractmethods__也行,但要留意只有存在抽象方法时这个属性才非空,空抽象类判断不出来。我自己的习惯是显式声明一个类属性作为标记,逻辑最直白,不容易出意外。
6. 性能与调试:从 dis 到 lru_cache 的失效场景
前面五节讲的都是"怎么把代码写对",这一节聊聊"怎么把它写快,以及怎么确认它真的快了"。性能优化最大的陷阱是凭感觉改代码——你觉得某处慢,改完发现毫无变化,反而引入了 bug。所以顺序永远是先测量、再定位、最后才优化,并且优化之后必须再测一次。
6.1 先用 dis 看清局部变量和全局变量的差距
想知道一行代码在解释器里长什么样,dis.dis(func)是最直接的工具。它会打印出字节码,你能看到LOAD_FAST、LOAD_GLOBAL、LOAD_ATTR这些指令。区别在于:局部变量存在一个固定的数组里,按下标取值;全局变量要走模块字典查找;属性访问还要经过描述符协议的完整流程。
import dis def slow(items): result = [] for i in items: result.append(i * len(items)) return result def fast(items): result = [] append = result.append n = len(items) for i in items: append(i * n) return resultfast里把result.append和len(items)提到循环外绑定成局部变量,字节码里就少掉了每次迭代的全局查找和属性查找。要注意的是,现代 CPython 对全局查找加了内联缓存,差距比十年前小了很多;而且这种改写会牺牲一点可读性。我的原则是:只在 profiler 明确指出的热点循环里做,其他地方保持原样,不要为了省几个纳秒把代码写成天书。
6.2 lru_cache 的四个失效场景
functools.lru_cache几乎是所有人都用过的缓存工具,但它有几个场景用了还不如不用:
| 场景 | 表现 | 处理方式 |
|---|---|---|
| 参数含不可哈希对象 | 直接抛TypeError | 先转换成元组或字符串键 |
| 装饰实例方法 | self进入缓存键,实例无法被回收 | 改用cached_property或模块级函数 |
使用无界cache | 键的组合爆炸,内存持续上涨 | 显式设置maxsize |
| 返回可变对象 | 调用方修改后,后续调用拿到被污染的对象 | 返回副本或改用不可变结构 |
其中第二个是最隐蔽的。缓存字典的键里包含了self,等于给每个实例加了一条强引用,实例永远不会被垃圾回收。如果这个类会频繁创建销毁(比如每个请求一个实例),内存会稳定地往上爬,而且用gc查起来也很绕,因为对象确实是"可达"的。
第三个也值得展开。@cache(3.9+)等价于maxsize=None,也就是无界缓存,写起来爽,但参数组合一旦多起来就是内存黑洞。我一般会在压测阶段观察cache_info()的currsize、hits、misses——如果hits占比很低而currsize一直涨,说明缓存根本没起到作用,反而在吃内存。顺便说,lru_cache从 3.8 起可以不带括号直接当装饰器用,少打一对空括号,读起来清爽一点。
6.3 singledispatch 替掉一长串 isinstance
不少老代码里有这种结构:
def render(obj): if isinstance(obj, str): return render_text(obj) elif isinstance(obj, dict): return render_dict(obj) elif isinstance(obj, list): return render_list(obj) ...这种写法有三个毛病:加一种类型就得改这个函数(违反开闭原则)、isinstance是有顺序的、子类可能被前面的分支截胡。functools.singledispatch就是为了解决这个:按第一个参数的实际类型分派到注册的实现,查找时沿 MRO 找最近的匹配。
from functools import singledispatch @singledispatch def render(obj): raise TypeError(f"不支持的类型: {type(obj)}") @render.register def _(obj: str): return render_text(obj) @render.register def _(obj: dict): return render_dict(obj)几个实操注意点:singledispatch只看第一个参数的类型,多个参数的话没辙;用注解方式注册需要确保注解是实际类型而不是字符串;每次新增实现都会清空内部分派缓存,所以不要在热路径里动态注册。类方法版本用singledispatchmethod(3.8+),但它和classmethod、staticmethod叠加时顺序有讲究,装饰器必须写在最内层,写反了会报错。
6.4 Protocol:让类型检查器看懂鸭子类型
最后聊一个不直接影响运行速度、但显著影响协作效率的东西。Python 是鸭子类型,只要方法齐全就能用,可静态检查器不认识"长成这样的对象",于是你要么写一堆Any,要么硬造继承关系。typing.Protocol提供了结构化子类型:只描述需要哪些方法,任何具备这些方法的类自动符合,不需要显式继承。
from typing import Protocol, runtime_checkable @runtime_checkable class Readable(Protocol): def read(self, n: int = -1) -> bytes: ...和abc.ABC相比,Protocol是非侵入式的,不会强迫第三方库改自己的继承结构;代价是它只做静态检查,isinstance需要加@runtime_checkable而且只校验方法名是否存在,不校验签名,参数个数对不上也照样通过。所以在需要严格运行时校验的场景,抽象基类仍然更可靠。我通常的做法是:跨模块的接口契约用Protocol描述,框架内部需要强制实现的用abc。
写到这里,其实这六个方向的共同点已经很清楚了:Python 的很多"魔法"都不是黑盒,它们背后是几条明确的协议和查找规则。把这些规则摸清之后,你看到报错信息就能猜到是哪一层出了问题——比如属性赋值没生效,先想数据描述符还是非数据描述符;异步任务莫名卡死,先想是不是在协程里塞了阻塞调用。这种"能定位"的感觉,比背多少技巧都管用。