前言
标题里的「特性」指的是property——中文书里常把它译作「特性」或「属性」,而「动态属性」指的是运行期才决定名字、才产生的属性。这两件事经常被放在一起讲,因为管好动态属性的前提,正是先弄清 Python 的属性查找与赋值到底经过哪些钩子。
一个典型误解是:「Python 对象可以随便加属性,所以动态属性就是随便obj.xxx = 1。」这只说对了一半。默认的对象确实允许随时新增属性(靠实例的__dict__),但一旦类里用了__slots__、或者定义了自己的__setattr__/__getattr__/property,这套「随便加」的规则就完全变了。搞不清规则,就会出现「明明赋了值却读不到」「读属性触发无限递归」这类诡异现象。
本文按「属性查找与赋值的四个钩子 →property→ 缓存属性与受限属性 → 描述符式字段」的顺序讲透动态属性,并给出可运行的示例。文中所有钩子的行为以 Python 官方数据模型章节为准。本机没有解释器,输出为人工推演。
一、属性访问的四个钩子
Python 把「读、写、删、找不到」拆成四个钩子,分工如下:
| 钩子 | 何时触发 | 常用场景 |
|---|
__getattribute__ | 每次读属性都触发(无条件) | 极少数需要全接管属性访问的场合 |
__getattr__ | 常规查找失败、抛AttributeError时才触发 | 代理、懒加载、缺省值 |
__setattr__ | 每次给属性赋值都触发 | 校验、只读属性、代理写 |
__delattr__ | 每次del obj.attr都触发 | 管控删除行为 |
最容易记错的是前两个:__getattribute__才是「每次读都走」的那个,__getattr__是兜底的、只在找不到时补一脚。官方特别注明:只要属性被常规机制找到,__getattr__就不会被调用——这种不对称是刻意的,既为了效率,也为了避免__getattr__没法访问实例的其他属性。
二、动态属性的基础:__dict__
普通对象的属性都存在实例自己的字典里,动态增删属性就是往这个字典里加删键:
# 适用于 Python 3.0+
class Bag:
def __init__(self, **kwargs):
self.__dict__.update(kwargs) # 一次性写入
b = Bag(a=1)
b.extra = 2 # 运行期新增属性
print(vars(b)) # {'a': 1, 'extra': 2}
del b.extra
print(hasattr(b, "extra")) # Falsevars(b)与b.__dict__是同一个东西。类属性也能动态加,但类对象的__dict__是只读代理,必须走setattr:
# 适用于 Python 3.0+
setattr(Bag, "kind", "container") # ✅ 类属性这样加
print(Bag.kind) # container有了这两个工具,__getattr__可以做出「点号访问字典键」的代理效果:
# 适用于 Python 3.0+
class Config:
def __init__(self, data):
self._data = data
def __getattr__(self, name):
try:
return self._data[name]
except KeyError:
raise AttributeError(name) from None
cfg = Config({"host": "db.local", "port": 5432})
print(cfg.host) # db.local
print(cfg.missing) # AttributeError: missing注意最后一行:找不到键时必须抛AttributeError(而不是返回None),否则hasattr、getattr的缺省值机制全都会失灵。
三、property:把属性包装成受控访问
property是数据描述符,因此它能优先于实例字典,成为读数、写数的必经通道。它的构造签名是property(fget=None, fset=None, fdel=None, doc=None),日常更多用装饰器写法:
# 适用于 Python 3.0+
class Temperature:
def __init__(self, celsius=0):
self.celsius = celsius # 走 setter
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError("不能低于绝对零度")
self._celsius = value
t = Temperature(25)
print(t.celsius) # 25
t.celsius = 100
print(t._celsius) # 100三个装饰器@property、@x.setter、@x.deleter分别设置读取、赋值、删除行为,每个都返回一个新的property 对象。只定义 getter 就是只读属性,此时给它赋值会抛AttributeError。真实数据存在_celsius里,名字带下划线是为了和 property 名区分开。
顺带说清版本差异:property是数据描述符,而 Python 2 的旧式类(old-style class)不支持描述符协议,只有继承object的新式类里property才照上面的规则生效;Python 2.7 已于2020 年 1 月 1 日停止维护,Python 3 里所有类都是新式类,上述行为一致适用,不必再区分。
四、缓存属性与受限属性
缓存属性:functools.cached_property在 Python 3.8 引入,把方法变成「算一次就缓存」的属性:
# 适用于 Python 3.8+
from functools import cached_property
class DataSet:
def __init__(self, values):
self._values = list(values)
@cached_property
def total(self):
print("只计算一次")
return sum(self._values)
ds = DataSet([1, 2, 3])
print(ds.total) # 打印"只计算一次",然后输出 6
print(ds.total) # 直接输出 6,不再打印它和property的机制不同:它只在属性还不存在时运行,运行后会把结果写进实例的同名属性,之后读取和写入都走普通属性通道。因此它要求实例有可变的__dict__——用了__slots__且没包含__dict__的类用不了它。官方还提示:从 Python 3.12 起它去掉了内部锁,多线程下 getter 可能被跑不止一次,需要同步得自己在 getter 里加锁。
受限属性:__slots__用来声明固定的一组属性,并禁止创建__dict__:
# 适用于 Python 3.0+
class Point:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x = x
self.y = y
p = Point(1, 2)
p.z = 3 # AttributeError: 'Point' object has no attribute 'z'一旦用了__slots__,「随便动态加属性」就彻底不成立了。这也是记忆点:动态加属性的能力来自__dict__,__slots__的本质是把__dict__去掉。
实战:描述符式字段与 __set_name__
想在类里声明若干「字段」,让它们自己知道名字,可以用描述符配合__set_name__(Python 3.6+):
# 适用于 Python 3.6+
class Field:
def __set_name__(self, owner, name):
self.name = name # 创建类时自动告知名字
def __get__(self, instance, owner=None):
if instance is None:
return self
return instance.__dict__.get(self.name)
def __set__(self, instance, value):
instance.__dict__[self.name] = value
class Model:
title = Field()
print(Model.title.name) # title
m = Model()
m.title = "hello"
print(m.title) # hello__set_name__由type.__new__在类创建时调用,参数是所属类和属性名。注意:类建好之后再赋值给类属性,钩子不会再自动触发,要手动调__set_name__。这一步让字段类不必在每次使用时重复写自己的名字,是很多 ORM 字段实现的基础。
顺带说,types.SimpleNamespace是标准库给的一个「随意加属性」的对象,适合临时打包数据:
# 适用于 Python 3.3+
from types import SimpleNamespace
ns = SimpleNamespace(a=1)
ns.b = 2
print(ns) # namespace(a=1, b=2)常见坑点
__getattr__里访问了会再次触发__getattr__的属性
❌def __getattr__(self, name): return self.data[name]→ 若data尚未设置,读self.data又找不到、又进__getattr__,无限递归。 ✅ 用下划线私有名并在__init__里先设好,或return object.__getattribute__(self, "data")[...]。
__getattribute__里用self.xxx取值
❌def __getattribute__(self, name): return self.__dict__[name]→ 读self.__dict__又走__getattribute__,栈溢出。 ✅ 永远用object.__getattribute__(self, name)取底层值。
__setattr__里写self.name = value
❌def __setattr__(self, name, value): self.__dict__[name] = value→ 读self.__dict__又触发__setattr__,递归。 ✅ 用super().__setattr__(name, value)或object.__setattr__(self, name, value)。
__getattr__找不到时返回None而不抛异常
❌def __getattr__(self, name): return self._data.get(name)→ 拼错的属性名也悄悄返回None,bug 被掩盖。 ✅ 找不到就raise AttributeError(name),让hasattr和缺省值机制正常工作。
- 只写 getter 的 property 被赋值
❌@property def x(self): ...之后obj.x = 1→AttributeError: can't set attribute。 ✅ 需要可写就补@x.setter;只读是有意为之就别去赋值。
- 给
__slots__又定义了同名类变量
❌class P: __slots__ = ("x",); x = 1→ 类属性会覆盖槽描述符,默认值根本不起作用(CPython 上这种定义会直接报错)。 ✅ 槽名不要和类变量重名;想让槽有默认值,在__init__里赋值。
- 对
__slots__类用cached_property
❌class P: __slots__ = ("_v",); @cached_property def total(self): ...→ 没有可变__dict__,装饰器无法把结果写回。 ✅ 把__dict__加进__slots__(__slots__ = ("__dict__", ...)),或改用property配functools.lru_cache。
- 遍历
__dict__时改属性
❌for k in vars(obj): setattr(obj, k + "_old", 1)→RuntimeError: dictionary changed size during iteration。 ✅ 先取快照:for k in list(vars(obj)): ...。
总结
| 需求 | 用什么 | 关键点 |
|---|
| 随便增删属性 | 实例__dict__/vars() | 类属性要用setattr,类__dict__只读 |
| 读不到时兜底 | __getattr__ | 必须抛AttributeError,别返回None |
| 全接管读属性 | __getattribute__ | 一律用object.__getattribute__取底层值 |
| 拦截赋值 | __setattr__ | 用super().__setattr__,别直接self.x = |
| 受控读写 | property(数据描述符) | @x.setter/@x.deleter,真实数据放_x |
| 算一次就缓存 | functools.cached_property(3.8+) | 需要可变__dict__;3.12 起无内置锁 |
| 固定属性集合 | __slots__ | 会去掉__dict__,动态加属性随之失效 |
动态属性的能力全部来自实例字典;property、描述符和__slots__之所以能改变规则的优先级,是因为它们要么是数据描述符、要么直接干掉了那个字典。把这条主线抓住,四个钩子的触发时机就不会再记混。