深入理解Python property底层:描述符协议的工作原理
很多 Python 开发者把 @property 当作将方法伪装成属性的便利语法糖,但在语言底层,它完全依赖一套严密的通用规范——描述符协议(Descriptor Protocol)。理解描述符的运作方式,不仅能解答“为什么属性赋值能直接触发校验函数”,更能搞清 Python 对象属性访问、方法绑定及元编程的底层分发逻辑。
一、描述符协议的定义与核心方法
在 Python 中,任何实现了 __get__()、__set__() 或 __delete__() 中至少一个特殊方法的对象,都被称为描述符(Descriptor)。这三个方法构成了描述符协议的全部接口规范:
__get__(self, instance, owner=None):在通过实例或类读取属性时调用。instance是访问属性的具体实例(若是通过类本身访问,则为None),owner为属性所在的宿主类。__set__(self, instance, value):在通过实例给属性赋值时调用。value为待赋予的目标值。__delete__(self, instance):在通过实例执行del instance.attr时调用。
描述符本身通常作为类属性存在,而不是实例属性。当外界尝试访问 instance.attr 时,Python 底层的 object.__getattribute__ 会拦截这一请求,并根据描述符协议决定是直接返回字典中的值,还是转而调用描述符的 __get__() 方法。
二、数据描述符与非数据描述符的优先级之争
理解描述符协议的关键分水岭在于:数据描述符(Data Descriptor)与非数据描述符(Non-data Descriptor)的差异。
- 数据描述符:只要定义了
__set__()或__delete__()(即使__set__内部直接抛出异常),该对象就是数据描述符。property属于典型的数据描述符。 - 非数据描述符:只定义了
__get__(),没有定义修改或删除方法。普通的 Python 函数、方法、@classmethod以及@staticmethod都是非数据描述符。
这一机制解释了两个核心现象:
- 为什么只读 property 无法被赋值覆盖:当我们仅用
@property装饰一个 getter 方法时,Python 返回的property实例仍然定义了__set__(并在其中抛出AttributeError: can't set attribute)。因此它是一个数据描述符。执行obj.attr = 1时,解释器会优先触发该描述符的__set__并抛错,绝不会退回到向obj.__dict__写入变量。 - 为什么实例方法可以被动态覆盖:普通方法只是非数据描述符。若你显式执行
obj.my_method = lambda: "new",该函数对象会直接存入obj.__dict__['my_method']。下一次调用时,实例字典命中,原类方法就被遮蔽了。
三、手写简化版 Property 剖析运行机制
为了直观掌握 property 在底层如何调度 getter 与 setter,我们可以用纯 Python 编写一个功能等价的 SimpleProperty 类:
class SimpleProperty:
def __init__(self, fget=None, fset=None, fdel=None, doc=None):
self.fget = fget
self.fset = fset
self.fdel = fdel
self.__doc__ = doc or (fget.__doc__ if fget else None)
def __get__(self, instance, owner=None):
# 通过类本身访问 (如 Person.age),返回描述符对象自身
if instance is None:
return self
if self.fget is None:
raise AttributeError("未定义 getter 方法,无法读取属性")
# 将实例作为第一个参数传入原始 getter 函数
return self.fget(instance)
def __set__(self, instance, value):
if self.fset is None:
raise AttributeError("未定义 setter 方法,该属性为只读")
# 调用被装饰的 setter 函数
self.fset(instance, value)
def __delete__(self, instance):
if self.fdel is None:
raise AttributeError("未定义 deleter 方法,无法删除该属性")
self.fdel(instance)
def setter(self, fset):
# 返回一个新的描述符实例,将已有的 fget 与新的 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__)
测试与使用方式如下:
class Account:
def __init__(self, balance):
self._balance = balance
@SimpleProperty
def balance(self):
"""账户余额属性"""
return self._balance
@balance.setter
def balance(self, value):
if value < 0:
raise ValueError("余额不能为负数")
self._balance = value
# 测试运行
acc = Account(100)
print(acc.balance) # 输出: 100 (调用 __get__)
acc.balance = 250 # 成功赋值 (调用 __set__)
print(acc.balance) # 输出: 250
# acc.balance = -10 # 触发 ValueError: 余额不能为负数
四、编写描述符时的三大关键细节与避坑指南
在使用描述符协议或编写自定义属性扩展时,初学者极易踩入几个隐蔽的底层陷阱:
1. 为什么描述符必须挂载在“类”上,而不能挂在“实例”上?
如果在 __init__ 中执行 self.attr = MyDescriptor(),当你调用 self.attr 时,Python 不会触发 __get__,而只是返回该描述符实例本身。
这是因为 Python 的属性查找机制 object.__getattribute__ 规定:协议钩子的检查是从 type(obj) 及其 MRO 开始寻找的。实例字典 __dict__ 里的对象只会被视作普通值,永远不会触发描述符协议。
2. 属性同名无限递归死循环
在编写 setter 或自定义描述符时,若在内部直接写 self.balance = value,会再次触发当前属性的 setter,导致 RecursionError: maximum recursion depth exceeded。标准做法是使用下划线私有变量(如 self._balance)作为底层实际存储载体。
3. 描述符自身存储数据陷阱(实例共享灾难)
由于描述符是定义在类上的单例对象,如果开发者在描述符类的 __set__ 中写下 self.val = value,那么所有宿主类实例都会共享这一份 self.val!当修改实例 A 的属性时,实例 B 的值也会被暗中篡改。正确做法是将值存放在 instance.__dict__[self.name] 中,确保每个实例的数据完全隔离。
五、总结
Python 的 property 并非魔法,而是描述符协议的最典型应用。归纳起来,整个机制的精髓可概括为三点:
- 协议分级:数据描述符(包含
__set__)优先于实例字典;非数据描述符(仅__get__)让位于实例字典。 - 宿主归属:描述符必须且只能定义在类上,由
object.__getattribute__在解析属性时主动触发。 - 逻辑解耦:通过把属性的读、写、删操作封装为独立的可调用对象,实现了面向对象中极高水准的封装与验证能力。
熟练掌握描述符协议,不仅能让你看懂 property、方法绑定(Method Binding)与各类装饰器的底层运作,也为你后续理解 Django ORM、SQLAlchemy 以及 Pydantic 的属性校验体系打下最坚实的基石。
本站声明
本文内容仅供参考,不代表本站观点。转载或引用请注明出处。如发现内容问题,请通过联系我们页面反馈。













