简介:这份PDF资料面向已掌握Python基础、希望深入理解面向对象机制的开发者,系统讲解__setattr__、getattr、__delattr__与__call__四个魔法方法的用法。内容从属性设置、属性读取、属性删除到实例可调用四个维度展开,每个方法均配有可运行的代码示例,并延伸至getattr工厂模式、微博API源码中_Callable与_Executable的链式调用等真实场景,帮助读者理解动态属性、数据验证与定制化行为的实现思路。资源包内共1个PDF文件,约40KB,篇幅精炼,适合作为查阅手册或练习素材。目前已有670人学习。通过对照示例动手实践,读者可掌握重写属性访问逻辑、处理未定义属性、在删除时执行清理以及让对象像函数一样被调用的技巧,进而提升代码的灵活性与可定制性。
1. 属性拦截的四个钩子:为什么改一行__setattr__能让整个配置系统活过来
线上配置中心有个经典需求:任何配置项被赋值时都要校验类型、记录变更日志、触发回调。最直觉的写法是在每个 setter 里手写一遍,几十个字段就是几十份重复代码。真正省事的做法是重写__setattr__,让所有赋值自动走同一条管道。Python 的属性钩子——__setattr__、__getattr__、__delattr__、__call__——就是干这个的。它们不是冷门语法糖,而是 ORM 字段校验、代理对象、懒加载、可调用实例这些真实系统的地基。这篇笔记按「先搞清触发时机,再动手写最小可跑示例,最后看边界和坑」的顺序走,适合已经会写类、但被属性拦截绕晕过的开发者。读完你能自己搭一个带校验和日志的配置类,也知道哪些写法会把自己坑进无限递归。
2. 四个钩子的触发时机:先分清谁在什么时候被调用
2.1__getattr__和__getattribute__不是一回事
很多人第一次翻车就翻在这里。__getattribute__拦截所有属性访问,__getattr__只在常规查找失败后才被调用。也就是说,实例字典、类字典、描述符都找不到属性时,才轮到__getattr__兜底。
class Lazy: def __init__(self): self._loaded = False def __getattr__(self, name): # 只有正常查找失败才会进来 print(f"fallback for {name}") if name == "heavy": self._loaded = True return "computed-value" raise AttributeError(name) obj = Lazy() print(obj.heavy) # 触发 __getattr__ print(obj._loaded) # 实例字典里有,不触发逻辑说明:_loaded在__init__里已经写进实例字典,访问它走的是正常路径,__getattr__完全不参与。heavy不存在,才落到兜底逻辑。参数上唯一要注意的是name是字符串,别在里面做重活,否则每次访问失败都要付一次代价。
提示:
__getattr__里访问self.xxx要格外小心,如果xxx也不存在,会再次触发__getattr__,直接递归到栈溢出。
2.2__setattr__是所有赋值的唯一入口
__setattr__(self, name, value)在obj.x = v时被调用,包括__init__里的赋值。这意味着你一旦重写它,就必须自己决定怎么把值真正写进实例字典,否则属性永远存不进去。
class Validated: def __setattr__(self, name, value): if name == "age": if not isinstance(value, int) or value < 0: raise ValueError("age must be a non-negative int") # 关键:用 object.__setattr__ 绕过自己,避免无限递归 object.__setattr__(self, name, value) v = Validated() v.age = 30 print(v.age) try: v.age = -1 except ValueError as e: print("rejected:", e)逻辑说明:object.__setattr__是绕过自定义逻辑、直接写实例字典的标准手段。如果你在__setattr__里写self.name = value,会再次调用__setattr__,形成无限递归,这是最经典的血泪经验。参数校验放在写之前,保证非法值根本进不了对象状态。
2.3__delattr__和__call__的定位
__delattr__(self, name)在del obj.x时触发,常用来禁止删除关键字段或做资源清理。__call__(self, *args, **kwargs)让实例本身可调用,写法和普通函数一样,常用于把「带状态的函数」封装成对象。
class Counter: def __init__(self): self.count = 0 def __call__(self, step=1): self.count += step return self.count def __delattr__(self, name): if name == "count": raise AttributeError("count is protected") object.__delattr__(self, name) c = Counter() print(c()) # 1 print(c(5)) # 6 try: del c.count except AttributeError as e: print("blocked:", e)逻辑说明:__call__让c()等价于c.__call__(),实例变成可调用对象,状态存在实例里,比闭包更直观。__delattr__里同样用object.__delattr__做真正的删除,保护字段直接抛异常。
2.4 一张表看清四个钩子的触发条件
| 钩子 | 触发时机 | 典型用途 | 必须注意 |
|---|---|---|---|
__getattr__ | 常规查找失败后 | 懒加载、代理、默认值 | 避免递归、别做重活 |
__setattr__ | 任意属性赋值 | 校验、日志、只读字段 | 用object.__setattr__落地 |
__delattr__ | del obj.x | 保护字段、清理资源 | 用object.__delattr__落地 |
__call__ | obj(...) | 有状态函数、工厂 | 参数签名要清晰 |
这张表建议贴在工位上,写代码前先确认自己到底要拦哪一类操作,选错钩子是后面所有 bug 的源头。
3. 手写一个带校验和日志的配置类:从零到能跑
3.1 先定接口:哪些字段、什么类型、要不要只读
动手前先把契约写清楚,否则__setattr__会越写越乱。假设我们要一个配置类,字段有host(字符串)、port(1 到 65535 的整数)、debug(布尔),其中host一旦设定不允许删除,所有赋值都要打日志。
import logging logging.basicConfig(level=logging.INFO, format="%(message)s") class Config: # 类级别声明字段规则,避免散落在 if 里 _schema = { "host": (str, False), "port": (int, False), "debug": (bool, False), } _protected = {"host"} def __init__(self, **kwargs): for k, v in kwargs.items(): setattr(self, k, v) def __setattr__(self, name, value): if name.startswith("_"): # 内部字段直接放行,避免污染校验逻辑 object.__setattr__(self, name, value) return if name not in self._schema: raise AttributeError(f"unknown config key: {name}") expected, _ = self._schema[name] if not isinstance(value, expected): raise TypeError(f"{name} expects {expected.__name__}") if name == "port" and not (1 <= value <= 65535): raise ValueError("port out of range") object.__setattr__(self, name, value) logging.info("set %s = %r", name, value) def __delattr__(self, name): if name in self._protected: raise AttributeError(f"{name} is protected") object.__delattr__(self, name)逻辑说明:_schema把「字段名 → 类型」集中管理,新增字段只改一处。__setattr__里先放行下划线开头的内部字段,再查 schema、校验类型、做范围检查,最后才真正写入并打日志。__delattr__用_protected集合保护关键字段。参数上,_schema的值是元组,第二个位置留给后续扩展(比如是否必填),现在先占位。
3.2 跑一遍:正常赋值、类型错误、越界、删除保护
cfg = Config(host="127.0.0.1", port=8080, debug=True) # 输出: set host = '127.0.0.1' / set port = 8080 / set debug = True try: cfg.port = "8080" # 类型错误 except TypeError as e: print("TypeError:", e) try: cfg.port = 70000 # 越界 except ValueError as e: print("ValueError:", e) try: cfg.unknown = 1 # 未知字段 except AttributeError as e: print("AttributeError:", e) try: del cfg.host # 删除保护 except AttributeError as e: print("AttributeError:", e)逻辑说明:四类错误分别对应类型、范围、字段白名单、删除保护,覆盖了配置系统最常见的失败模式。注意cfg.port = "8080"抛的是TypeError,因为isinstance("8080", int)为假;而70000类型对但范围错,抛ValueError。把异常类型区分开,调用方才能精准捕获。
3.3 加上__getattr__做默认值兜底
配置类经常需要「没设过就返回默认值」。这时__getattr__正好补位,因为只有查找失败才会进来。
class ConfigWithDefault(Config): _defaults = {"debug": False, "port": 80} def __getattr__(self, name): # 只有实例字典里没有才会走到这里 if name in self._defaults: return self._defaults[name] raise AttributeError(name) c = ConfigWithDefault(host="0.0.0.0") print(c.port) # 80,来自默认值 print(c.debug) # False逻辑说明:__getattr__只在port、debug没被显式赋值时触发,返回类级别默认值。这里没有把默认值写回实例字典,所以每次访问都会走一遍兜底逻辑;如果默认值计算昂贵,应该在首次访问时用object.__setattr__缓存下来。参数上_defaults独立于_schema,职责分离。
3.4 用__call__把配置对象变成可刷新的入口
配置系统常需要「重新加载」。与其加一个reload()方法,不如让实例可调用,语义更顺。
class ReloadableConfig(ConfigWithDefault): def __call__(self, **overrides): for k, v in overrides.items(): setattr(self, k, v) return self cfg = ReloadableConfig(host="0.0.0.0", port=8080) cfg(port=9090, debug=True) # 直接调用刷新 print(cfg.port, cfg.debug) # 9090 True逻辑说明:__call__接收覆盖参数,内部复用setattr,所以校验和日志全部自动生效,不用重写一遍。返回self支持链式调用。参数上**overrides让调用方只写要改的字段,未提及的保持不变,这比整体替换更安全。
4. 避坑与排查:属性钩子最容易翻车的五个地方
4.1 无限递归:self.x = v写进__setattr__
现象:一赋值就RecursionError: maximum recursion depth exceeded。原因:__setattr__里用self.name = value,又触发自己。解决:一律用object.__setattr__(self, name, value)落地,__delattr__同理用object.__delattr__。这是最高频的坑,没有之一。
4.2__getattr__吞掉AttributeError导致hasattr永远为真
现象:hasattr(obj, "anything")返回True,copy、pickle行为诡异。原因:__getattr__对任何名字都返回了值,没抛AttributeError。解决:兜底逻辑只处理白名单字段,其余一律raise AttributeError(name)。hasattr靠异常判断存在性,你吞掉异常它就失真。
4.3__setattr__拦截了内部字段,初始化直接崩
现象:__init__里给self._cache = {}赋值时报「未知字段」。原因:__setattr__没放行下划线开头的内部属性。解决:在__setattr__开头判断name.startswith("_")直接走object.__setattr__。内部状态和业务字段要分开对待,否则每个内部变量都得进 schema。
4.4__call__和普通方法签名冲突
现象:实例既能obj()又有obj.run(),调用时参数对不上。原因:__call__的签名设计得太宽泛,*args, **kwargs全收,错误被吞。解决:给__call__写明确的参数列表,别用万能签名。可调用对象的语义应该和它的职责一致,配置刷新就只收覆盖字段。
4.5 继承链上钩子被覆盖,子类行为突变
现象:父类靠__setattr__做校验,子类重写后校验失效。原因:子类__setattr__没调用super().__setattr__。解决:子类要么显式super().__setattr__(name, value),要么在父类逻辑上扩展。属性钩子是普通方法,继承规则一样适用,别以为它会自动叠加。
5. 进阶技巧:用__slots__配合钩子做内存与安全的双重收口
属性钩子写顺了之后,下一个值得投入的方向是把它和__slots__结合。__slots__限制实例能有哪些属性,还能省掉每个实例的__dict__,在字段多、实例量大的场景下内存收益明显。但它和__setattr__配合时有几个细节,值得单独讲。
先看一个最小组合:
class Point: __slots__ = ("x", "y") def __setattr__(self, name, value): if name not in self.__slots__: raise AttributeError(f"{name} not allowed") if not isinstance(value, (int, float)): raise TypeError("coordinate must be numeric") object.__setattr__(self, name, value) p = Point() p.x = 1 p.y = 2.5 try: p.z = 3 except AttributeError as e: print("blocked:", e)逻辑说明:__slots__声明了x、y,__setattr__里用self.__slots__做白名单,双重保险。注意__slots__本身是类属性,访问它不会触发实例的__setattr__。参数上,__slots__用元组而不是列表,语义更清晰,也避免被误改。
这里有个容易忽略的点:一旦定义了__slots__,实例就没有__dict__,__getattr__的兜底逻辑不能再依赖往__dict__里塞东西。如果你需要缓存计算结果,得在__slots__里预留一个字段,比如_cache,然后用object.__setattr__写进去。
再进一步,把校验规则从硬编码的if抽成数据驱动,配合__slots__就是一个小型 ORM 字段系统:
class Field: def __init__(self, type_, default=None): self.type_ = type_ self.default = default class Model: __slots__ = () _fields = {} def __setattr__(self, name, value): field = self._fields.get(name) if field is None: raise AttributeError(f"no field {name}") if not isinstance(value, field.type_): raise TypeError(f"{name} expects {field.type_.__name__}") object.__setattr__(self, name, value) def __getattr__(self, name): field = self._fields.get(name) if field is not None and field.default is not None: return field.default raise AttributeError(name) class User(Model): __slots__ = ("name", "age") _fields = {"name": Field(str), "age": Field(int, 0)} u = User() u.name = "A" print(u.age) # 0,来自默认值逻辑说明:_fields把字段名映射到Field对象,__setattr__查表校验,__getattr__查表返回默认值。子类用__slots__声明实际存储的字段,父类__slots__ = ()避免重复。参数上Field的default为None表示无默认值,访问时抛AttributeError,保持hasattr语义正确。
验证这套东西是否可靠,我一般会跑三个检查:一是给每个字段赋错误类型,确认抛TypeError;二是访问未赋值但有默认值的字段,确认返回默认值;三是访问完全未声明的字段,确认hasattr返回False。这三条过了,属性层基本就稳了。
最后一个习惯:属性钩子里的逻辑越短越好。校验、日志、回调各占一行,超过二十行的__setattr__就该拆成独立方法。钩子被调用的频率极高,里面每多一个分支,整个对象的每次赋值都要付一次代价。我踩过最深的坑就是在一个__setattr__里做了数据库查询,结果批量创建对象时慢了一个数量级,排查了半天才定位到钩子上。把重活挪到显式方法里,钩子只做轻量拦截,这个方向才值得长期投入。希望帮到你。
本文还有配套的精品资源,点击获取