Python属性拦截四件套:__setattr__、__getattr__、__delattr__与__call__详解
简介这份PDF面向已掌握Python基础语法、希望深入理解对象模型与魔法方法的开发者聚焦__setattr__、getattr、__delattr__与__call__四个特殊方法的实际用法。资源通过可运行的代码示例分别演示属性设置时的拦截与日志记录、属性缺失时的默认值回退与异常抛出、属性删除时的清理逻辑以及让实例像函数一样被调用的实现方式并延伸至getattr驱动的工厂模式与微博API客户端中的动态属性链式调用场景。压缩包内仅含1个PDF文件约40KB篇幅精炼、便于随时查阅。目前已有670人学习。读者可从中获得对属性访问全流程的清晰认知理解__dict__查找与__getattr__的触发顺序掌握用魔法方法实现动态属性、数据校验与可调用对象的设计思路为阅读框架源码和编写高可定制类提供直接参考。1. 属性拦截四件套为什么你的 Python 类需要__setattr__和__getattr__写业务代码时你可能遇到过这种需求给对象赋值时想自动做类型校验访问不存在的属性时想返回一个默认值而不是抛AttributeError删除属性时想记录日志甚至想让实例像函数一样被调用。这些场景背后都指向同一组工具——__setattr__、__getattr__、__delattr__、__call__。它们不是冷门语法糖而是 Python 数据模型里最实用的四个钩子。很多 ORM 框架、配置管理库、代理类和 DSL 工具核心逻辑就是围绕这几个方法展开的。这篇文章从零开始把四个方法的触发时机、参数签名、常见组合和踩坑点讲清楚每个方法都配可运行的代码示例最后给出一套能直接套用的属性代理模板。适合已经会写类、但对属性拦截机制还停留在“知道有这么回事”阶段的开发者。2. 四个钩子的触发时机与参数签名先搞清谁在什么时候被调用2.1__setattr__每次赋值都会被它截住只要执行obj.x valuePython 就会调用type(obj).__setattr__(obj, x, value)。注意是类型上的方法不是实例上的。默认实现会把值写进实例的__dict__。一旦你重写了它就必须自己决定值往哪里放否则属性永远写不进去。class StrictConfig: def __init__(self): # 必须用 object.__setattr__ 绕过自己的拦截否则死循环 object.__setattr__(self, _allowed, {host, port}) def __setattr__(self, name, value): if name not in self._allowed: raise AttributeError(f不允许设置属性: {name}) object.__setattr__(self, name, value) cfg StrictConfig() cfg.host localhost # 正常 cfg.debug True # 抛 AttributeError逻辑说明__setattr__里如果写self.name value会再次触发自己形成无限递归。正确做法是调用object.__setattr__(self, name, value)走默认写入路径。参数说明name是字符串属性名value是任意对象。_allowed集合本身也要用object.__setattr__初始化否则第一次赋值就被拦截。2.2__getattr__只在正常查找失败后才出场__getattr__和__getattribute__容易搞混。__getattribute__拦截所有属性访问__getattr__只在默认查找链实例字典、类字典、父类全部失败后才被调用。所以它适合做兜底默认值、动态代理、懒加载。class DefaultDict: def __init__(self, defaults): self._defaults defaults def __getattr__(self, name): # 注意_defaults 本身要通过正常路径找到否则递归 if name.startswith(_): raise AttributeError(name) return self._defaults.get(name, None) d DefaultDict({timeout: 30, retry: 3}) print(d.timeout) # 30 print(d.unknown) # None逻辑说明__getattr__里访问self._defaults时如果_defaults还没被设置会再次触发__getattr__导致递归。所以用name.startswith(_)把下划线开头的属性直接抛AttributeError让 Python 走正常报错路径。参数说明name是访问失败的属性名返回值就是这次属性访问的结果。2.3__delattr__删除属性时的最后一道关卡del obj.x触发__delattr__(self, name)。默认实现从__dict__里删。重写它可以做删除保护、引用计数清理、审计日志。class ProtectedResource: def __init__(self): object.__setattr__(self, _locked, {id, token}) self.id 1001 self.token abc self.cache {} def __delattr__(self, name): if name in self._locked: raise PermissionError(f属性 {name} 不允许删除) object.__delattr__(self, name) r ProtectedResource() del r.cache # 正常 del r.id # 抛 PermissionError逻辑说明和__setattr__一样删除时也要用object.__delattr__走默认路径否则递归。参数说明name是要删除的属性名。_locked集合用object.__setattr__初始化避免被自己的__setattr__拦截。2.4__call__让实例变成可调用对象定义了__call__的实例可以像函数一样加括号调用。参数完全自定义返回值也自定义。常见于工厂类、装饰器类、状态机。class Multiplier: def __init__(self, factor): self.factor factor def __call__(self, x, y0): return self.factor * (x y) double Multiplier(2) print(double(5)) # 10 print(double(5, 3)) # 16逻辑说明__call__的签名就是实例调用时的签名。double(5)等价于double.__call__(5)。参数说明self是实例本身其余参数由调用方传入。返回值直接作为调用结果。四个方法的触发时机可以用一张表对照方法触发语句默认行为常见用途__setattr__obj.x v写入__dict__校验、代理、只读保护__getattr__obj.x查找失败抛AttributeError默认值、懒加载、动态代理__delattr__del obj.x从__dict__删除删除保护、清理、审计__call__obj(...)不可调用工厂、装饰器、状态机3. 把四个钩子组合起来一个可复用的属性代理类3.1 需求拆解代理对象要做什么假设你要写一个配置代理它包装一个字典支持点号读写、删除、默认值还能像函数一样批量更新。这个需求同时用到四个钩子。先明确行为边界读属性时优先从内部字典取取不到返回默认值写属性时校验键名删除时只允许删非核心键调用时批量合并字典。3.2 完整实现与逐段说明class ConfigProxy: def __init__(self, dataNone, defaultsNone): # 用 object.__setattr__ 初始化内部状态避免被拦截 object.__setattr__(self, _data, dict(data or {})) object.__setattr__(self, _defaults, dict(defaults or {})) object.__setattr__(self, _locked, {_data, _defaults, _locked}) def __getattr__(self, name): # 下划线开头直接报错防止递归 if name.startswith(_): raise AttributeError(name) if name in self._data: return self._data[name] if name in self._defaults: return self._defaults[name] raise AttributeError(f配置项不存在: {name}) def __setattr__(self, name, value): if name.startswith(_): object.__setattr__(self, name, value) return if not isinstance(name, str) or not name.isidentifier(): raise KeyError(f非法配置键: {name}) self._data[name] value def __delattr__(self, name): if name in self._locked: raise PermissionError(f核心属性不允许删除: {name}) if name in self._data: del self._data[name] else: raise AttributeError(f配置项不存在: {name}) def __call__(self, **updates): for k, v in updates.items(): setattr(self, k, v) return self def as_dict(self): merged dict(self._defaults) merged.update(self._data) return merged逻辑说明__init__里所有内部属性都用object.__setattr__写入避免触发自己的__setattr__。__getattr__先拦截下划线属性再从_data和_defaults里找找不到抛AttributeError。__setattr__对下划线属性放行到object.__setattr__普通属性写入_data。__delattr__保护_locked集合里的键。__call__接收关键字参数批量setattr后返回自身支持链式调用。参数说明data是初始配置字典defaults是默认值字典_locked是禁止删除的键集合。__call__的**updates是任意关键字参数。3.3 运行验证与边界测试cfg ConfigProxy({host: 127.0.0.1}, {port: 8080, timeout: 30}) print(cfg.host) # 127.0.0.1 print(cfg.port) # 8080来自默认值 cfg.port 9090 print(cfg.port) # 9090 del cfg.timeout # 删除默认值实际删的是 _data没有则报错上面最后一行会抛AttributeError因为timeout只在_defaults里不在_data里。这是设计选择删除只作用于显式设置的配置。如果要支持删除默认值需要在__delattr__里同时检查_defaults。这个边界值得在文档里写清楚否则调用方会困惑。链式调用验证cfg(host0.0.0.0, timeout60).as_dict() # {port: 9090, timeout: 60, host: 0.0.0.0}__call__返回self所以可以继续点号访问或再次调用。这种风格在配置构建器里很常见。4. 避坑与排查四个钩子最容易翻车的五个地方4.1 无限递归__setattr__里写self.x v现象调用obj.x 1直接RecursionError栈溢出。原因__setattr__内部又触发了自己的__setattr__。解决所有内部写入改用object.__setattr__(self, name, value)或者用一个独立的内部字典存状态通过object.__getattribute__访问。4.2__getattr__吞掉AttributeError导致hasattr永远为真现象hasattr(obj, anything)返回True即使属性不存在。原因__getattr__对任何名字都返回了值没有抛AttributeError。解决对不认识的属性必须抛AttributeError让hasattr和getattr的三参数形式正常工作。下划线开头的属性尤其要直接抛错避免递归。4.3__delattr__里访问self.__dict__被拦截现象del obj.x时__delattr__里写del self.__dict__[x]报KeyError或递归。原因self.__dict__本身也是属性访问可能触发__getattr__。解决用object.__getattribute__(self, __dict__)获取真实字典或者直接用object.__delattr__(self, name)。4.4__call__和__init__参数混淆现象obj MyClass(1, 2)正常但obj(3, 4)报参数错误。原因__init__和__call__是两套独立签名实例化时传参给__init__调用时传参给__call__。解决在类文档里分别写清两个签名或者用*args, **kwargs统一转发。4.5 继承链上父类钩子被绕过现象子类重写了__setattr__但父类__init__里的赋值没有触发子类逻辑。原因父类__init__里如果用了self.x v会触发子类的__setattr__但如果父类用了object.__setattr__或super().__setattr__就绕过了。解决统一约定内部状态用object.__setattr__业务属性用self.x v并在子类里用super().__setattr__调用父类实现。5. 进阶技巧用__getattr__做懒加载与__call__做装饰器类5.1 懒加载属性第一次访问才计算class LazyLoader: def __init__(self, compute_fn): object.__setattr__(self, _compute, compute_fn) object.__setattr__(self, _cache, {}) def __getattr__(self, name): if name.startswith(_): raise AttributeError(name) if name not in self._cache: self._cache[name] self._compute(name) return self._cache[name] loader LazyLoader(lambda key: fvalue_of_{key}) print(loader.foo) # value_of_foo print(loader.foo) # 第二次直接读缓存逻辑说明__getattr__只在属性不存在时触发所以第一次访问foo时计算并写入_cache第二次访问时foo已经在_cache里但注意——foo并不在实例__dict__里所以每次访问loader.foo仍然会走__getattr__只是命中缓存后直接返回。如果要让第二次访问走正常路径可以在__getattr__里用object.__setattr__(self, name, value)把结果写进实例字典这样后续访问就不再触发__getattr__。两种策略各有取舍写回字典更快但会占用内存不写回则每次都有一次__getattr__调用开销。5.2 装饰器类用__call__同时支持带参和不带参class Retry: def __init__(self, times3): self.times times def __call__(self, func): def wrapper(*args, **kwargs): last_exc None for _ in range(self.times): try: return func(*args, **kwargs) except Exception as e: last_exc e raise last_exc return wrapper Retry(times5) def unstable(): raise ValueError(boom)逻辑说明Retry(times5)先创建一个实例然后语法把这个实例当作可调用对象触发__call__(func)返回wrapper。这样既支持Retry也支持Retry(times5)取决于__init__的默认参数。参数说明times是重试次数func是被装饰函数wrapper是实际替换的函数。5.3 验证方法用unittest.mock检查钩子调用次数from unittest.mock import patch class Target: def __setattr__(self, name, value): object.__setattr__(self, name, value) with patch.object(Target, __setattr__, wrapsTarget.__setattr__) as mock_set: t Target() t.x 1 t.y 2 print(mock_set.call_count) # 2逻辑说明patch.object包装__setattr__后每次赋值都会记录调用。call_count为 2 说明两次赋值都走了钩子。这个方法可以用来验证你的拦截逻辑是否覆盖了所有预期路径尤其是继承链上父类初始化是否绕过了子类钩子。我自己的习惯是只要类里出现了__setattr__或__getattr__就立刻在__init__里把所有内部状态用object.__setattr__写死并且给下划线属性加一条“直接抛AttributeError”的规则。这条规则帮我省掉了至少三次递归调试的后悔药。希望帮到你。本文还有配套的精品资源点击获取