描述符协议:@property 与 @classmethod 的实现原理

文章目录

    • 背景:那些"看起来像属性"的东西
    • 一、什么是描述符
      • [1.1 描述符的定义](#1.1 描述符的定义)
      • [1.2 数据描述符与非数据描述符](#1.2 数据描述符与非数据描述符)
      • [1.3 最简单的描述符示例](#1.3 最简单的描述符示例)
    • 二、描述符协议的完整触发链路
      • [2.1 通过 `object.getattribute` 理解触发机制](#2.1 通过 object.__getattribute__ 理解触发机制)
    • [三、`@property` 的实现原理](#三、@property 的实现原理)
      • [3.1 `property` 是数据描述符的证明](#3.1 property 是数据描述符的证明)
    • [四、`@classmethod` 与 `@staticmethod` 的描述符本质](#四、@classmethod@staticmethod 的描述符本质)
      • [4.1 方法绑定:函数是非数据描述符](#4.1 方法绑定:函数是非数据描述符)
      • [4.2 `classmethod` 的纯 Python 实现](#4.2 classmethod 的纯 Python 实现)
      • [4.3 `staticmethod` 的纯 Python 实现](#4.3 staticmethod 的纯 Python 实现)
      • [4.4 三种方法的对比](#4.4 三种方法的对比)
    • 五、`set_name`:描述符的自我感知
    • [六、`@property` vs 描述符的选型原则](#六、@property vs 描述符的选型原则)
    • 七、`functools.cached_property`:延迟计算描述符
    • [八、描述符在 ORM 中的应用:Django Field 的工作原理](#八、描述符在 ORM 中的应用:Django Field 的工作原理)
    • 九、描述符协议的工程最佳实践
      • [9.1 始终处理 `obj is None` 的情况](#9.1 始终处理 obj is None 的情况)
      • [9.2 用 `set_name` 替代手动传名](#9.2 用 __set_name__ 替代手动传名)
      • [9.3 描述符存储:实例字典 vs 描述符自身](#9.3 描述符存储:实例字典 vs 描述符自身)
    • 十、小结

背景:那些"看起来像属性"的东西

在日常开发中,有一类行为司空见惯却鲜有人深究:

python 复制代码
class Circle:
    def __init__(self, radius):
        self._radius = radius

    @property
    def area(self):
        return 3.14159 * self._radius ** 2

c = Circle(5)
print(c.area)   # 78.53975 ------ 访问的是方法,语法却是属性

c.area 看起来是一次属性访问,实际上调用了一个方法。@property 是怎么做到的?它背后有什么机制在支撑?

答案是描述符协议 (Descriptor Protocol)。它是 Python 数据模型中最精妙、也最容易被忽略的一个核心机制------@property@classmethod@staticmethod__slots__,乃至 functools.cached_property,全都是基于描述符协议实现的。

理解描述符,不仅能看穿这些内置装饰器的本质,更能在框架开发、ORM 设计、配置校验等场景中写出真正优雅的代码。


一、什么是描述符

1.1 描述符的定义

描述符(Descriptor)是一个实现了以下一个或多个特殊方法的对象:

方法 签名 触发时机
__get__ (self, obj, objtype=None) 属性读取
__set__ (self, obj, value) 属性写入
__delete__ (self, obj) del 删除属性
__set_name__ (self, owner, name) 类定义时,描述符被赋给类属性

把这样的对象赋值给类的属性(注意:必须是类属性,不是实例属性),Python 就会在访问该属性时自动调用对应的方法,而不是直接返回对象本身。

1.2 数据描述符与非数据描述符

描述符分为两类,优先级不同:

类型 实现的方法 示例
数据描述符 同时实现 __get____set__(或 __delete__ propertyclassmethod(的底层实现)
非数据描述符 只实现 __get__ 普通函数(方法绑定的本质)

这两类描述符的优先级顺序至关重要,放在属性查找顺序一节详细解释。

1.3 最简单的描述符示例

python 复制代码
class Verbose:
    """一个会说话的描述符:每次读写都打印日志"""

    def __set_name__(self, owner, name):
        # 在类定义时被调用,name 是在 owner 类中的属性名
        self.public_name = name
        self.private_name = f"_{name}"

    def __get__(self, obj, objtype=None):
        if obj is None:
            # 通过类访问时,obj 为 None,返回描述符本身
            return self
        value = getattr(obj, self.private_name, None)
        print(f"[GET] {self.public_name} = {value!r}")
        return value

    def __set__(self, obj, value):
        print(f"[SET] {self.public_name} = {value!r}")
        setattr(obj, self.private_name, value)


class Person:
    name = Verbose()
    age  = Verbose()

    def __init__(self, name, age):
        self.name = name   # 触发 Verbose.__set__
        self.age  = age    # 触发 Verbose.__set__


p = Person("Alice", 30)
# 输出:[SET] name = 'Alice'
#        [SET] age  = 30

print(p.name)
# 输出:[GET] name = 'Alice'
#        Alice

这个 Verbose 描述符只有 30 行,却完整演示了描述符的核心工作方式。Person.namePerson.age 不再是普通属性------任何读写操作都经过 Verbose 的拦截。


二、描述符协议的完整触发链路









obj.attr 属性访问
type(obj).mro

是否有名为 attr 的

数据描述符?
调用描述符的 get(obj, type(obj))
obj.dict

是否有 attr?
直接返回 obj.dict['attr']
type(obj).mro

是否有名为 attr 的

非数据描述符?
调用非数据描述符 get(obj, type(obj))
type(obj).mro

是否有名为 attr 的

普通类属性?
返回该类属性
抛出 AttributeError

这条链路揭示了几个关键规则:

  1. 数据描述符优先级最高 ,凌驾于实例 __dict__ 之上
  2. 实例 __dict__ 高于非数据描述符
  3. 非数据描述符(普通函数)最后才被检查

这就是为什么 @property 可以"覆盖"同名的实例变量------property 是数据描述符,优先级高于实例字典。

2.1 通过 object.__getattribute__ 理解触发机制

实际上,整个属性访问链路的实现者是 object.__getattribute__。用 Python 伪代码表示:

python 复制代码
def object_getattribute(obj, name):
    # 1. 在 MRO 中查找类属性
    for base in type(obj).__mro__:
        if name in base.__dict__:
            class_attr = base.__dict__[name]
            # 2. 是否是数据描述符?
            if hasattr(class_attr, '__get__') and (
                hasattr(class_attr, '__set__') or hasattr(class_attr, '__delete__')
            ):
                return class_attr.__get__(obj, type(obj))  # 数据描述符优先
            break

    # 3. 检查实例字典
    if name in obj.__dict__:
        return obj.__dict__[name]

    # 4. 非数据描述符(如普通方法)
    if class_attr is not None and hasattr(class_attr, '__get__'):
        return class_attr.__get__(obj, type(obj))

    # 5. 普通类属性
    if class_attr is not None:
        return class_attr

    raise AttributeError(name)

三、@property 的实现原理

property 是 Python 内置类,本质是一个数据描述符 。用纯 Python 重写 property 可以让其工作原理一目了然:

python 复制代码
class property_impl:
    """property 的纯 Python 等价实现"""

    def __init__(self, fget=None, fset=None, fdel=None, doc=None):
        self.fget = fget
        self.fset = fset
        self.fdel = fdel
        # 文档字符串优先取 doc 参数,其次取 fget 的 docstring
        if doc is None and fget is not None:
            doc = fget.__doc__
        self.__doc__ = doc

    def __set_name__(self, owner, name):
        self.attrname = name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self          # 类访问时返回描述符本身
        if self.fget is None:
            raise AttributeError(f"unreadable attribute '{self.attrname}'")
        return self.fget(obj)    # 调用 getter

    def __set__(self, obj, value):
        if self.fset is None:
            raise AttributeError(f"can't set attribute '{self.attrname}'")
        self.fset(obj, value)    # 调用 setter

    def __delete__(self, obj):
        if self.fdel is None:
            raise AttributeError(f"can't delete attribute '{self.attrname}'")
        self.fdel(obj)           # 调用 deleter

    def getter(self, fget):
        return type(self)(fget, self.fset, self.fdel, self.__doc__)

    def setter(self, fset):
        return type(self)(self.fget, fset, self.fdel, self.__doc__)

    def deleter(self, fdel):
        return type(self)(self.fget, self.fset, fdel, self.__doc__)

理解了这个实现,@property 的语法糖就完全透明了:

python 复制代码
class Temperature:
    def __init__(self, celsius):
        self._celsius = celsius

    @property
    def celsius(self):
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        if value < -273.15:
            raise ValueError(f"温度不能低于绝对零度:{value}")
        self._celsius = value

    @property
    def fahrenheit(self):
        return self._celsius * 9/5 + 32

    @fahrenheit.setter
    def fahrenheit(self, value):
        self.celsius = (value - 32) * 5/9   # 复用 celsius 的校验逻辑

@celsius.setter 等价于 celsius = celsius.setter(new_function),它返回一个包含 getter 和 setter 的新 property 对象,替换原来只有 getter 的 property。

3.1 property 是数据描述符的证明

python 复制代码
p = property(lambda self: 42)
print(hasattr(p, '__get__'))    # True
print(hasattr(p, '__set__'))    # True ------即使没有设置 fset,__set__ 也存在
print(hasattr(p, '__delete__')) # True

正因为 property 同时拥有 __set__(即使未赋 setter 也存在,调用时抛 AttributeError),它是数据描述符,优先级高于实例字典。这意味着:

python 复制代码
class Foo:
    @property
    def x(self):
        return 42

f = Foo()
f.__dict__['x'] = 100   # 直接操作实例字典
print(f.x)              # 仍然输出 42,property 的 __get__ 优先

四、@classmethod@staticmethod 的描述符本质

4.1 方法绑定:函数是非数据描述符

在理解 classmethod 之前,需要先理解为什么普通函数可以作为实例方法被调用

Python 中的每个函数都实现了 __get__,因此函数是非数据描述符

python 复制代码
def greet(self):
    return f"Hello, {self.name}"

class Robot:
    name = "R2D2"
    greet = greet   # 把函数赋为类属性

r = Robot()
# r.greet() ------实际发生了什么?
# 1. 查找 type(r).__mro__ → 找到 Robot.greet(函数对象)
# 2. 函数有 __get__,是非数据描述符
# 3. 调用 greet.__get__(r, Robot) → 返回 bound method
# 4. 调用 bound method() → 等同于 greet(r)

bound = greet.__get__(r, Robot)
print(bound)          # <bound method greet of <__main__.Robot object>>
print(bound())        # Hello, R2D2

unbound = greet.__get__(None, Robot)
print(unbound)        # <function greet at 0x...>

4.2 classmethod 的纯 Python 实现

python 复制代码
class classmethod_impl:
    """classmethod 的纯 Python 等价实现"""

    def __init__(self, func):
        self.func = func

    def __get__(self, obj, objtype=None):
        if objtype is None:
            objtype = type(obj)
        # 关键:将 objtype(类本身)作为第一个参数绑定,而不是 obj(实例)
        def method(*args, **kwargs):
            return self.func(objtype, *args, **kwargs)
        return method

classmethodproperty 不同------它没有 __set__ ,所以是非数据描述符

python 复制代码
cm = classmethod(lambda cls: cls)
print(hasattr(cm, '__get__'))  # True
print(hasattr(cm, '__set__'))  # False ------非数据描述符

这意味着实例 __dict__ 中同名属性可以遮蔽 classmethod(虽然这种情况在实践中几乎不存在)。

4.3 staticmethod 的纯 Python 实现

python 复制代码
class staticmethod_impl:
    """staticmethod 的纯 Python 等价实现"""

    def __init__(self, func):
        self.func = func

    def __get__(self, obj, objtype=None):
        # 不绑定任何东西,直接返回原始函数
        return self.func

staticmethod 的作用就是屏蔽描述符协议 :没有绑定、没有传入 selfcls__get__ 只是原样返回被包装的函数。

4.4 三种方法的对比

python 复制代码
class Analyzer:
    threshold = 0.8

    def instance_method(self, data):
        """接收实例,可访问实例属性"""
        return f"instance: {data}, threshold={self.threshold}"

    @classmethod
    def class_method(cls, data):
        """接收类,可访问类属性,常用于工厂方法"""
        return f"class: {data}, threshold={cls.threshold}"

    @staticmethod
    def static_method(data):
        """不接收任何隐式参数,相当于命名空间里的普通函数"""
        return f"static: {data}"


a = Analyzer()

# 三种调用方式
print(a.instance_method("x"))   # instance: x, threshold=0.8
print(a.class_method("x"))      # class: x, threshold=0.8
print(a.static_method("x"))     # static: x

# 通过类调用
print(Analyzer.class_method("y"))   # class: y, threshold=0.8
print(Analyzer.static_method("y"))  # static: y
# Analyzer.instance_method("y")   ← 需要手动传 self,不常用

五、__set_name__:描述符的自我感知

Python 3.6 引入 __set_name__,让描述符在类定义阶段就知道自己叫什么名字:

python 复制代码
class TypedField:
    """带类型检查的描述符,自动感知属性名"""

    def __init__(self, expected_type):
        self.expected_type = expected_type
        self.name = None  # 将在 __set_name__ 中设置

    def __set_name__(self, owner, name):
        self.name = name
        self.storage_name = f"_{name}"

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self.storage_name, None)

    def __set__(self, obj, value):
        if not isinstance(value, self.expected_type):
            raise TypeError(
                f"{self.name!r} 期望 {self.expected_type.__name__} 类型,"
                f"实际传入 {type(value).__name__!r}"
            )
        setattr(obj, self.storage_name, value)


class Employee:
    name   = TypedField(str)
    salary = TypedField(float)
    dept   = TypedField(str)

    def __init__(self, name, salary, dept):
        self.name   = name
        self.salary = salary
        self.dept   = dept


e = Employee("Alice", 8500.0, "Engineering")
print(e.name)    # Alice
print(e.salary)  # 8500.0

try:
    e.salary = "8500"   # 字符串传给 float 字段
except TypeError as exc:
    print(exc)  # 'salary' 期望 float 类型,实际传入 'str'

__set_name__type.__new__ 创建类时被调用,时机早于任何实例的创建。在此之前(Python 3.6 之前),描述符必须通过显式传入名称或"猜"名称来实现自我感知,代码冗余且脆弱。


六、@property vs 描述符的选型原则

很多开发者会纠结:什么时候用 @property,什么时候自己实现描述符?








需要控制属性访问行为
多个类共享

相同的访问逻辑?
需要 getter/setter/deleter?
使用 @property

(单类场景的标准选择)
直接用普通属性

(无需额外抽象)
需要访问实例数据?
实现完整描述符

(含 get / set
是否是计算属性

且纯函数无副作用?
使用 @staticmethod

或模块级函数
使用 @classmethod

(工厂方法等场景)

核心选型准则:

  • @property:控制单个类中某个属性的读写行为,逻辑不复用
  • 自定义描述符:同一套访问逻辑跨多个类复用(如 ORM 字段、配置项校验)
  • @classmethod:工厂方法、构造器重载、需要访问类本身的方法
  • @staticmethod:逻辑上属于类但不依赖类或实例状态的工具函数

七、functools.cached_property:延迟计算描述符

Python 3.8 引入 cached_property,它是描述符协议的一个精妙应用:

python 复制代码
import functools
import time

class DataProcessor:
    def __init__(self, data):
        self._data = data

    @functools.cached_property
    def statistics(self):
        """首次访问时计算,结果缓存到实例字典中"""
        print("正在计算统计信息...")
        time.sleep(0.5)  # 模拟耗时操作
        return {
            "count": len(self._data),
            "mean": sum(self._data) / len(self._data),
            "max": max(self._data),
            "min": min(self._data),
        }


dp = DataProcessor([1, 2, 3, 4, 5, 100])
print(dp.statistics)  # 首次访问:打印"正在计算..."并耗时 0.5s
print(dp.statistics)  # 第二次访问:直接返回缓存,无耗时无日志

cached_property非数据描述符 (只有 __get__,没有 __set__)。第一次访问时它将结果写入实例的 __dict__ ,由于实例 __dict__ 的优先级高于非数据描述符,后续访问直接命中实例字典,跳过描述符,实现零开销的缓存。

用 Python 实现这个机制:

python 复制代码
class cached_property_impl:
    def __init__(self, func):
        self.func = func
        self.attrname = None
        self.__doc__ = func.__doc__

    def __set_name__(self, owner, name):
        self.attrname = name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        name = self.attrname
        val = self.func(obj)        # 计算结果
        obj.__dict__[name] = val    # 写入实例字典(下次直接命中,跳过描述符)
        return val

注意cached_property 要求类不使用 __slots__,因为缓存结果需要写入实例的 __dict__


八、描述符在 ORM 中的应用:Django Field 的工作原理

描述符在 ORM 框架中的应用是最能体现其价值的场景。以 Django Model Field 的简化实现为例:

python 复制代码
class Field:
    """简化版 Django ORM Field 描述符"""

    def __init__(self, field_type, nullable=False, default=None):
        self.field_type = field_type
        self.nullable = nullable
        self.default = default
        self.name = None

    def __set_name__(self, owner, name):
        self.name = name
        self.column = name.lower()   # 数据库列名
        # 将字段注册到所属 Model 类的元信息
        if not hasattr(owner, '_fields'):
            owner._fields = {}
        owner._fields[name] = self

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self   # Model.field_name 返回描述符本身(用于查询构建)
        return obj.__dict__.get(self.name, self.default)

    def __set__(self, obj, value):
        if value is None and not self.nullable:
            raise ValueError(f"{self.name!r} 不允许为 None")
        if value is not None and not isinstance(value, self.field_type):
            value = self.field_type(value)   # 自动类型转换
        obj.__dict__[self.name] = value


class CharField(Field):
    def __init__(self, max_length=255, **kwargs):
        super().__init__(str, **kwargs)
        self.max_length = max_length

    def __set__(self, obj, value):
        super().__set__(obj, value)
        current = obj.__dict__.get(self.name, "")
        if current and len(current) > self.max_length:
            raise ValueError(
                f"{self.name!r} 超过最大长度 {self.max_length},"
                f"实际长度 {len(current)}"
            )


class IntField(Field):
    def __init__(self, min_val=None, max_val=None, **kwargs):
        super().__init__(int, **kwargs)
        self.min_val = min_val
        self.max_val = max_val

    def __set__(self, obj, value):
        super().__set__(obj, value)
        current = obj.__dict__.get(self.name)
        if current is not None:
            if self.min_val is not None and current < self.min_val:
                raise ValueError(f"{self.name!r} 不能小于 {self.min_val}")
            if self.max_val is not None and current > self.max_val:
                raise ValueError(f"{self.name!r} 不能大于 {self.max_val}")


class Model:
    """简化版 ORM 基类"""
    _fields: dict = {}

    def __init__(self, **kwargs):
        for name, value in kwargs.items():
            setattr(self, name, value)

    @classmethod
    def field_names(cls):
        """返回所有字段名------classmethod 直接访问类的 _fields"""
        return list(cls._fields.keys())

    def to_dict(self):
        return {name: getattr(self, name) for name in self._fields}


class User(Model):
    username = CharField(max_length=50)
    email    = CharField(max_length=100, nullable=False)
    age      = IntField(min_val=0, max_val=150)

    def __repr__(self):
        return f"User(username={self.username!r}, age={self.age})"


# 使用示例
u = User(username="alice", email="alice@example.com", age=28)
print(u)                 # User(username='alice', age=28)
print(u.to_dict())       # {'username': 'alice', 'email': 'alice@...', 'age': 28}
print(User.field_names()) # ['username', 'email', 'age']

# 访问类属性时返回 Field 描述符本身(可用于构建查询)
print(User.username)     # <CharField object> ------ 可用于 User.username == "alice" 查询构建

try:
    u.age = 200          # 超过最大值
except ValueError as e:
    print(e)             # 'age' 不能大于 150

这个设计中,User.username 在类访问时返回描述符对象本身,这让 ORM 可以用 User.username == "alice" 的语法生成查询条件(__eq__ 在描述符上重载,返回 Condition 对象而非布尔值)。Django 的实际实现更为复杂,但核心机制与此完全一致。


九、描述符协议的工程最佳实践

9.1 始终处理 obj is None 的情况

__get__类访问MyClass.attr)时也会被调用,此时 objNone。未处理此情况会在类访问时产生难以理解的错误:

python 复制代码
class BrokenDescriptor:
    def __get__(self, obj, objtype=None):
        return obj.some_value   # ← 若 obj 为 None 则崩溃

class Safe:
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self   # 类访问返回描述符本身(惯例)
        return obj.__dict__.get("_value")

9.2 用 __set_name__ 替代手动传名

Python 3.6+ 中始终使用 __set_name__,避免重复指定属性名:

python 复制代码
# 旧写法(Python 3.5 及更早)
class MyModel:
    x = TypedField("x", int)   # 需要手动传名字------冗余且容易写错
    y = TypedField("y", float)

# 新写法(Python 3.6+)
class MyModel:
    x = TypedField(int)        # __set_name__ 自动获取 "x"
    y = TypedField(float)      # __set_name__ 自动获取 "y"

9.3 描述符存储:实例字典 vs 描述符自身

描述符中存储每个实例的数据时,有两种方案:

python 复制代码
# 方案一:存在描述符自身的 dict 中(有内存泄漏风险)
class BadDescriptor:
    def __init__(self):
        self.data = {}   # {obj: value} ← 持有实例引用,阻止 GC 回收

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return self.data.get(id(obj))

    def __set__(self, obj, value):
        self.data[id(obj)] = value   # 内存泄漏!obj 引用永不释放

# 方案二:存在实例 __dict__ 中(推荐)
class GoodDescriptor:
    def __set_name__(self, owner, name):
        self.storage = f"_{name}_value"

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return obj.__dict__.get(self.storage)

    def __set__(self, obj, value):
        obj.__dict__[self.storage] = value   # 随实例 GC 自然释放

方案一中 self.dataid(obj) 为键,持有实例引用,会阻止 GC 回收,产生内存泄漏。使用 weakref.WeakKeyDictionary 可以解决这个问题,但更简洁的做法是直接存入实例字典。


十、小结

描述符协议是 Python 对象模型的核心基础设施,理解它能让很多"魔法"变得透明:

内置特性 本质
@property 数据描述符(__get__/__set__/__delete__
实例方法绑定 函数的 __get__ 返回 bound method
@classmethod 非数据描述符,将 class 绑为第一参数
@staticmethod 非数据描述符,__get__ 返回原始函数
cached_property 非数据描述符,首次计算后写入实例 __dict__
Django Model Field 数据描述符,__set_name__ + 类型校验

掌握描述符,等于打开了一扇门:框架级代码的设计思路、ORM 的实现逻辑、Python 语言特性的底层原理------这些都建立在这个简洁的协议之上。

下一模块将进入描述符实战,在更复杂的工程场景中运用今天的理论基础。


如果这篇文章让某个一直以来困惑的"魔法"变得清晰,点个赞是最好的反馈

相关推荐
Kiling_07045 小时前
Java Map集合详解与实战
java·开发语言·python·算法
绝顶少年5 小时前
[特殊字符] curl_cffi vs requests:Python请求库的终极对决
开发语言·python
WL_Aurora5 小时前
备战蓝桥杯国赛【Day 18】
python·算法·蓝桥杯
Gerardisite5 小时前
企业微信消息回调接口
python·机器人·企业微信
XMYX-05 小时前
34 - Go 二进制处理(编码/解码)深度解析
开发语言·golang
RSTJ_16255 小时前
PYTHON+AI LLM DAY FIFITY-ONE
开发语言·人工智能·python
qingfeng154155 小时前
企业微信定时群发实战:API 如何实现批量消息自动发送?
java·开发语言·python·自动化·企业微信
丁劲犇5 小时前
QodeAssist:为msys2 ucrt64 Qt Creator 注入 AI 灵魂的开源插件
开发语言·人工智能·qt
qingfeng154155 小时前
企业微信 API 可以做什么?
java·开发语言·python·自动化·企业微信