描述符与元类
上一章我们见识了一批魔术方法,其中 getattr 一家负责在实例层面拦截属性访问。这一章往更深处走两层:
上一章我们见识了一批魔术方法,其中 __getattr__ 一家负责在实例层面拦截属性访问。这一章往更深处走两层:
- 描述符:在类属性层面拦截读写删,
@property、@classmethod、@staticmethod这些常用工具,底层全是它; - 元类:类的模板。实例由类造,那类由谁造?答案就是元类。
先讲描述符(实用频率高),再讲元类(更抽象、更底层),两条线最后会汇合——你会发现它们本质上是同一件事在不同高度的投影:把"访问属性"和"创建类"这两个动作本身变成可编程的。
上篇:描述符
1. 从一个"数据不同步"的 bug 说起
先看一段埋着雷的代码:
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
self.area = width * height # 面积:算好存起来
self.perimeter = (width + height) * 2 # 周长:算好存起来
r = Rectangle(3, 4)
print(r.width, r.area, r.perimeter) # 3 12 14,一切正常
# 雷 1:不合理的值拦不住
r.width = -10 # 宽度为负?没人管
# 雷 2:数据悄悄"过期"
r.width = 10
print(r.width, r.area, r.perimeter) # 10 12 14 —— area 还是旧值!
问题拆开看是两个:
- 给
width赋值时,我们想要一次"安检"(校验合法性),但普通属性赋值无从插手; area、perimeter是推导出来的值,存成普通属性的那一刻就"冻结"了,源头一变它们就成了过期数据。
理想方案:width 写入前校验;area 读取时现场计算。要做到"拦截读写",就需要描述符出场了。
2. 描述符协议:一份三方契约
描述符的规则非常简单:一个类只要实现了 __get__、__set__、__delete__ 中的任意一个,它就是描述符类;它的实例就是描述符对象(下文简称描述符)。描述符必须作为类属性放置才有意义,因此也常被叫作属性描述符。
按实现方法的不同,描述符再分两档:
- 实现了
__set__或__delete__(即"能管写入")→ 数据型描述符(Data Descriptor); - 只实现了
__get__("只能读")→ 非数据型描述符(Non-Data Descriptor)。
# 一个描述符类:读、写、删三个钩子全备
class Inspector:
def __get__(self, instance, owner):
print("__get__ 被触发")
return None
def __set__(self, instance, value):
print("__set__ 被触发")
def __delete__(self, instance):
print("__delete__ 被触发")
class Holder:
probe = Inspector() # 描述符挂在类属性的位置上
3. 属性查找的优先级顺序
当你写下 ins.attr 时,Python 内部其实按一套固定的"资历排序"找值:
- 数据型描述符(类身上的)
- 实例自己的属性(
instance.__dict__) - 类身上的普通属性
- 一路向上找父类……
数据型描述符排在实例字典前面,这一点非常关键——它意味着:只要类属性位置上放的是数据型描述符,实例字典就"抢不走"这个名字:
class Inspector:
def __get__(self, instance, owner):
return "经过 __get__ 的值"
def __set__(self, instance, value):
pass # 有 __set__,所以是数据型描述符
class Holder:
probe = Inspector()
def __init__(self, value):
self.probe = value # 这句赋值被描述符接管,进不了实例字典
ins = Holder(10)
print(ins.__dict__) # {} —— 里面根本没有 probe
print(ins.probe) # 经过 __get__ 的值 —— 拿到的是描述符给的
4. 读、写、删,分别怎么触发
描述符的三个钩子各司其职,还要分清"从实例出发"和"从类出发"两条路径:
class Inspector:
def __get__(self, instance, owner):
print(f"__get__:instance={instance}, owner={owner}")
def __set__(self, instance, value):
print(f"__set__:instance={instance}, value={value}")
def __delete__(self, instance):
print(f"__delete__:instance={instance}")
class Holder:
slot_a = Inspector()
slot_b = Inspector()
ins = Holder()
# ---- 从实例出发:读写删都会被描述符接住 ----
ins.slot_a # Inspector.__get__(Holder.slot_a, ins, Holder)
ins.slot_a = 10 # Inspector.__set__(Holder.slot_a, ins, 10)
del ins.slot_a # Inspector.__delete__(Holder.slot_a, ins)
# ---- 从类出发:要小心! ----
Holder.slot_b # __get__ 仍会触发,但 instance 参数是 None
Holder.slot_b = 20 # 危险!这不是调用 __set__,而是把描述符整个覆盖掉了!
del Holder.slot_b # 直接删掉类属性,slot_b 从此不存在
给类属性直接赋值,等于拿一个普通值顶替了描述符的位置——描述符就此失效。由此得出两条最佳实践:
- 绝大多数场景都用数据型描述符(读写都在掌控中);
- 对描述符的访问,永远走实例这条路径。
5. __set_name__:让描述符知道自己"姓什么"
Python 3.6 起描述符多了一个专属钩子 __set_name__:描述符被赋值到类属性上的那一刻(赋值完成后),它会自动收到"你被挂在了哪个类的哪个属性名下"这条通知:
class Inspector:
def __set_name__(self, owner, name):
# owner:挂载它的类;name:它占用的属性名
print(f"我被挂到了 {owner.__name__} 类的 '{name}' 上")
def __get__(self, instance, owner):
return None
def __set__(self, instance, value):
pass
class Holder:
# 下面两行各触发一次 __set_name__
slot_a = Inspector() # 我被挂到了 Holder 类的 'slot_a' 上
slot_b = Inspector() # 我被挂到了 Holder 类的 'slot_b' 上
这个钩子看似不起眼,却是"一个描述符类服务多个属性"的关键——第 9 节的类型检查示例会用到它。
6. 修复开篇的 bug
装备齐全,回头收拾 Rectangle:宽、高是数据型描述符(写入时校验),面积、周长是只读描述符(读取时现算):
class WidthField:
"""宽度:可写,但必须为正数"""
def __set__(self, instance, value):
if value <= 0:
raise ValueError("宽度必须是正数")
instance._width = value
def __get__(self, instance, owner):
return instance._width
def __delete__(self, instance):
raise AttributeError("宽度不允许删除")
class HeightField:
"""高度:可写,但必须为正数"""
def __set__(self, instance, value):
if value <= 0:
raise ValueError("高度必须是正数")
instance._height = value
def __get__(self, instance, owner):
return instance._height
def __delete__(self, instance):
raise AttributeError("高度不允许删除")
class AreaField:
"""面积:只读,永远由当前宽高现算"""
def __get__(self, instance, owner):
return instance.width * instance.height
def __set__(self, instance, value):
raise AttributeError("面积是只读的,不能设置")
def __delete__(self, instance):
raise AttributeError("面积不允许删除")
class PerimeterField:
"""周长:只读,永远由当前宽高现算"""
def __get__(self, instance, owner):
return (instance.width + instance.height) * 2
def __set__(self, instance, value):
raise AttributeError("周长是只读的,不能设置")
def __delete__(self, instance):
raise AttributeError("周长不允许删除")
class Rectangle:
width = WidthField()
height = HeightField()
area = AreaField()
perimeter = PerimeterField()
def __init__(self, width, height):
self.width = width
self.height = height
r = Rectangle(3, 4)
print(r.width) # 3
print(r.area) # 12
print(r.perimeter) # 14
r.width = 10 # 合法修改,畅通无阻
print(r.width) # 10
print(r.area) # 40 —— 自动跟着变了,再也不会过期
print(r.perimeter) # 28
# r.area = 100 # AttributeError! 面积不许直写
# r.width = -10 # ValueError! 宽度必须是正数
开篇的两颗雷,就这么被排掉了。
7. @property:描述符的官方快捷方式
描述符功能强大,但为一个属性写一个类确实隆重了些。于是 Python 提供了 @property——它本质上就是一个内置的数据型描述符,把"读时计算、写时校验"压缩成装饰器写法:
class Rectangle:
def __init__(self, width, height):
self._width = width
self._height = height
@property
def width(self):
"""读取宽度"""
return self._width
@width.setter
def width(self, value):
"""写入宽度,顺带校验"""
if value <= 0:
raise ValueError("宽度必须是正数")
self._width = value
@width.deleter
def width(self):
"""删除宽度"""
print("正在删除宽度")
del self._width
@property
def area(self):
"""面积:只读属性,现算"""
return self._width * self._height
@property
def perimeter(self):
"""周长:只读属性,现算"""
return (self._width + self._height) * 2
r = Rectangle(3, 4)
print(r.width) # 3 —— 触发 getter
print(r.area) # 12
print(r.perimeter) # 14
r.width = 10 # 触发 setter
print(r.area) # 40 —— 始终同步
# r.area = 100 # AttributeError! area 没配 setter,天然只读
# r.width = -5 # ValueError! 校验挡住了脏数据
# del r.width # 触发 deleter
三个部件记清楚:
@property把方法包装成只读属性(访问不加括号);@属性名.setter补上写入能力(方法名必须与属性同名);@属性名.deleter补上删除能力。
8. 亲手造一个 property
@property 不神秘,用描述符就能复刻一个(完整可运行版本见本章 demo.py)。骨架长这样:
class MyProp:
"""迷你版 property:装饰器进、描述符出"""
def __init__(self, read_fn=None, write_fn=None, remove_fn=None):
# 存钩子的字段名刻意避开方法名(setter/deleter),
# 否则实例属性会遮蔽同名方法,@xxx.setter 就失效了
self.read_fn = read_fn
self.write_fn = write_fn
self.remove_fn = remove_fn
def __get__(self, instance, owner):
if self.read_fn is None:
raise AttributeError("属性不可读")
return self.read_fn(instance)
def __set__(self, instance, value):
if self.write_fn is None:
raise AttributeError("属性不可写")
self.write_fn(instance, value)
def __delete__(self, instance):
if self.remove_fn is None:
raise AttributeError("属性不可删除")
self.remove_fn(instance)
def setter(self, func):
self.write_fn = func
return self
def deleter(self, func):
self.remove_fn = func
return self
这段代码还顺带说明了一个有趣的事实:@property 这类"装饰器"其实是个可调用对象——装饰器接收可调用对象、可以返回任何东西,这里返回的正是描述符实例。装饰器与可调用对象的关系,下一章还会细说。
9. 描述符的两大经典应用
9.1 惰性计算:算一次,用一辈子
有些属性算起来很费劲(读大文件、跑重模型),但算过一次后基本不变——适合"第一次访问才算,之后直接吃缓存":
class LazyAttr:
"""惰性属性:首次访问才执行计算,结果存进实例字典备用"""
def __init__(self, func):
self.func = func
self.name = func.__name__
def __get__(self, instance, owner):
if instance is None:
return self # 从类身上访问时,返回描述符自身
value = self.func(instance)
# 把结果写进实例字典——注意 LazyAttr 没实现 __set__,
# 是非数据型描述符,斗不过实例字典,下次访问直接命中缓存
setattr(instance, self.name, value)
return value
class UserProfile:
def __init__(self, user_id):
self.user_id = user_id
@LazyAttr
def history(self):
print(f"正在拉取用户 {self.user_id} 的历史记录……(很慢)")
return ["登录", "下单", "评价", "退款"]
profile = UserProfile("u_1001")
print(profile.history) # 第一次:打印"正在拉取……",返回完整记录
print(profile.history) # 第二次:直接吐缓存,不再打印
注意这里的门道:LazyAttr 只实现 __get__,是非数据型描述符,查找优先级排在实例字典后面——所以第一次算完写进实例字典后,后续访问会直接命中字典,描述符连出场机会都没有。优先级规则在这里被用成了特性。
9.2 类型检查:一个描述符守住一个字段
配合 __set_name__,一个描述符类可以同时服务多个属性:
class TypeGuard:
"""强制类型检查的描述符"""
def __init__(self, expected):
self.expected = expected
self.name = None
def __set_name__(self, owner, name):
# 记下自己看守的字段名,报错信息才能说清楚
self.name = name
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):
raise TypeError(
f"{self.name} 需要的是 {self.expected.__name__} 类型,"
f"而不是 {type(value).__name__}"
)
instance.__dict__[self.name] = value
class Movie:
title = TypeGuard(str)
year = TypeGuard(int)
rating = TypeGuard(float)
def __init__(self, title, year, rating):
self.title = title
self.year = year
self.rating = rating
film = Movie("星际漫游", 2024, 8.5)
# film.year = "2024" # TypeError! year 需要的是 int 类型,而不是 str
下篇:元类
10. 换个视角:类自己也是对象
我们早已习惯"类造实例"。可类本身也是个对象啊——那类是谁造出来的?
先做个小实验。下面四种写法完全等价:
class Bird:
pass
# 写法二:显式声明用 type 这个元类来造
class Bird(metaclass=type):
pass
# 写法三:直接调用 type,三个参数分别是:类名、父类元组、属性字典
Bird = type("Bird", (), {})
# 写法四:把 type 当对象调用,背后其实是 type 的 __call__
Bird = type.__call__(type, "Bird", (), {})
结论很震撼:class 关键字只是语法糖,你写的每个类,底层都是 type 这个"类的工厂"生产出来的。这个"工厂"就有个名字——元类(metaclass)。元类就是"造类的类"。
顺着上一章的记忆捋一遍就通了:调用 Bird() 造实例,走的是 Bird.__call__;那调用 type(...) 造类,走的自然是 type.__call__。一切都是同一套魔术方法逻辑,只是抬高了一层。
11. 自定义元类:接管"造类"这件事
既然类由元类生产,那我们继承 type 写个子类,就能在生产线上插手——这就是自定义元类:
class WatchMeta(type):
def __new__(mcs, name, bases, namespace):
# 类图纸送到生产线,我们先过目一遍
print(f"即将开工的类: {name}")
print(f"它的父类: {bases}")
print(f"它携带的属性: {list(namespace.keys())}")
# 真正的"造类"动作还是要请示 type 本尊
cls = super().__new__(mcs, name, bases, namespace)
return cls
# 通过 metaclass 关键字参数指派生产线
class Bird(metaclass=WatchMeta):
species = "凤头鹦鹉"
def sing(self):
print("啾啾!")
# 输出(注意:这些打印发生在"类定义那一刻",还没造任何实例):
# 即将开工的类: Bird
# 它的父类: ()
# 它携带的属性: ['__module__', '__qualname__', 'species', 'sing']
四个参数的含义:
mcs:元类自己(相当于普通方法里的cls);name:类的名字(字符串);bases:父类们(元组);namespace:类体里所有成员(字典)。
元类身上同样有 __new__、__init__、__call__ 三件套,分工和上一章讲的完全同构:
| 方法 | 职责 |
|---|---|
__new__ |
造出类对象并返回 |
__init__ |
给刚造好的类做初始化(无返回值) |
__call__ |
拦截"类名加括号"——类的实例化过程被它捏在手里 |
三者触发时机,可运行演示见本章 demo.py。典型应用场景:命名规范检查、子类自动注册、批量方法增强、ORM 框架的模型定义等。
12. 元类的继承与冲突
子类会自动继承父类的元类——毕竟"生产线"也是家产的一部分:
class WatchMeta(type):
pass
class Root(metaclass=WatchMeta):
pass
class Leaf(Root): # 没写 metaclass,但自动用上了 WatchMeta
pass
print(type(Leaf)) # <class '__main__.WatchMeta'>
麻烦出在多继承:两个父类各用各的元类,Python 不知道听谁的,直接报错。解法是造一个"共同上级":
class MetaOne(type):
pass
class MetaTwo(type):
pass
class A(metaclass=MetaOne):
pass
class B(metaclass=MetaTwo):
pass
# class C(A, B): pass # TypeError! 两条生产线合不到一块儿
# 解决:让新元类同时继承两条生产线
class JointMeta(MetaOne, MetaTwo):
pass
class C(A, B, metaclass=JointMeta): # 正常运行
pass
13. 本章总结
| 概念 | 一句话说明 |
|---|---|
| 描述符类 | 实现了 __get__/__set__/__delete__ 之一的类 |
| 数据型描述符 | 带写入能力的描述符,优先级压过实例字典 |
| 非数据型描述符 | 只有 __get__ 的描述符,会被实例字典反超 |
__set_name__ |
挂载到类属性时自动触发,告知类名与属性名 |
@property |
官方提供的数据型描述符快捷方式 |
| 元类 | 造类的类,默认为 type |
元类 __new__ |
创建类,返回类对象 |
元类 __init__ |
初始化类,无返回值 |
元类 __call__ |
接管类的实例化过程 |
| 元类应用 | 命名检查、自动注册、方法增强、ORM 等 |
习题
一、只读的扑克牌
编写一个 Card 类,点数和花色一旦确定就不许再改(提示:用 @property,且不提供 setter):
card = Card("A", "黑桃")
print(card.rank) # A
print(card.suit) # 黑桃
# card.rank = "K" # AttributeError! 不许修改
二、工资条校验
编写一个 Salary 类:月薪可修改,但必须在 0 到 1000000 之间(闭区间);年薪和时薪是只读的派生属性(按每年 12 个月、每月 22 个工作日、每日 8 小时折算):
s = Salary(20000)
print(s.monthly) # 20000
print(s.yearly) # 240000(只读,自动计算)
print(s.hourly) # 113.63636363636364(只读,自动计算)
# s.monthly = -1 # ValueError! 月薪不合法
三、带监控的配置项
编写一个描述符 UsageMeter,统计它守护的属性被读取、被改写各多少次(次数记录在描述符类身上,全班共享):
class Device:
voltage = UsageMeter(220) # 初始电压 220
d = Device()
print(d.voltage) # 220
print(d.voltage) # 220
d.voltage = 380
print(d.voltage) # 380
print(UsageMeter.get_read_count()) # 3 —— 一共被读了 3 次
print(UsageMeter.get_write_count()) # 1 —— 一共被写了 1 次
四、思考题
不运行代码,先推理输出结果,再说明原因:
class Inspector:
def __get__(self, instance, owner):
print(f"__get__ 触发,instance={instance}, owner={owner}")
return 42
def __set__(self, instance, value):
print(f"__set__ 触发,instance={instance}, value={value}")
class Host:
x = Inspector()
h = Host()
print(h.x)
h.x = 100
h.__dict__["x"] = "藏在实例字典里"
print(h.x)
print(h.__dict__)
五、单例元类
编写元类 OneOnlyMeta,凡是用它生产的类,实例化多少次都只发同一个对象:
class OneOnlyMeta(type):
# 你的代码
pass
class CacheStore(metaclass=OneOnlyMeta):
def __init__(self, region):
self.region = region
store_a = CacheStore("华东")
store_b = CacheStore("华北")
print(store_a is store_b) # True
print(store_a.region) # 华东 —— 第一次实例化的参数说了算
六、自动注册的导出器
编写元类 ExporterMeta:凡是继承 Exporter 的子类,定义完成的一瞬间就自动登记进 ExporterMeta.registry 字典(键为类名,值为类本身):
class ExporterMeta(type):
# 你的代码
pass
class Exporter(metaclass=ExporterMeta):
pass
class CsvExporter(Exporter):
pass
class JsonExporter(Exporter):
pass
print(ExporterMeta.registry)
# {'CsvExporter': <class CsvExporter>, 'JsonExporter': <class JsonExporter>}
七、给所有方法装上追踪器
编写元类 TraceMeta:自动为类中每个非私有方法(名字不以 _ 开头的方法)包一层追踪——调用时先打印 [TRACE] 进入 {方法名},再执行原方法逻辑:
class TraceMeta(type):
# 你的代码
pass
class TextStats(metaclass=TraceMeta):
def word_count(self, text):
return len(text.split())
def char_count(self, text):
return len(text)
stats = TextStats()
print(stats.word_count("hello magic world"))
print(stats.char_count("hello magic world"))
# 期望输出:
# [TRACE] 进入 word_count
# 3
# [TRACE] 进入 char_count
# 17
下一章:10 · 装饰器