ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python高阶特性实战:元类、描述符、__slots__、弱引用与协程

Python高阶特性实战:元类、描述符、__slots__、弱引用与协程

上一篇里挖的坑,这次全部填上

如果你一路跟着这个系列读到第八部分,我默认你已经会写装饰器,会构造生成器,也用得转with上下文管理器了。这些通常是大家理解里的"高级特性",但说实话,它们只是Python进阶的第一层台阶。真正让你觉得"这语言还能这么玩"的,是今天要聊的这五样东西:元类、描述符、slots、弱引用、协程。

很多自学Python的人会经历三个阶段:第一个阶段刚学会写for循环和if判断,觉得"编程也不过如此";第二个阶段听到装饰器、生成器立刻兴奋,恨不得在每段代码里都用上,代码风格花里胡哨;第三个阶段开始琢磨这些语法背后的机制——为什么装饰器能"偷换"函数?为什么生成器能暂停?为什么类能像函数一样被调用?如果你正处于第二阶段向第三阶段过渡的时期,今天这篇文章就是为你写的。

我不会像官方文档那样把每个知识点都铺开讲一遍,而是挑最容易被忽略、又最能提升功力的细节:先给你看机制,再给场景,最后给出可以直接拿去用的代码。文章最后还有一个综合案例,把这些特性拧在一起,做一个迷你ORM框架——你看完就知道这些"黑魔法"在生产环境里是怎么落地的。

1. 元类:为什么说它是"让人恐惧的魔法"

1.1 先搞懂type():类也是对象

大多数人第一次听到"元类"这个词的时候,反应都是:类不就是用来创建对象的模板吗?怎么还能有"创建类的类"?这听起来像俄罗斯套娃,一层套一层,直接把人绕晕。

要解开这个绕,你得先接受一个反直觉的事实:在Python里,类本身也是一个对象。你在class关键字的代码块里定义的属性、方法,本质上只是往一个叫"类对象"的东西里塞数据。这个类对象可以被赋值给变量,可以塞进列表,甚至可以动态地创建出来。

最常见的动态创建方式就是type()。新手只见过type()用来查类型:

print(type(42)) # <class 'int'> print(type("hello")) # <class 'str'>

但type()其实还可以直接造出一个新类。它的调用方式是type(类名, 基类元组, 属性字典):

Person = type("Person", (), {"speak": lambda self: "Hello!"}) p = Person() print(p.speak()) # Hello!

这里type()创建了一个名为Person的类,它没有继承任何基类,带一个speak方法。你会说,这跟class写出来的类一模一样吗?没错,本质就是一模一样的。class关键字只是一个语法糖,底层调用的还是type()创建类对象的那套流程。

所以当我们问"什么是元类"的时候,答案就变得很简单了:元类就是用来创建类的类。默认情况下,所有类的元类都是type。你写一个class Foo,Python在解释器层面会调用type这个元类来创建Foo这个类对象。

1.2 元类到底在哪个环节介入:new__与__init

当你自定义一个元类时,最常见的是继承type,然后重写它的__new__或__init__方法。你可能会问,__new__是创建实例时用的,跟创建类有什么关系?

先回忆一下普通类创建实例的流程。当你执行Foo()时,先是Foo.__new__被调用,它负责分配内存、创建出这个实例;然后Foo.__init__被调用,负责初始化实例的属性。两步缺一不可。

现在我们把这个流程提升一级:当你定义一个类的时候,Python会用元类去"创建这个类对象"。元类的__new__负责创建类的骨架,元类的__init__负责填充类的属性。所以这两个方法在元类里也是存在的,只是它们处理的对象从"实例"变成了"类本身"。

看一个实际例子,感受一下调用时机:

class Meta(type): def __new__(mcs, name, bases, namespace): print(f"Meta.__new__ 正在创建类: {name}") return super().__new__(mcs, name, bases, namespace) def __init__(cls, name, bases, namespace): print(f"Meta.__init__ 正在初始化类: {name}") super().__init__(name, bases, namespace) class Demo(metaclass=Meta): pass

运行时你会看到,先是Meta.__new__打印出创建信息,然后Meta.__init__打印出初始化信息。__new__比__init__早执行,这是Python里固定的顺序。

很多人会在这里犯一个错误:在元类的__new__里告诉自己要return super().new(...),结果忘了传namespace,或者传成关键字参数导致报错。我的建议是,绝大多数场景下你应该重写__new__,因为如果你想在类创建阶段"篡改"属性字典,在__new__里修改namespace是最干净的方式。如果你只是想在类创建后做点什么,比如打个日志、注册一下,重写__init__就够了,还不用操心返回值的问题。

1.3 一个正经落地场景:模块级的类注册表

元类最常见的生产级用法,就是做"类注册表"。你写了一套插件体系,希望任何子类在被定义时,就自动登记到一个统一的dict里,这样主程序不用显式导入每个插件类。

我见过不少人的第一版实现是在基类的__init_subclass__里做登记,这个方法Python 3.6之后确实也能用,但对于更老的代码库,元类几乎是唯一选择,而且元类能做的更彻底——它能在类还没完全创建好的时候就介入。

下面是一个插件注册表的完整实现:

class PluginRegistry(type): plugins = {} def __new__(mcs, name, bases, namespace): cls = super().__new__(mcs, name, bases, namespace) if name != "BasePlugin": mcs.plugins[name] = cls return cls class BasePlugin(metaclass=PluginRegistry): pass class PluginA(BasePlugin): pass class PluginB(BasePlugin): pass print(PluginRegistry.plugins) # {'PluginA': <class '__main__.PluginA'>, 'PluginB': <class '__main__.PluginB'>}

注意我在注册时排除了BasePlugin本身,否则基类也会被登记进去,这通常是你不想要的行为。除此之外,这个模式还有个很实用的变体:在元类里给所有类自动添加一个统一的方法或属性,比如给所有子类打上版本号。

class VersionedMeta(type): def __new__(mcs, name, bases, namespace): namespace.setdefault("version", "1.0") return super().__new__(mcs, name, bases, namespace) class ServiceA(metaclass=VersionedMeta): pass print(ServiceA.version) # 1.0

这种"注入类属性"的能力,在框架开发里非常常见。你不需要每个子类都写一行version = "1.0",元类帮你自动完成。

2. 描述符协议:@property的高级真相

2.1 三个魔法方法如何接管属性访问

如果你用过@property,你应该知道它能让你把一个方法伪装成属性,访问时自动执行里面的逻辑:

class Circle: def __init__(self, radius): self._radius = radius @property def area(self): return 3.14159 * self._radius ** 2

但是这个语法背后发生了什么?很多人没想过。答案是描述符协议。一个类如果实现了__get__、set、__delete__中的任意一个方法,它就是描述符。当一个类的属性是一个描述符对象时,访问这个属性时,Python不会直接返回这个对象本身,而是会触发相应的魔术方法。

class PositiveNumber: def __get__(self, instance, owner): if instance is None: return self return instance.__dict__[self.name] def __set__(self, instance, value): if value <= 0: raise ValueError("值必须大于0") instance.__dict__[self.name] = value def __set_name__(self, owner, name): self.name = "_positive_" + name class Order: price = PositiveNumber() order = Order() order.price = 100 # 走 __set__,校验通过 print(order.price) # 走 __get__,返回100 order.price = -5 # ValueError: 值必须大于0

这里的关键点在于__set_name__。它会在类创建完成后自动被调用,告诉描述符"你在类里叫什么名字"。这个方法Python 3.6才加入,但非常实用,省得你在类的__init__里手动去设置self.name。

2.2 数据描述符与非数据描述符:查找顺序的恩怨

描述符分两类:实现了__set__或__delete__的叫数据描述符,只实现了__get__的叫非数据描述符。这个区分直接影响属性查找的顺序。

在Python里,访问一个实例属性的查找顺序是:先是数据描述符,然后是实例的__dict__,最后才是非数据描述符。也就是说:

  • 数据描述符>实例字典>非数据描述符
  • 实例字典中如果有同名属性,可以覆盖非数据描述符,但覆盖不了数据描述符

这个区别在实际中有什么影响?最典型的是函数和方法的区别。函数对象只实现了__get__,没有__set__,所以函数是非数据描述符。这意味着你可以在实例上给一个同名属性赋值,把方法"盖掉":

class Foo: def bar(self): return "method" foo = Foo() foo.bar = "not a method" print(foo.bar) # not a method ← 实例属性覆盖了方法

但如果把方法改成数据描述符,这种覆盖就不会发生。property恰好就是数据描述符,所以你不能在实例上覆盖一个property属性。

我之前排查过一个诡异bug,起因就是同事在某个类里定义了一个方法,然后在另一个人写的mixin里,有个同名的property把方法给"劫持"了。方法走的是非数据描述符,property是数据描述符,查找顺序上property优先,结果那个方法永远调不到。排查了整整一下午,最后发现是查找顺序的问题。所以遇到诡异属性行为,先想是不是描述符在作怪。

2.3 一个类型校验的小框架

描述符最能体现价值的地方,是构建一个可复用的字段校验体系。假设你写了一个数据类,要求age属性必须是int且大于等于0,name属性必须是str,如果你一个个写property,类会变得非常臃肿。

用描述符做模板:

class Field: def __init__(self, expected_type): self.expected_type = expected_type def __get__(self, instance, owner): if instance is None: return self return instance.__dict__[self.name] def __set__(self, instance, value): if not isinstance(value, self.expected_type): raise TypeError(f"{self.name} 需要 {self.expected_type.__name__},实际传入 {type(value).__name__}") instance.__dict__[self.name] = value def __set_name__(self, owner, name): self.name = name class Person: name = Field(str) age = Field(int) p = Person() p.name = "张三" p.age = 28 # p.name = 123 # 会抛 TypeError

有了这个Field,你可以写出Series(序列)、Vector(向量)等各种字段,然后随意组合到不同的类里。这就是描述符的核心价值:把重复的校验逻辑抽出来,变成可插拔的组件。

3.slots:内存焦虑者的福音与陷阱

3.1 省下的内存到底从哪来

Python类在默认情况下,每个实例都有一个__dict__,也就是实例属性字典。字典是为了灵活性而设计的,但灵活性是有代价的——每个实例都要维护一个哈希表,这个哈希表占的内存远远大于你存的那些值本身。

当你定义__slots__时,Python会为实例分配一块固定的内存空间来存放这些属性,用类似C语言结构体的方式存储,不再为每个实例创建__dict__。省下的内存有多可观?我实际测试过,一个只有两个属性的类,几十万个实例跑下来,用__slots__能省出30%到40%的内存。

class WithDict: def __init__(self): self.x = 1 self.y = 2 class WithSlots: __slots__ = ("x", "y") def __init__(self): self.x = 1 self.y = 2

用tracemalloc跟踪一下内存分配,你会发现WithSlots的实例明显更轻。

3.2 加了这个,动态属性就废了

__slots__的第一个陷阱大家应该都听过:加了__slots__之后,不能动态添加不在列表里的属性。

obj = WithSlots() obj.z = 3 # AttributeError: 'WithSlots' object has no attribute 'z'

很多新手一上来就踩这个坑,然后果断放弃__slots__。但其实这是特性,不是bug。如果你需要动态属性,可以把"dict"加到__slots__里,这样实例同时拥有固定槽位和动态字典,只是内存优化效果会打折扣。

3.3 继承与描述符一起使用时的坑

__slots__有两点非常容易踩坑:

第一点,子类如果不定义__slots__,父类的__slots__优化就失效了,因为子类实例仍然会创建__dict__。所以你要么在整条继承链上都定义__slots__,要么放弃这个优化。

第二点,slots__与描述符并存时需要格外小心。如果你的类里有一个描述符属性,同时又把它写进__slots,就会出问题。描述符的__set_name__会设置属性名,但__slots__也会尝试接管这块内存,两边会打架。我的建议是:描述符属性不要放进__slots__,让描述符自己存到instance.__dict__里,__slots__只放普通字段。还有一种设计是描述符不存到实例字典,而是像正常属性一样被槽位管理,但那个配置方式非常绕,普通场景不值得折腾。

4. 弱引用:管好对象的最后一道防线

4.1 为什么明明del了对象还没死

先抛一个问题:你写了一个缓存,把计算结果放在全局字典里,key是对象,value是计算结果。程序跑久了之后,内存越来越肥。你明明在业务代码里del了那些对象,但垃圾回收器就是不肯把内存还回来。原因很简单:那个全局字典还在强引用着这些对象,引用计数永远不为零,它们永远活得好好的。

这几乎是所有缓存设计者都会撞上的墙。解决办法就是弱引用。

4.2 weakref.ref与WeakValueDictionary

弱引用的核心思想是:引用一个对象,但不增加它的引用计数。如果对象只剩弱引用在指向它,垃圾回收器就可以放心地把它收走。被回收之后,弱引用对象会返回None。

import weakref class Data: pass d = Data() ref = weakref.ref(d) print(ref()) # 指向 Data 对象 del d print(ref()) # None,对象已被回收

但最实用的还是WeakValueDictionary(弱值字典)。它的value是弱引用,key保持强引用。当value对象只剩下WeakValueDictionary里的弱引用时,这个条目会被自动清除。

import weakref class Data: pass cache = weakref.WeakValueDictionary() d1 = Data() cache["key1"] = d1 print(len(cache)) # 1 del d1 print(len(cache)) # 0,自动清除了

我有一个自己做过的数据去重模块,里面就用了WeakValueDictionary来保证同一个数据源只创建一份处理器对象。处理器对象被外界引用时,大家共享同一份;没人引用时,它自动从全局表里消失,不会造成内存堆积。

4.3 用弱引用做缓存

弱引用做缓存有一个经典例子:缓存"由函数计算出的昂贵结果"。但要注意,WeakValueDictionary只适合缓存那些"在其他地方也有人引用"的对象。如果你的计算结果生成之后,没有任何强引用在别处,缓存的数据放进去就会被立刻回收掉,缓存形同虚设。

所以弱引用缓存的正确打开方式是:计算结果被业务代码持有,缓存只是做一个"复用去重"的作用。我在一个模拟项目X里用它缓存配置文件解析后的数据对象:同一个路径的配置被多处读取时,直接返回同一个对象;所有读取方都释放后,缓存自动清理,内存不会失控。

另一个变体是WeakSet(弱引用集合)。如果你需要维护"当前存活的所有连接"这种集合,用WeakSet就对了。连接对象释放后,集合自动把它们移出去,省去了手动remove的麻烦。

5. 从生成器到async/await:协程进化史

5.1 生成器怎么用send变成协程

生成器的标准用法是for迭代,但其实它还有一个几乎被遗忘的方法send()。当你用yield作为表达式的时候,生成器就变成了一个可以接收数据的"双向通道"。

def coroutine(): name = yield "ready" print(f"收到名字: {name}") age = yield "ready for age" print(f"收到年龄: {age}") c = coroutine() print(next(c)) # ready,执行到第一个yield print(c.send("张三")) # 收到名字: 张三,然后输出 ready for age

这段代码里,next(c)让生成器运行到第一个yield后暂停,send("张三")则把"张三"塞回生成器,赋值给name。这就实现了生成器与调用方之间的双向通信。本质上,协程就是一个可以被外部"喂数据"的生成器。

这个能力对于实现一个简单的状态机或流水线非常有用。比如你可以写一个数据清洗的管道:每个节点是一个协程,上游把数据send给下游,下游处理完再send给更下游,整个流转过程像流水线一样清晰。

5.2 yield from如何委派子生成器

当你需要在生成器里调用另一个生成器,最笨的办法是for循环遍历:

def sub(): yield 1 yield 2 def main(): for item in sub(): yield item

有了yield from之后,可以直接写:

def main(): yield from sub()

yield from不只是简写,它还能完成"委托"的完整语义:子生成器的return值会被捕获,子生成器和调用方之间的send通道会被打通,异常也能正确传递。我当年第一次看到yield from的时候,只觉得这是个语法糖,后来才发现它是在为协程铺路。

5.3 async/await只是语法糖吗

async/await出现之后,很多人以为它是另一个东西,其实它就是在yield from的基础上加了一层包装。async def定义的函数是一个原生协程,await关键字用来挂起当前协程,等待另一个协程完成。这与yield from委派子生成器的本质是一样的,只是语义上更明确。

但这里有个重要区别:async/await本身不提供并发能力。并发需要事件循环来调度。事件循环在背后做的,就是不停地从任务队列里取出协程,让它们运行到下一个await挂起点,然后切换到另一个协程。

我见过很多新手,以为只要用async def写函数就能并行,结果发现耗时并没有减少。这是因为他们全程只在一个线程里转悠,所有IO操作依然是阻塞的。真正的并发提升,来源于await一个真正异步的IO操作,比如用aiohttp发网络请求,让等待时间让位给别的任务。

所以我的建议是:如果只是为了写一个简单爬虫,几十个请求并发抓取,直接用标准库的asyncio就够了;但如果你要在同一进程里做CPU密集型的计算并发,协程帮不了你,应该考虑进程池或线程池。

6. 综合案例:迷你ORM里的老古董和新伙计

前面讲了一堆零散的高阶特性,现在把它们拧到一起,做一个迷你ORM框架。我管它叫"微型ORM",你可以在任何一个中型项目里当模板用。这个例子完美融合了元类、描述符和弱引用。

6.1 需求拆解:ORM到底做了什么

ORM(对象关系映射)的核心就是:让你用Python类去操作数据库表。类对应表,属性对应字段,实例对应一行记录。真正实现的ORM框架要做很多事,但核心机制无非两件事:

  • 把一个Python类转换成一张表的定义,收集它的字段
  • 把一个Python实例序列化成一条SQL语句的字段值

第一件事适合交给元类,第二件事适合交给描述符。

6.2 字段描述符:类型校验和SQL片段生成

先定义一个Field描述符,它要干两件事:一是校验类型,二是生成SQL片段。

class Field: def __init__(self, column_type="TEXT"): self.column_type = column_type def __get__(self, instance, owner): if instance is None: return self return instance.__dict__.get(self.name) def __set__(self, instance, value): instance.__dict__[self.name] = value def __set_name__(self, owner, name): self.name = name

这段代码里我没有做严格类型校验,只把属性值存到实例字典里。原因很简单:框架级别的字段校验交给使用者扩展,核心机制先跑通。

6.3 元类注册表:把字段收集到类级别

接下来需要元类在创建表类时,把类里所有Field描述符收集起来,生成建表语句。

class ModelMeta(type): def __new__(mcs, name, bases, namespace): fields = {} for attr_name, attr_value in namespace.items(): if isinstance(attr_value, Field): fields[attr_name] = attr_value cls = super().__new__(mcs, name, bases, namespace) cls.fields = fields cls.table_name = name.lower() return cls

这里有个关键点:为什么不用namespace.items()直接生成SQL?因为直接生成SQL字符串会让框架失去灵活性,用户可能想自定义表名、主键。所以我只收集字段,把建表动作留给用户调用时才执行。

6.4 跑起来看看效果

class Model(metaclass=ModelMeta): def save(self): field_list = [] value_list = [] for fname, field in self.fields.items(): field_list.append(fname) value_list.append(getattr(self, fname)) sql = f"INSERT INTO {self.table_name} ({', '.join(field_list)}) VALUES ({', '.join(['?'] * len(value_list))})" print("生成的SQL:", sql) return tuple(value_list) class User(Model): name = Field("TEXT") age = Field("INTEGER") u = User() u.name = "张三" u.age = 28 values = u.save() print(values) # ('张三', 28)

输出:

生成的SQL: INSERT INTO user (name, age) VALUES (?, ?) ('张三', 28)

一个可用的迷你ORM骨架就成型了。每次执行save(),都会收集当前类的所有字段,拼出INSERT语句。因为fields存放在类级别,所有实例共享,省了重复的劳动。

6.5 我踩过的坑

第一,元类里收集字段时,一定要处理好继承。如果子类不重新定义字段,它就会继承父类的fields。如果你不想这样,需要在元类里合并父类字段。第二,描述符的名字冲突问题:实例里如果把某个字段名覆盖成普通属性,getattr就会拿到普通属性而不是走描述符,这类bug极难排查。第三,字段顺序问题:如果你依赖建表时字段的顺序,那么不要假设字典的枚举顺序在Python 3.7之前是可靠的,3.7之后dict按插入顺序返回,才放心。

7. 实战笔记:哪些高级特性天天用,哪些只是面试题

最后说说我的真实体会。学完这些高级特性之后,最大的变化不是你写的代码有多炫,而是你排查bug的速度变快了。

**_描述符和元类,在生产环境里用得最多的是框架作者,普通业务开发可能一年都碰不上一次,但你理解了它们,就知道装饰器和@property的底层是什么,遇到属性被"劫持"的情况能快速定位。slots,**如果你在处理大规模数据,比如几百万条日志记录、上百万个数据点,它几乎是必选项。**弱引用,**做缓存、做对象池、做监听器列表的时候非常有用,能让你少写一堆手动清理的代码。**async/await,**别为了赶时髦而用,如果你的任务是IO密集型的,该用就用;如果只是CPU计算,老老实实写成普通函数,别自找麻烦。

我给初学者的建议是:先别试图一次全掌握。把描述符和__slots__放在一起学,因为它们都涉及"类属性怎么被访问";把元类和装饰器放一起学,因为它们都涉及"函数和类的创建过程"。等这些打通了,再碰async/await,你的理解深度会是跳跃式的。

这篇文章里写的每一个案例,我都用最基本的Python语法跑过。你会发现,真正复杂的不是语法本身,而是理解"是谁在什么时机调用了谁"。搞明白了这一点,Python高级特性的大门就算彻底踹开了——剩下的,不过是多写多用的体力活。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进