2119 字
约 7 分钟
15
S10-A20 · 属性访问的三层:`property`、`__getattr__` 与描述符

一段"配置读取"的代码用 __getattr__ 兜底:任何未定义的属性都从字典里取。上线后一个拼写错误的属性名(conf.timeuot)没有报错,而是静默返回了 None——因为 __getattr__ 被调用了,而它从字典里没找到就返回 None。

Python 的属性访问有三层钩子,而它们的"触发条件"完全不同:__getattribute__ 每次都调、__getattr__ 只在找不到时调、描述符在类层面接管访问。这个区分的实际意义是"出错方式":用 property 写的逻辑,出错会抛异常;用 __getattr__ 兜底的逻辑,出错会静默返回默认值——而后者在生产上更难发现。
而"用哪个"决定了"出错时是报错还是静默"。


1. 三层的触发条件

钩子 触发时机
__getattribute__ 每次属性访问
__getattr__ 常规查找失败后
描述符(__get__) 类属性被访问时
property 本质是描述符的一种

第二项是"兜底",而兜底的问题就是"它掩盖了拼写错误"。

第三项是"属性代理"的底层机制:property 就是一个实现了 __get__ 的类第三项的"投资回报"很高:理解描述符之后,property、classmethod、staticmethod、乃至 ORM 的字段声明都能一句话解释——它们都是"类属性上的描述符"。
,所以"理解描述符"能解释 property 的一切行为。

2. __getattr__ 的三种风险

风险 表现
吞掉拼写错误 返回 None 而不是 AttributeError
干扰 hasattr hasattr 永远为 True
与 __getattribute__ 混用 递归/性能问题

第一项的"变体"是"兜底返回空字典/空列表":于是"取配置失败"表现为"配置项为空",而下游"空也不报错"——错误被推迟到很远的地方才暴露。
第一项最实际:conf.timeuot 静默返回 None,于是"配置没生效"而不是"报错"。

第二项让"存在性判断"失效:hasattr(obj, 'anything') 都返回 True第二项的连带影响是"getattr(obj, 'x', default) 的默认值永远用不上",于是"没这个属性就用默认"这条逻辑失效——而它看起来完全正常。
,于是"特性检测"完全不可用。

// 反例:无约束的 __getattr__ 兜底
class Conf:
    def __getattr__(self, name):
        return self._data.get(name)          # 拼错 → None

// 正解:兜底但抛错(区分"有值"与"没这个键")
class Conf:
    def __getattr__(self, name):
        if name in self._data:
            return self._data[name]
        raise AttributeError(f"unknown config: {name}")   # 拼错就报错

3. property 的四个用途

用途 说明
计算属性 从已有字段派生
校验赋值 setter 里检查
懒加载 首次访问时计算
只读封装 只定义 getter

第四项的"陷阱"在于"它连内部赋值也禁止":self.name = x 在 __init__ 里也会抛,于是"只读属性"必须在 __init__ 里写 self.__dict__['name'] = x(或用 object.__setattr__)。
第四项有个"陷阱":只定义 getter 时,赋值会抛 AttributeError——看起来"实现了只读",但它与"不该有 setter"的语义不完全一致(内部也不能赋值)。

第二项在"字段规范化"时有用:self._name = value.strip() 放在 setter 里,于是"赋值就规范"。

// 四种用途的写法(懒加载最容易写错)
class Report:
    @property
    def rows(self):
        if self._rows is None:               # 懒加载:首次访问才查库
            self._rows = self._load()
        return self._rows
// 注意:懒加载 + 多线程时需要加锁,否则可能重复加载

4. 描述符与 property 的关系

写法 等价于
@property 一个 __get__ 描述符
@x.setter 给同一描述符加 __set__
自定义描述符类 可复用的属性行为

第三项在"多字段同规则"时最有用:例如十个字段都要"转小写",用一个描述符类比写十个 property 更省。第三项的"复用"是它相对 property 的唯一优势,但优势很大:一个描述符类可以服务几十个字段与多个类,而 property 必须每个都写一遍。

// 可复用描述符:多字段同一规则
class Lower:
    def __set_name__(self, owner, name): self.name = name
    def __get__(self, obj, objtype=None):
        return None if obj is None else obj.__dict__.get(self.name)
    def __set__(self, obj, value):
        obj.__dict__[self.name] = value.lower()      # 赋值即转小写

class User:
    email = Lower()          # 一个类,多处复用
    nickname = Lower()

"属性协议"还有一个常被忽略的入口:__set_name__。它在类创建时被调用,于是描述符可以"知道自己叫哪个名字"——这让"存到 __dict__ 的哪个键"自动化,避免手写 _name。

// 用 __set_name__ 自动取名(无需手写 _name)
class Lower:
    def __set_name__(self, owner, name):
        self.name = name                     # 自动知道叫 "email"
    def __get__(self, obj, objtype=None):
        if obj is None: return self
        return obj.__dict__.get(self.name)
    def __set__(self, obj, value):
        obj.__dict__[self.name] = value.lower()

class User:
    email = Lower()      # __set_name__ 在这里被调用,self.name = "email"

5. 什么时候用哪个

需求 选择
一个类的少量计算属性 property
多个类/字段同一规则 自定义描述符
动态代理(未知字段) __getattr__(要抛错)
全局拦截所有访问 __getattribute__(慎用)

第四项的"性能"影响也不小:Python 的普通属性访问已经绕了几层,__getattribute__ 再包一层会让"高频字段访问"明显变慢——而在热点路径上,这个开销是可测的。
第四项最危险:它拦截"每一次"访问,包括 self._x 内部访问,于是内部字段也可能被拦截——而最坏的情况是"拦截逻辑里访问了自身属性"造成无限递归。

"属性访问"的问题可以用"三行自测"在开发期暴露:拼错的属性、hasattr 的真假、.get 与属性的差异。

// 三行自测(写在测试里,防"静默兜底")
assert not hasattr(Conf({}), "definitely_missing")      # 不该 True
with pytest.raises(AttributeError): Conf({}).timeuot     # 拼错要报错
assert Conf({"timeout": 3}).timeout == 3                 # 正常路径
// 若第 1、2 项失败 → 你的 __getattr__ 兜得太宽

6. 排错:五种"属性访问问题"

现象 成因 处理方向
拼错属性不报错 __getattr__ 无约束 抛 AttributeError
hasattr 总是 True 同上 同上
property 里递归 getter 访问了自身 用 self.__dict__
懒加载重复计算 无锁/无标记 加标记
赋值没生效 只定义了 getter 补 setter

第三项很常见:@property def name(self): return self.name 会无限递归第三项的修法是"绕开属性协议":在 getter 里用 self.__dict__["name"] 或 object.__getattribute__(self, "_name")——而"用不同名字存"(_name)是更常见的做法。
——因为 self.name 触发的还是这个 property。

最后一个"性能"边界值得量化:property 的访问比公共属性慢约 2–3 倍,__getattribute__ 再慢一档。而在"每秒几十万次字段访问"的热点路径上,这个差别会体现在 CPU 上。

// 粗略量级(Python 3.11,纳秒级)
公共属性:        ~20ns
property:        ~60ns(含额外调用)
__getattribute__:~120ns(拦截所有访问)
// 判据:热点路径上"是否每秒访问百万次以上"

7. 边界:什么情况不必用这些钩子

场景 为什么 做法
简单数据类 直接公共属性 不写 property
无校验需求 无需 setter 不写
内部小工具 出错可见 不兜底
已用 dataclass 有现成机制 跟随

判断口径一句话:这个属性"访问时需不需要做事"(计算、校验、懒加载)。需要,用 property;不需要,公共属性更简单——而"为了封装而封装"会让代码更长,却不更安全。


8. 自测

  1. 三层钩子的触发条件分别是什么?为什么 __getattr__ 会"吞掉拼写错误"?
  2. 为什么"hasattr 永远为 True"会让特性检测失效?
  3. property 的四个用途里,哪一个与多线程有关?要注意什么?
  4. 自定义描述符在什么场景比 property 更省?"赋值即处理"怎么实现?
  5. 为什么 __getattribute__ 最危险?"无限递归"是怎么发生的?
  6. 判断"是否需要 property"的那个问题是什么?
S10-A20 · 属性访问的三层:`property`、`__getattr__` 与描述符
https://www.clxhxhhr.top/posts/6571/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。