写了几年 Python,几乎每个人都知道
@property,但很少有人知道——它底层就是一个描述符(descriptor)。而描述符,正是 Python 里 property、classmethod、staticmethod、甚至普通方法,共享的同一个底层机制。

文章目录
一、先说结论:描述符是 Python 属性系统的地基
我先抛一个问题:@property、@classmethod、@staticmethod,这三个东西看起来八竿子打不着,但它们有没有共同点?
答案是:它们都是描述符。
描述符(descriptor)是 Python 的一个底层协议——任何定义了 __get__、__set__、__delete__ 中任意一个方法的对象,就是一个描述符。它能在属性被访问的瞬间"劫持"这个访问,执行自定义逻辑。
这篇文章我把描述符协议、@property 的底层拆解、数据描述符 vs 非数据描述符的优先级规则、以及自定义描述符的实战,一次性讲清楚。核心结论先放这:理解描述符,你才算真正理解了 Python 的属性访问机制——这也是从"会用 Python"到"懂 Python 底层"的一道分水岭。
收藏提示①:文末的自定义描述符模板可以直接复用,做类型校验、惰性属性时抄过去改就行。
二、环境信息
| 项 | 版本 |
|---|---|
| Python | 3.13.12 |
| 说明 | 描述符协议是 Python 语言级特性,2.2 起就存在,本文示例适用所有现代 Python 3.x |
三、什么是描述符:一个三方法的协议
描述符协议极其简单,就三个方法:
class Descriptor:
def __get__(self, obj, objtype=None):
# 访问属性时调用:obj.attr
...
def __set__(self, obj, value):
# 赋值时调用:obj.attr = value
...
def __delete__(self, obj):
# 删除时调用:del obj.attr
...
一个对象只要实现了其中任意一个方法,它就成了描述符。关键在它必须作为类属性存在,而不是实例属性。
先看一个最简单的描述符,感受一下"劫持属性访问"是什么意思:
class Upper:
"""把赋进去的字符串自动转成大写"""
def __get__(self, obj, objtype=None):
return obj._value
def __set__(self, obj, value):
obj._value = value.upper() # 赋值时自动转大写
class Person:
name = Upper() # 注意:描述符是「类属性」
p = Person()
p.name = "patrick"
print(p.name) # PATRICK —— 赋值时被自动转成了大写
p.name = "patrick" 这行赋值,没有走默认的"存进实例 __dict__",而是被 Upper.__set__ 劫持了,把字符串转成大写再存。这就是描述符的本质——在属性访问的必经之路上插一脚。
四、property 就是个描述符
这是最能让人"哦原来如此"的一步。你平时这么用 @property:
class Person:
def __init__(self, name):
self._name = name
@property
def name(self):
return self._name
@name.setter
def name(self, value):
if not value:
raise ValueError("名字不能为空")
self._name = value
这段代码,底层等价于:
class Person:
def __init__(self, name):
self._name = name
def get_name(self):
return self._name
def set_name(self, value):
if not value:
raise ValueError("名字不能为空")
self._name = value
name = property(get_name, set_name) # property 返回的就是一个描述符对象
property(...) 返回的对象,实现了 __get__ 和 __set__——所以它就是一个描述符。@property 只是它的语法糖。同理,@classmethod、@staticmethod,甚至普通的函数对象,全都是描述符。
五、数据描述符 vs 非数据描述符:优先级才是核心
描述符分两档,这一档之差,决定了属性查找时谁说了算:
| 类型 | 实现的方法 | 优先级(相对实例 __dict__) | 典型代表 |
|---|---|---|---|
| 数据描述符 | __get__ + __set__/__delete__ | 高于实例字典 | @property、ORM 字段 |
| 非数据描述符 | 只实现 __get__ | 低于实例字典 | 函数、@classmethod、@staticmethod |
属性查找的完整顺序(CPython object.__getattribute__ 的逻辑)是这样的:
obj.attr
├─ 1. 找数据描述符 → 找到就调用 __get__,直接返回
├─ 2. 查实例 __dict__ → 有同名键就返回
├─ 3. 找非数据描述符 → 找到就调用 __get__
├─ 4. 返回类变量
└─ 5. 都没有 → 触发 __getattr__(兜底)
收藏提示②:这个优先级顺序是描述符的全部精髓——数据描述符能覆盖实例属性,非数据描述符会被实例属性覆盖。这也是为什么
@property能"锁死"属性、而普通方法可以被实例同名属性顶掉。

上图:
obj.attr的完整查找顺序,从上到下依次是数据描述符 → 实例字典 → 非数据描述符 → 类变量 →__getattr__。记住这张图,属性"为什么没生效"的问题就再也不用靠猜了。

上图:左边数据描述符(红色,能覆盖实例属性),右边非数据描述符(橙色,会被实例属性顶掉)。一档之差,行为完全不同。
六、自定义描述符实战:类型校验 + 惰性属性
理解了原理,就能自己造轮子了。下面两个是描述符最经典的两个应用场景。
场景一:字段类型校验(模仿 ORM)
class TypedField:
"""一个带类型校验的数据描述符"""
def __init__(self, expected_type):
self.expected_type = expected_type
def __get__(self, obj, objtype=None):
if obj is None: # 类级别访问,返回描述符本身
return self
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
if not isinstance(value, self.expected_type):
raise TypeError(f"期望 {self.expected_type},得到 {type(value)}")
obj.__dict__[self.name] = value
def __set_name__(self, owner, name): # 自动拿到属性名
self.name = name
class Account:
owner = TypedField(str) # owner 必须是 str
balance = TypedField(float) # balance 必须是 float
acc = Account()
acc.owner = "Patrick" # ok
acc.balance = 100.0 # ok
acc.balance = "一百块" # TypeError: 期望 <class 'float'>,得到 <class 'str'>
场景二:惰性属性(访问时才计算,只算一次)
class LazyProperty:
"""非数据描述符:首次访问时计算并缓存"""
def __init__(self, func):
self.func = func
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:
@LazyProperty
def heavy_data(self):
print("计算中……") # 只会打印一次
return sum(i*i for i in range(10_000_000))
r = Report()
r.heavy_data # 第一次:计算中……
r.heavy_data # 第二次:直接返回缓存,不再计算
收藏提示③:这两个模板——
TypedField(类型校验)和LazyProperty(惰性属性)——是描述符最高频的两个用途,直接收藏,以后做数据校验或性能优化时抄来改。
七、踩坑记录:这两个坑,几乎人人踩
- 描述符必须定义成类属性:如果你在
__init__里写self.name = Descriptor(),那只是普通的实例属性赋值,描述符协议完全不会触发。这是新手最常见的坑——描述符要写在 class 体里,作为类属性。 - 数据 vs 非数据搞混,属性"覆盖"行为不一样:想用描述符锁死一个属性(不让实例覆盖),就必须用数据描述符(实现
__set__)。如果只实现__get__(非数据描述符),实例一旦self.attr = xxx,描述符就失效了。
八、写在最后
描述符这个东西,学之前觉得神秘,学完之后你会发现——Python 的属性系统其实就建立在这么简单的一个三方法协议上。property、classmethod、staticmethod、方法绑定,全都是它的具体应用。
理解它最大的收获,不是"多会一个特性",而是看懂了 Python 对象模型的一层:当你下次遇到"为什么这个属性赋值没生效""为什么 @property 能锁死属性"这类问题时,脑子里会自动浮现那张优先级查找表,而不是靠猜。
需要说明的是,描述符是语言级特性,不受版本影响,示例代码在 Python 3.x 全部适用。如果你正从"会用 Python"往"理解 Python"进阶,描述符是绕不过去的一站。
收藏提示④:如果这篇帮你把 Python 属性机制看懂了,收藏 + 点赞,下次纠结"属性为什么没生效"时直接回来查那张优先级表。


4471

被折叠的 条评论
为什么被折叠?



