在Python面向对象编程中,描述符是一个能拦截属性读写行为的强大工具。当我们把描述符实例作为类属性挂载后,解释器在访问该属性时会自动调用描述符的__get__或__set__方法。很多人在第一次手写描述符做数据校验或惰性计算时,都会踩到一个隐蔽的坑:描述符内部用来保存真实数据的实例属性名,绝对不能和类上挂载的外部属性名相同,否则程序会直接递归爆炸。

描述符协议与属性查找顺序
要理解为什么名称必须不同,得先看清Python的属性查找机制。当一个类定义了描述符(即实现了__get__、__set__或__delete__中至少一个方法的对象),并且该描述符是类的属性时,对实例属性的读写会被重定向到描述符的方法。以数据描述符为例,其优先级高于实例字典__dict__中的同名条目。
具体来说,执行obj.attr = value时,如果type(obj).attr是一个数据描述符,Python会调用type(obj).attr.__set__(obj, value),而不会把值直接塞进obj.__dict__。读取时同理,优先走__get__。这个机制正是描述符能控制访问的原因,但也埋下了名称冲突的隐患。
同名导致的无限递归陷阱
假设我们想写一个最简单的类型校验描述符,并要求内部用同样的名字存储。下面这段代码就是典型的错误写法:
class BadDescriptor:
def __init__(self):
self.value = None
def __get__(self, instance, owner):
if instance is None:
return self
return instance.value
def __set__(self, instance, value):
instance.value = value
class User:
name = BadDescriptor()
u = User()
u.name = "tom"
当执行u.name = "tom"时,Python发现User.name是数据描述符,于是调用name.__set__(u, "tom")。而在__set__里又写了instance.value = value,但由于我们的描述符恰好就叫value且挂在实例的同名属性上(这里演示的是描述符自身属性,更常见的错误变体是描述符内部用instance.name = value),实际场景中若内部写instance.name = value就会再次触发__set__,形成__set__调用自身、永无终止的循环,直到超过递归深度限制抛出RecursionError。
即便上面示例没有直接同名,只要把instance.value改成instance.name,就会立刻复现崩溃。因为描述符叫name,实例再对name赋值又走描述符,死循环成立。这种错误在复杂项目里极难排查,因为堆栈全是同一个__set__。
正确的内部命名约定
行业内的通用做法是在描述符内部使用一个带下划线前缀、与外部属性名明显不同的名称来存放真实数据。这样赋值目标不再是描述符本身,而是实例字典里的一个普通字段,查找时也不会被描述符拦截。
class SafeDescriptor:
def __init__(self, name):
self.storage_name = '_' + name
def __get__(self, instance, owner):
if instance is None:
return self
return getattr(instance, self.storage_name, None)
def __set__(self, instance, value):
if not isinstance(value, str):
raise TypeError('value must be str')
setattr(instance, self.storage_name, value)
class User:
name = SafeDescriptor('name')
age = SafeDescriptor('age')
u = User()
u.name = 'tom'
u.age = 18
print(u.name)
print(u.age)
在上面的代码中,描述符通过构造参数拿到name,并在内部拼接出_name作为存储键。对u.name赋值只会写入u._name,而_name并不是描述符,所以不会再次触发__set__。读取时从_name取数,逻辑清晰且安全。
这种写法还带来一个额外好处:同一个描述符类可以复用在多个不同字段上,只需传入不同的name参数即可,避免了硬编码属性名带来的耦合。许多ORM框架和校验库正是基于此模式实现字段管理。
非数据描述符的特例与注意点
如果描述符只实现了__get__而没有__set__,它被称为非数据描述符。此时实例字典的优先级高于描述符,即如果你先u.name = x,这个赋值会直接进u.__dict__,此后读取u.name拿到的是实例字典里的值,描述符的__get__被屏蔽。但在__get__里若错误地用同名去取数,仍可能拿到描述符自身或造成混淆。
class LazyLoader:
def __init__(self, func):
self.func = func
self.name = func.__name__
def __get__(self, instance, owner):
if instance is None:
return self
value = self.func(instance)
setattr(instance, self.name, value)
return value
class Config:
@LazyLoader
def path(self):
return '/etc/app.conf'
c = Config()
print(c.path)
上面这个惰性加载例子在__get__中把计算结果写回了同名属性path。由于LazyLoader没有__set__,第二次访问c.path时实例字典已存在path,便不会再走描述符。这虽能工作,但如果在并发或继承场景下,容易因名称相同引发覆盖问题。更稳妥的方案依旧是写入带下划线的内部名,并在__get__中先查内部名缓存。
总结与最佳实践
描述符内部名称必须与外部属性名不同,根本原因是Python的属性查找会把同名赋值再次路由回描述符方法,造成无限递归。开发时请养成以下习惯:描述符类通过初始化参数接收字段名,并统一加上下划线作为存储键;在__get__和__set__中只操作这个内部键;若需复用描述符,不要硬编码属性字符串。
遵循这一约定,你就能安全利用描述符实现校验、懒加载、代理等高级特性,而不必在递归错误里耗费数小时调试。描述符本身是Python优雅设计的代表,避开名称陷阱后,它会成为你构建整洁API的得力助手。