文章目录
-
- 背景:那些"看起来像属性"的东西
- 一、什么是描述符
-
- [1.1 描述符的定义](#1.1 描述符的定义)
- [1.2 数据描述符与非数据描述符](#1.2 数据描述符与非数据描述符)
- [1.3 最简单的描述符示例](#1.3 最简单的描述符示例)
- 二、描述符协议的完整触发链路
-
- [2.1 通过 `object.getattribute` 理解触发机制](#2.1 通过
object.__getattribute__理解触发机制)
- [2.1 通过 `object.getattribute` 理解触发机制](#2.1 通过
- [三、`@property` 的实现原理](#三、
@property的实现原理) -
- [3.1 `property` 是数据描述符的证明](#3.1
property是数据描述符的证明)
- [3.1 `property` 是数据描述符的证明](#3.1
- [四、`@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 描述符的选型原则](#六、
@propertyvs 描述符的选型原则) - 七、`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 描述符自身)
- [9.1 始终处理 `obj is None` 的情况](#9.1 始终处理
- 十、小结
背景:那些"看起来像属性"的东西
在日常开发中,有一类行为司空见惯却鲜有人深究:
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__) |
property、classmethod(的底层实现) |
| 非数据描述符 | 只实现 __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.name 和 Person.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
这条链路揭示了几个关键规则:
- 数据描述符优先级最高 ,凌驾于实例
__dict__之上 - 实例
__dict__高于非数据描述符 - 非数据描述符(普通函数)最后才被检查
这就是为什么 @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
classmethod 和 property 不同------它没有 __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 的作用就是屏蔽描述符协议 :没有绑定、没有传入 self 或 cls,__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)时也会被调用,此时 obj 为 None。未处理此情况会在类访问时产生难以理解的错误:
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.data 以 id(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 语言特性的底层原理------这些都建立在这个简洁的协议之上。
下一模块将进入描述符实战,在更复杂的工程场景中运用今天的理论基础。
如果这篇文章让某个一直以来困惑的"魔法"变得清晰,点个赞是最好的反馈