☰
Python魔法方法:协议接口让自定义类真正融入语言生态
2026/10/1 16:48:54 网站建设 项目流程

如果你写过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 f"Money({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 f"ReadOnlyList({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, items=None): 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 f"Vector({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被重新绑定到了一个新对象上。对于不可变类型这没问题,但对于可变类型、或者持有该对象的其他引用方,这就是个隐蔽bug:

class 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(f"total 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无缝接住,不再需要调用方去猜"应该用哪个方法",魔法方法才真正算掌握了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询