首页/Python 与数据实践/描述符与元类
Python 与数据实践 · 第 09 章

描述符与元类

上一章我们见识了一批魔术方法,其中 getattr 一家负责在实例层面拦截属性访问。这一章往更深处走两层:

约 30 分钟12,124 字21 个代码示例

上一章我们见识了一批魔术方法,其中 __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 还是旧值!

问题拆开看是两个:

  1. 给 width 赋值时,我们想要一次"安检"(校验合法性),但普通属性赋值无从插手;
  2. 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 内部其实按一套固定的"资历排序"找值:

  1. 数据型描述符(类身上的)
  2. 实例自己的属性(instance.__dict__)
  3. 类身上的普通属性
  4. 一路向上找父类……

数据型描述符排在实例字典前面,这一点非常关键——它意味着:只要类属性位置上放的是数据型描述符,实例字典就"抢不走"这个名字:

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 从此不存在

给类属性直接赋值,等于拿一个普通值顶替了描述符的位置——描述符就此失效。由此得出两条最佳实践:

  1. 绝大多数场景都用数据型描述符(读写都在掌控中);
  2. 对描述符的访问,永远走实例这条路径。

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 · 装饰器