Python 描述符深度实战:property、__slots__、ORM 字段背后的同一个魔法(2026)

📝 485 字 · ☕ 2 分钟阅读

前言:一个 property 引发的深夜思考

昨天写完装饰器那篇,评论区有人问了个好问题:@property 到底是啥?它不是函数也不是装饰器结果,为什么 obj.x 就能触发一个方法?我当时回了一句「它是描述符」,然后发现自己也没法三句话讲清楚。

不怪大家,描述符(descriptor)是 Python 里最「隐形」的机制之一。你每天用的 propertyclassmethodstaticmethod__slots__、甚至 ORM 里的字段对象,底层全是它。但网上资料要么只讲协议定义,要么贴一堆你看不懂的源码。这篇我用 5 个生产场景 + 200 万次调用实测,把它彻底讲透。

描述符是什么:三个方法定义一切

描述符就是一个实现了 __get____set____delete__ 中至少一个方法的类。就这么简单。当这个类的实例被作为另一个类的类属性时,Python 会在属性访问时调用对应方法,而不是直接返回这个实例本身。

class Desc:
    def __get__(self, obj, objtype=None):
        print("读取属性")
        return 42
    def __set__(self, obj, value):
        print("写入属性", value)

class Foo:
    x = Desc()   # x 是描述符实例

f = Foo()
f.x        # 输出「读取属性」,返回 42
f.x = 99   # 输出「写入属性 99」

看到没?f.x 并没有返回 Desc() 实例本身,而是被 __get__ 截胡了。这就是描述符的全部魔法——把「属性访问」这个动作变成可编程的钩子

按实现的方法分两种:只实现 __get__ 的叫非数据描述符(non-data descriptor),实现了 __set____delete__ 的叫数据描述符(data descriptor)。这个区分决定了一个关键行为——实例字典能不能覆盖它,后面细说。

场景一:手写一个 property

先破个迷信:property 不是语法,它是一个内置描述符类。用我们刚学的知识,可以自己造一个简化版:

class MyProperty:
    def __init__(self, getter=None, setter=None):
        self.getter = getter
        self.setter = setter
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self          # 类上访问返回描述符本身
        return self.getter(obj)
    def __set__(self, obj, value):
        if self.setter is None:
            raise AttributeError("只读属性")
        self.setter(obj, value)
    def setter(self, fn):        # 支持 @x.setter 链式调用
        self.setter = fn
        return self

class Circle:
    def __init__(self, radius):
        self._radius = radius
    @MyProperty
    def radius(self):
        return self._radius
    @radius.setter
    def radius(self, value):
        if value < 0:
            raise ValueError("半径不能为负")
        self._radius = value

注意我埋了一个经典坑:def setter(self, fn) 这行会把实例属性 self.setter 覆盖成方法。在 __init__ 里赋值时用的是 self.setter = setter,一旦调用 @radius.setter 后,self.setter 就变成函数了。正确写法是把 setter 存到私有属性 self._setter 里。这种「描述符内部属性名冲突」的坑,写过的人都知道多烦。

场景二:ORM 字段校验器

描述符最典型的工业级应用就是 ORM。SQLAlchemy 的 Column、Django 的 Field,本质都是描述符——表定义里的字段对象,在实例上访问时触发类型转换、校验、脏标记。我们自己写一个迷你版:

class Field:
    def __init__(self, type_, required=True, default=None):
        self.type_ = type_
        self.required = required
        self.default = default
        self.name = None     # 由 Model 元类回填
    def __set_name__(self, owner, name):
        self.name = name     # Python 3.6+,类创建时自动调用
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return obj.__dict__.get(self.name, self.default)
    def __set__(self, obj, value):
        if not isinstance(value, self.type_):
            raise TypeError(f"{self.name} 需要 {self.type_.__name__} 类型,收到 {type(value).__name__}")
        obj.__dict__[self.name] = value

class Product:
    name = Field(str, required=True)
    price = Field(float, default=0.0)
    stock = Field(int, default=0)

p = Product()
p.name = "机械键盘"     # OK
p.price = "399"         # TypeError: price 需要 float 类型,收到 str

这个例子里有个 3.6 才有的好东西 __set_name__:类创建时 Python 自动把属性名回传给描述符,省得在 __init__ 里手动传名字。数据描述符(实现了 __set__)把值存进 obj.__dict__,实例字典和描述符各司其职,不会无限递归。

场景三:cached_property 惰性缓存

functools.cached_property 是描述符的经典应用:首次访问时计算结果并缓存,之后直接返回缓存值。手写一个也就十几行:

class cached_property:
    def __init__(self, func):
        self.func = func
        self.__doc__ = func.__doc__
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        value = self.func(obj)
        obj.__dict__[self.func.__name__] = value   # 写进实例字典
        return value

class Report:
    def __init__(self, raw_data):
        self.raw_data = raw_data
    @cached_property
    def parsed(self):
        # 模拟昂贵的解析
        return [row for row in self.raw_data if row]

r = Report([1, 0, 2, None, 3])
print(r.parsed)   # 第一次:执行解析
print(r.parsed)   # 第二次:直接读实例字典,不再执行

注意这里有个微妙的点:cached_property非数据描述符(没实现 __set__)。缓存写进 obj.__dict__ 后,实例字典的优先级高于非数据描述符,所以第二次访问走的是字典而不是 __get__。如果它实现了 __set__,那实例字典永远覆盖不了它,缓存就没法生效了——这就是数据/非数据描述符区分的实战意义。

场景四:__slots__ 的真相

之前写过 __slots__ 内存优化,那篇讲的是「怎么用」,这篇揭底:__slots__ 的原理就是描述符。每个 slot 在类上生成一个成员描述符(member descriptor),替代了实例的 __dict__

class Point:
    __slots__ = ("x", "y")
    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(1, 2)
print(p.x)          # 1 —— 走的是成员描述符
# print(p.__dict__) # AttributeError: 'Point' object has no attribute '__dict__'

正因为 slot 是数据描述符,它还能阻止你给实例加新属性。百万级对象内存直降 60% 的秘密,就是省掉了每个实例的 __dict__ 字典。想省内存又想保留灵活性的,可以用 __slots__ = ("x",) + ("dict",) 组合。

性能实测:描述符到底贵不贵

总有人担心「用描述符性能崩了」。我跑了 200 万次读取实测(Python 3.12):

Python描述符性能实测:普通属性11.2ns vs property 26.6ns vs 自定义描述符61.5ns

结论:普通属性 11.2ns,property 26.6ns,自定义描述符 61.5ns。自定义描述符比普通属性贵约 5 倍,但那是纳秒级——每秒调 10 万次也才多花 5ms。真正值得警惕的不是描述符本身,而是你在 __get__ 里写的业务逻辑。别为了几纳秒的洁癖放弃正确的抽象,先量后优化,这句我在这篇 性能翻车现场里也强调过。

查找顺序:属性访问的完整链路

搞懂描述符后,obj.attr 的查找顺序才算完整:

1. type(obj).__mro__ 中找数据描述符(data descriptor)→ 调用 __get__
2. obj.__dict__(实例字典)
3. type(obj).__mro__ 中找非数据描述符 → 调用 __get__
4. type(obj).__mro__ 中找普通类属性
5. 都没有 → __getattr__(如果定义了)→ 抛 AttributeError

注意数据描述符排在实例字典前面。所以上面 cached_property 的缓存技巧才成立:非数据描述符排在实例字典后面,字典一写入就赢了。这也是为什么给类加一个实现 __set__ 的描述符,能「锁死」属性读写——它的优先级永远高于实例字典。

避坑清单

  • 描述符内部属性名冲突self.setter 这种名字会被同名方法覆盖,内部状态用 _xxx 私有命名
  • 忘记处理 obj is None:类上访问 Foo.xobj 是 None,不处理会崩或行为诡异
  • 数据描述符缓存失效:想用「写入实例字典」做缓存,描述符必须是非数据描述符
  • __set_name__ 才拿得到属性名:3.6 以下或手动实例化描述符时,name 是 None
  • 别在 __get__ 里放重逻辑:每次属性访问都会执行,配合缓存或惰性计算使用

常见问题(FAQ)

Q: 描述符和装饰器是什么关系?

完全不同但又经常一起出现。装饰器是「用函数替换函数」的语法糖;描述符是「接管属性访问」的协议。property 既是装饰器又是描述符,所以容易混淆。想看装饰器细节可以读昨天那篇 装饰器深度实战

Q: property 和自定义描述符怎么选?

单个类、逻辑简单的用 property 足够;同一个校验/转换逻辑要在多个类里复用的,抽成描述符类。ORM 字段、配置项校验、缓存属性这类「模式化」场景,描述符是正解。

Q: 数据描述符和非数据描述符区别?

数据描述符实现了 __set__ 或 __delete__,查找优先级高于实例字典,能「锁死」属性;非数据描述符只实现 __get__,优先级低于实例字典,可被实例属性覆盖。cached_property 正是利用后者实现缓存。

Q: 描述符会影响性能吗?

实测 200 万次读取:普通属性 11.2ns,property 26.6ns,自定义描述符 61.5ns。开销在纳秒级,可忽略;真正影响性能的是 __get__ 内部执行的业务逻辑,注意惰性计算和缓存即可。

总结

描述符是 Python 元编程的基石:property、classmethod、staticmethod、slots、ORM 字段、cached_property,全是一套协议的不同应用。理解它之后,很多「Python 黑魔法」在你眼里就变成了「哦,就是个钩子」。记住三个要点:三个方法、数据/非数据之分、查找顺序。剩下的,就是写的时候别踩那几个命名坑。

这篇跟昨天的 装饰器深度实战 是连着的,再往深走可以看 类型注解进阶(Protocol 和描述符配合能写出更安全的代码)、__slots__ 内存优化(描述符省内存的实战)。如果你在项目里用描述符写过什么骚操作,欢迎留言。

📤 分享这篇文章