ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python魔法方法:协议接口让自定义类真正融入语言生态

Python魔法方法:协议接口让自定义类真正融入语言生态 如果你写过Python类大概率遇过这个场景自定义一个对象print出来是__main__.User object at 0x7f...想用len()取长度报错想比较两个对象大小也报错放进set里更是直接摔跟头。于是你上网搜怎么让Python对象打印出来好看一点找到__str__和__repr__装上之后对象变好看了但你对魔法方法的理解可能也就止步于此。我最早也是这样直到后来要做一个自定义缓存容器才发现魔法方法根本不是让对象变好看的装饰性语法而是Python解释器给你留好的一整套协议接口。类一旦接上这些协议就能享受语言自带的一切便利for迭代、in判断、切片索引、算术运算、with上下文管理……你的类才算真正融入了Python生态而不是一个处处要手动调方法的二等公民。这篇文章我会从协议的角度把魔法方法重新拆一遍不只是列方法清单而是讲清楚解释器在什么时机调用它们、每个协议怎么组合最优雅、以及我在实际项目里踩过的那些坑。1. 魔法方法不是语法糖而是协议接口1.1 解释器在什么时候调用这些双下划线方法很多人把魔法方法理解成特殊的方法名但更准确的说法是魔法方法是Python语法层面的协议接口。当你写a b时编译器并不会简单地把两个变量相加而是先尝试调用a.__add__(b)如果a不支持这个运算再回头尝试b.__radd__(a)。print(obj)会去调obj.__str__()obj[key]会去调obj.__getitem__(key)for x in obj会去调iter(obj)然后走obj.__iter__()或obj.__getitem__()。这意味着你用中缀运算符、方括号、with语句、print函数写出来的代码本质上都是一次隐藏的方法调用。解释器把这些调用点都预留好了你只需要在自己的类里把对应的魔法方法实现出来就能让自定义对象和内置对象享受同样的语法待遇。一个特别重要的细节是魔法方法是在类上查找的不是在实例上查找的。也就是说就算你给某个实例单独挂了一个__add__属性a b也不会调用它解释器只认type(a).__add__。这和我下面要讲的所有坑都有关系先记住这条底层规则。1.2 先记住三组最常用的repr / str / format这三者是对象字符串化的核心协议初学者最容易搞混的是它们的优先级。repr(obj)调用__repr__力求返回一个无歧义、最好能用来重建对象的字符串。print(obj)、str(obj)调用__str__返回一个可读性好、给人看的字符串。如果__str__没定义str(obj)会退回去用__repr__的结果。f{obj}和format(obj, spec)优先走__format__没定义__format__时走__str__最后兜底__repr__。我建议在一个类里至少实现__repr__因为它承担了交互式环境、日志、异常消息里的全部展示工作。__str__是给人看的更友好的版本可以单独做。__format__适合需要处理格式说明符的场景比如数字、对齐普通类一般用不到。看一个最简例子class Money: def __init__(self, amount): self.amount amount def __repr__(self): return fMoney({self.amount}) def __str__(self): return f${self.amount} def __format__(self, spec): if spec cny: return f¥{self.amount} return str(self) m Money(42) repr(m) Money(42) print(m) $42 f{m:cny} ¥42写__repr__的时候经验法则是让返回的字符串能直接喂给eval()重建对象。我见过很多人写成Money object这虽然不报错但调试时会让你非常痛苦因为所有日志里都看不到任何一个字段的值。2. 容器协议让你的类像list和dict一样被使用2.1 一个能跑起来的最迷你序列只需要__len____getitem__这是我很想强调的一点让一个自定义类支持len(obj)、obj[i]、for x in obj和x in obj并不是非得实现五个方法。旧式序列协议里Python允许你只实现__getitem__就能自动获得迭代和成员测试的能力。只要定义了__getitem__解释器会从索引0开始不断调用obj[0]、obj[1]、obj[2]……直到抛出自定义IndexError这个失败信号就是迭代终止的条件。同样的机制也支持in运算。但这种方式是逐个尝试索引效率偏低所以更规范的姿势是同时提供__iter__。先看一个最小实现class ReadOnlyList: def __init__(self, items): self._items list(items) def __len__(self): return len(self._items) def __getitem__(self, index): return self._items[index] def __repr__(self): return fReadOnlyList({self._items!r})这个类写了__len__和__getitem__之后立刻就能支持 rl ReadOnlyList([1, 2, 3]) len(rl) 3 rl[1] 2 rl[1:] # 切片会作为 slice 对象传给 __getitem__ [2, 3] for x in rl: ... print(x) 1 2 3 2 in rl True这里有个隐藏细节rl[1:]会自动构造一个slice(1, None)对象传给self._items[index]因为底层list已经会处理slice所以切片天然可用。如果你在__getitem__里写的是return self._items[index]就免费获得了切片支持如果你用self._items[index.value]之类的自定义逻辑就必须自己处理slice类型否则会报错。2.2 迭代器协议__iter__和__next__怎么配合真正高效的迭代来自__iter__协议。for x in obj做的事情是调用iter(obj)即type(obj).__iter__(obj)拿到一个迭代器对象然后循环调用迭代器的__next__()直到StopIteration。初学者最容易犯的错误是在自定义类里把__iter__写成return self然后顺手也把__next__写在同一个类里。这样确实能跑但你的类变成了一个有状态的迭代器一旦某个for循环把索引推进到末尾这个对象就彻底耗尽了后续再迭代就得不到任何数据。比如这样class BadCounter: def __init__(self, limit): self.limit limit self.current 0 def __iter__(self): return self def __next__(self): if self.current self.limit: raise StopIteration self.current 1 return self.current - 1 c BadCounter(3) list(c) [0, 1, 2] list(c) # 第二次就空了 []把迭代器和可迭代对象混为一谈是容器类设计里最常见的坏味道。我推荐的做法是容器类实现__iter__()返回一个独立的迭代器对象如果实在图方便可以用生成器函数class GoodCounter: def __init__(self, limit): self.limit limit def __iter__(self): for i in range(self.limit): yield i这样每次iter(c)都会创建一个全新的生成器迭代状态互不干扰多次for循环都能正常工作。2.3 补上可变容器__setitem__、__delitem__、__contains__如果你希望自定义容器支持赋值和删除就得实现__setitem__和__delitem__。加上__contains__能让in的判断更高效、更精确不实现__contains__时Python会退化到用__iter__或__getitem__逐个比较性能差一些。一个可变序列的补齐案例class MutableList: def __init__(self, itemsNone): self._items list(items or []) def __len__(self): return len(self._items) def __getitem__(self, index): return self._items[index] def __setitem__(self, index, value): self._items[index] value def __delitem__(self, index): del self._items[index] def __contains__(self, item): return item in self._items实现__setitem__之前要有心理准备赋值操作obj[i] v会立刻调用它所以任何前置校验类型检查、边界检查、触发缓存失效都应该放在这里。我在一个缓存项目里就是靠__setitem__统一做写入后同步更新索引表这件事避免调用方各自为政。3. 运算重载让对象支持加减乘除3.1 实现一个像样的Vector类魔法方法里最容易让人耳目一新的是算术运算的重载。你完全可以让一个二维向量类支持v1 v2、v * 2、abs(v)、v1 v2之类的操作。class Vector: def __init__(self, x, y): self.x x self.y y def __repr__(self): return fVector({self.x!r}, {self.y!r}) def __add__(self, other): if not isinstance(other, Vector): return NotImplemented return Vector(self.x other.x, self.y other.y) def __sub__(self, other): if not isinstance(other, Vector): return NotImplemented return Vector(self.x - other.x, self.y - other.y) def __mul__(self, scalar): if not isinstance(scalar, (int, float)): return NotImplemented return Vector(self.x * scalar, self.y * scalar) def __rmul__(self, scalar): return self.__mul__(scalar) def __abs__(self): return (self.x ** 2 self.y ** 2) ** 0.5 def __lt__(self, other): if not isinstance(other, Vector): return NotImplemented return abs(self) abs(other)注意我在__add__里用了return NotImplemented而不是直接抛异常。这是协议的标准写法返回NotImplemented告诉解释器我不支持这种类型的加法解释器会继续尝试对方的__radd__如果最终还是不行才抛TypeError。如果这里直接raise TypeError就断送了另一个类型提供反向运算的机会。3.2 反向运算与原地运算的边界反向运算的调用时机很多人搞不清楚。比如上面的__rmul__它处理的是2 * v这种场景。执行2 * v时Python先试(2).__mul__(v)左操作数int发现自己不认识Vector类型返回NotImplemented接着解释器再试v.__rmul__(2)于是我们实现了这个协议数乘就有了。这里有一个类型优先级的细节如果左操作数是右操作数的子类Python会优先调用子类的运算方法。也就是说issubclass(type(a), type(b))为真时a.__add__(b)会比b.__radd__(a)更优先。这条规则为了支持子类覆盖父类的行为自己实现运算重载时碰到的概率不高但遇到诡异的为什么走的是这个方法时往这个方向排查就对了。原地运算和*对应__iadd__、__imul__。如果类没实现__iadd__a b会退化成a a.__add__(b)也就是说a被重新绑定到了一个新对象上。对于不可变类型这没问题但对于可变类型、或者持有该对象的其他引用方这就是个隐蔽bugclass LazyList: def __init__(self): self.data [] def __add__(self, other): # 每次 都新建对象 new LazyList() new.data self.data other return new如果你写了这种类又没有__iadd__那么lst [4]之后原对象没有任何变化而所有还指向旧对象的引用都看丢了新增数据。正确做法是可变对象应该实现__iadd__在内部就地修改并return self。3.3 比较运算和__hash__牵一发动全身比较运算__eq__是个大坑因为它决定了对象在set、dict里的行为。Python的规定是两个相等的对象哈希值必须相等。默认情况下对象的哈希值来自id()默认相等也是id()比较所以自洽。一旦你自定义了__eq__比如按内容比较默认的等值规则就变了哈希如果还是基于id()两个内容相同的对象就可能哈希值不同放进set会出现看起来相同却存了两份的诡异结果。因此Python在3.0之后做了一个看似粗暴但完全正确的决定类中定义了__eq__却没有同时定义__hash__那么__hash__会被置为None这个类的实例直接不可哈希。class User: def __init__(self, name): self.name name def __eq__(self, other): return isinstance(other, User) and self.name other.name # 不写 __hash__User 实例就不能放进 set/dict 的 key u1 User(alice) hash(u1) TypeError: unhashable type: User解决办法是如果你希望对象可以放进set或作为dict的键同时__eq__按某个不可变字段比较那一定要补一个和它匹配的__hash__class User: def __init__(self, name): self.name name def __eq__(self, other): return isinstance(other, User) and self.name other.name def __hash__(self): return hash(self.name)反过来如果你的对象是可变的比如属性会变我的建议是根本不要实现__hash__让它保持不可哈希。因为可变对象放进set之后一旦关键字段被改掉哈希值跟着变化set内部索引就乱了后续查找会直接失败。这个bug极难排查我吃过一次大亏后来凡是做了__eq__的模型类我都要问自己一句这对象到底可不可变可不可哈希4. 属性访问机制getattr和setattr真正的区别4.1__getattribute__、__getattr__、__setattr__的执行顺序属性访问相关的魔法方法很多教程是混着讲的但它们的触发时机完全不同。__getattribute__(self, name)所有属性访问都会先经过它不管属性存不存在。它是最底层的拦截点。__getattr__(self, name)只有当正常的属性查找流程全部失败、即将抛出AttributeError时才会被调用。它是最后补救的钩子。__setattr__(self, name, value)每次属性赋值都会调用。__delattr__(self, name)每次del obj.name都会调用。正常查找流程是__getattribute__先接管内部会依次检查实例字典、类字典、非数据描述符等如果都找不到并且类里定义了__getattr__就把控股权交给它。举个实际例子一个懒加载属性第一次访问时才计算算完缓存到实例字典里后续访问就走正常路径class LazyField: def __getattr__(self, name): if name heavy: value compute_heavy() self.__dict__[heavy] value return value raise AttributeError(name)注意self.__dict__[heavy] value这里不能写成self.heavy value否则会再进__setattr__虽然是安全的但如果你的__setattr__里做了别的逻辑就可能多触发一次副作用。4.2 用__getattr__实现属性委托和代理属性委托是一个非常实用的模式你有一个内部对象想把不存在的属性转发给它。class Proxy: def __init__(self, target): self._target target def __getattr__(self, name): return getattr(self._target, name) def __setattr__(self, name, value): if name _target: object.__setattr__(self, name, value) else: setattr(self._target, name, value)__setattr__这里有个大坑如果你写self._target value会再次触发__setattr__无限递归下去。所以对特殊字段要绕开拦截直接用object.__setattr__(self, name, value)。我在实现配置代理类的时候第一次就栽在这儿递归爆栈报错直接把我整懵了。记住这一条凡是重写了__setattr__就必须用object.__setattr__绕过它处理内部字段。4.3 描述符协议管理类属性访问的底层机制当你看到property、classmethod、staticmethod这些内置装饰器时它们背后都是描述符协议__get__(self, instance, owner)、__set__(self, instance, value)、__delete__(self, instance)。Python 3.6之后还有__set_name__(self, owner, name)。描述符最典型的场景是做一个带类型校验的属性class PositiveNumber: def __set_name__(self, owner, name): self._name name def __get__(self, instance, owner): if instance is None: return self return instance.__dict__[self._name] def __set__(self, instance, value): if value 0: raise ValueError(f{self._name} must be positive) instance.__dict__[self._name] value配合使用class Order: count PositiveNumber() order Order() order.count 10 # 正常 order.count -1 # ValueError: count must be positive描述符协议比较底层普通项目里用property就足够了但理解了它你就能明白property内部的实现机制遇到为什么属性赋值的校验没生效这类问题时排查方向会清晰很多。5. 高级魔法方法让对象参与语言更复杂的行为5.1__call__把实例当函数用任何实现了__call__的类它的实例都可以像函数一样被调用。这个能力非常实用尤其是需要携带状态的回调场景。比如一个带记忆的加法器class CounterFn: def __init__(self): self.calls 0 def __call__(self, *args): self.calls 1 print(ftotal calls: {self.calls}) return sum(args) fn CounterFn() fn(1, 2) # total calls: 1 fn(4, 5) # total calls: 2__call__在装饰器、策略模式、依赖注入容器里都很好用。你甚至可以把它理解成持有配置的函数创建一个类在__init__里存配置在__call__里执行逻辑调用方拿到的就是一个干净可调用的对象不用关心内部状态管理。5.2__enter__/__exit__自写上下文管理器with语句的协议在Python进阶里是必修课。__enter__的返回值会绑定给as后面的变量__exit__在代码块结束后被调用它的三个参数分别是异常类型、异常值和traceback。我要强调一个很多人不知道的细节__exit__返回True表示异常已经被我处理了不要往外抛。如果你只想知道异常发生但不想吞掉它__exit__应该返回False或None。一个计时器的例子class Timer: def __enter__(self): self.start time.perf_counter() return self def __exit__(self, exc_type, exc_value, tb): self.elapsed time.perf_counter() - self.start if exc_type is not None: return False # 不吞异常with Timer() as t: do_something() print(t.elapsed)如果你只是想快速搞一个上下文管理器不想写类可以用contextlib.contextmanager配合生成器。它是__enter__/__exit__的上层封装日常脚本里更简洁。但核心的异常语义还是要理解不然生成器里的try/finally处理时机容易出错。5.3__new__的用武之地单例与不可变类很多人以为__init__是构造函数其实它是初始化函数真正的实例分配发生在__new__。__new__(cls, ...)返回一个新对象然后解释器把实例和参数传给__init__如果__new__返回的对象不是cls的实例__init__根本不会被调用。__new__最常见的用途是单例模式class Singleton: _instance None def __new__(cls): if cls._instance is None: cls._instance super().__new__(cls) return cls._instance另一个场景是继承不可变类型。假设你要一个只能接受int元素的tuple子类就得在__new__里校验因为tuple对象创建之后就无法修改class IntTuple(tuple): def __new__(cls, iterable): for item in iterable: if not isinstance(item, int): raise TypeError(only int allowed) return super().__new__(cls, iterable)记住__new__的调用优先级它发生在实例创建阶段比__init__更早任何在对象诞生前就要拦截逻辑的需求都应该落到__new__而不是__init__。6. 实战踩坑魔法方法最容易翻车的地方6.1 在__setattr__里写self.x value导致无限递归这是重写__setattr__时最经典的翻车。你以为在给实例的字段赋初值实际上每写一次self.x ...都会再触发一次__setattr__于是无限循环下去直到RecursionError。正确的写法是内部字段用object.__setattr__(self, name, value)绕过重写逻辑或者把所有字段存进实例字典self.__dict__。我在写代理类时专门加了一条测试来防止回归后来还发现一个变种有人想用__setattr__做属性名映射结果映射逻辑里又写了原始属性名依然递归。总之只要重写了__setattr__每个赋值语句都要过一遍脑子这个语句会不会再次触发我6.2 定义了__eq__却丢了__hash__前面详细讲过这个这里列一个典型的看起来很对但会炸的代码class Item: def __init__(self, sku, price): self.sku sku self.price price def __eq__(self, other): return isinstance(other, Item) and self.sku other.sku # 没有 __hash__Item 不可哈希然后你去set里收藏一批Item直接TypeError。解决方式是__hash__ hash(self.sku)并且最好把sku设计成不可变字段。千万别为了消除报错随手写成__hash__ object.__hash__那会让set出现重复元素且行为诡异比报错更难查。6.3__getitem__的切片与越界处理自定义容器时我最常见到的bug是__getitem__里忘了处理slice对象。Python的切片语法obj[1:3]并不会帮你拆开而是构造一个slice(1, 3)传给方法。如果你在__getitem__里直接做start, end index信不信马上炸。解决方式很简单要么把slice交给内部真实序列去处理如上文ReadOnlyList那样直接self._items[index]要么显式判断def __getitem__(self, index): if isinstance(index, slice): return [self._items[i] for i in range(*index.indices(len(self._items)))] return self._items[index]越界处理上越界时应该抛IndexError自定义容器如果抛别的异常会导致那些依赖IndexError来判断迭代结束的机制比如旧式迭代协议全部失效。6.4 bool判断到底走哪个方法__bool__、__len__的优先级最后一个小坑bool(obj)的判定顺序是__bool__-__len__- 默认True。对容器类来说经常有人定义__len__来判断是否为空这时候if obj:已经可以正确工作了不需要额外写__bool__。但如果你两个都定义了__bool__优先所以要注意别让两个方法的结果逻辑不一致。class DelayQueue: def __init__(self): self._items [] def __len__(self): return len(self._items) def __bool__(self): # 队列为空时返回 False return len(self._items) 0如果__bool__和__len__语义上冲突if判断的结果会让你一脸懵。我通常只依赖__len__尽量不重复定义__bool__除非有队列里全是过期数据也算空这类自定义语义。写到这里回头看我在项目里的用法最值钱的体会其实就一句话不要把魔法方法当成一份可选的特殊功能清单而是把自定义类放进Python的协议生态里想问题——你的对象需要支持哪些语法行为就去实现哪些协议接口。做容器就老老实实补__len__/__getitem__/__setitem__做数值类型就认真处理__add__/__radd__/__iadd__的配合做管理类对象就设计好属性访问和上下文协议。等你哪一天发现自己写的类可以被for、in、with、、print无缝接住不再需要调用方去猜应该用哪个方法魔法方法才真正算掌握了。
返回列表