K 的一隅

Python Python 语言核心

运算符和内置函数如何挂钩:常用魔术方法

双下划线方法把 Python 语法与对象行为连起来:__str__、__repr__、__len__、__eq__ 等,重点示例而非穷举清单。

11 分钟阅读 更新于 2026-07-28
本文目录

在交互式里敲 len([1, 2, 3]) 得到 3,背后是列表类型的 __len__ 被调用。用户写的类默认没有这些钩子,所以 len(my_obj) 会报 TypeError——除非你自己实现对应的双下划线方法。

这类方法统称魔术方法(dunder,double underscore)。它们不打算被业务代码直接调用;解释器和内置函数在特定语法下自动查找并触发它们。搞清几个高频 dunder,就能让自定义类型「像内置类型一样」参与 print==len、算术运算等操作,而不必写一堆 to_string()equals() 之类的旁路 API。

Python 没有运算符重载的单独关键字;+==len() 全是「语法 → 查 dunder → 调方法」的统一模型。内置 listint 能做的事,你的类型也能按需挂钩——但只实现你真正需要的几个,比实现整张表更务实。

与 C++ 的 operator 不同,Python 的 dunder 是可选协议:不实现就得不到对应语法,或回退到默认行为(如 __eq__ 缺省时用 is)。这种「显式挂钩」让类型作者控制表面 API,而不必实现几十个 operator 才能编译通过。

reprstr:两种字符串形态

对象转字符串有两条路径,服务不同读者:

  • __repr__:给开发者看,目标是尽量明确、可反推对象内容;官方建议 ideally 能 eval(repr(obj)) 重建(不总能做到)。
  • __str__:给终端用户看,print(x)str(x) 用它;若未定义,回退到 __repr__
python
class Vector:
    __slots__ = ("x", "y")

    def __init__(self, x: float, y: float) -> None:
        self.x = x
        self.y = y

    def __repr__(self) -> str:
        return f"Vector({self.x!r}, {self.y!r})"

    def __str__(self) -> str:
        return f"({self.x}, {self.y})"


v = Vector(1.0, 2.0)
print(repr(v))  # Vector(1.0, 2.0)
print(str(v))     # (1.0, 2.0)
print(v)          # (1.0, 2.0)  — print 用 __str__

容器展示元素、异常信息、调试输出里,常走 __repr__。面向用户的格式化走 __str__。团队约定:至少把 __repr__ 写准确,日志和 REPL 会舒服很多。

f"{v}" 默认调 __str__f"{v!r}"__repr__。在日志里打印未知对象时,用 %r!r 往往比 str() 更安全——即便 __str__ 写坏了,__repr__ 仍可能可用。

len:对接 len() 与 truthiness

实现 __len__ 后,len(obj) 可用。若类未定义 __bool____len__ 还影响布尔值——长度为 0 为假,非 0 为真(与空容器直觉一致)。

python
class Playlist:
    def __init__(self, tracks: list[str]) -> None:
        self._tracks = list(tracks)

    def __len__(self) -> int:
        return len(self._tracks)


pl = Playlist(["a", "b"])
print(len(pl))              # 2
print(bool(pl))             # True
print(bool(Playlist([])))   # False

不要指望 __len__ 返回负数或抛异常来「表示无效」——调用方假定它返回非负整数。需要「有没有有效数据」另写属性或 __bool__

eq:== 比较;别和 is 混

a == b 调用 a.__eq__(b)(或对称的 b.__eq__(a) 回退)。未定义 __eq__ 时回退到身份比较(等价于 is)。定义了 __eq__ 却不定义 __hash__ 时,实例默认 __hash__ = None,不可哈希——不能放进 set / 当 dict 键。

python
class Money:
    __slots__ = ("amount", "currency")

    def __init__(self, amount: int, currency: str = "CNY") -> None:
        self.amount = amount
        self.currency = currency

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Money):
            return NotImplemented
        return self.amount == other.amount and self.currency == other.currency


a = Money(100)
b = Money(100)
print(a == b)                            # True
print(a is b)                            # False
print(Money(100) == Money(100, "USD"))   # False

对非预期类型返回 NotImplemented,让 Python 尝试反向比较或通知「无法比较」。若还要当 dict 键,需同时实现一致的 __hash__(且不可变字段)。

is 比较身份,永远不走 __eq__。值相等与同一对象是两回事:两个内容相同的 Money 实例 == 为真,但 is 为假。容器成员检测 x in bag 则依赖元素的 __eq__(或 __contains__),与 is 无关。

运算符挂钩:以 add 为例

a + b 尝试 a.__add__(b),若返回 NotImplemented 再试 b.__radd__(a)。这种「先左后右」规则让自定义类型与内置数字、字符串共存:

python
class Tag:
    def __init__(self, label: str) -> None:
        self.label = label

    def __add__(self, other: "Tag") -> "Tag":
        return Tag(f"{self.label}+{other.label}")

    def __repr__(self) -> str:
        return f"Tag({self.label!r})"


print(Tag("py") + Tag("dev"))  # Tag('py+dev')

同类还有 __sub____mul____lt__(配合 sorted)等——不必死记整张表,需要时查 数据模型文档

反向方法 __radd__ 在左操作数不支持时启用,使 3 + MyInt(1) 有机会成功。数值类型扩展库常成对实现 __add____radd__。若两边都返回 NotImplemented,解释器抛出 TypeError,而不是静默失败。

容器协议:getitemiter

让对象支持下标和 for 循环,不必继承 list

python
class Row:
    def __init__(self, cells: list[str]) -> None:
        self._cells = cells

    def __getitem__(self, index: int) -> str:
        return self._cells[index]

    def __iter__(self):
        return iter(self._cells)


row = Row(["id", "name"])
print(row[1])      # name
print(list(row))   # ['id', 'name']

实现 __getitem__ 还能让对象支持切片(若 index 是 slice)。__iter__ 应返回迭代器;简单场景 return iter(self._cells) 即可。

配合 __contains__ 可自定义 in 运算符;不定义时,in 会尝试迭代并比较元素。容器类通常实现 __iter____len__ 之一或两者,就能覆盖大部分使用场景。

数值转换:intfloat

需要把对象传给 int()float() 或格式化时,可实现对应转换方法:

python
class Percent:
    def __init__(self, ratio: float) -> None:
        self.ratio = ratio

    def __float__(self) -> float:
        return self.ratio

    def __repr__(self) -> str:
        return f"Percent({self.ratio})"


p = Percent(0.25)
print(float(p))       # 0.25
print(f"{float(p):.0%}")  # 25%

这类方法应返回内置数值类型,而不是再包一层自定义类,否则嵌套转换容易失控。

上下文与调用

  • with obj:__enter__ / __exit__(上下文管理器,后面专章展开)
  • obj()__call__(上一篇已讲可调用对象)

__enter__ 返回值常绑定到 as 变量;__exit__ 接收异常信息,返回真可压制异常。

上下文管理器与魔术方法的关系:资源管理(文件、锁)靠 with,背后仍是 dunder 协议。协程的 async with 则用 __aenter__ / __aexit__,属于异步专章内容,此处知道「还有异步版本」即可。

常见误解

误解实际
应该实现所有 dunder只实现语法会用到的;缺省则回退或 TypeError
__str____repr__ 写一个就够服务场景不同;调试靠 repr,展示靠 str
__eq__ 会自动生成 hashPython 3 中定义 __eq__ 往往使对象不可哈希
直接调 obj.__eq__(other)应使用 ==,让 Python 处理 NotImplemented 与反向比较
实现了 __len__ 就必须实现 __bool__二者可选;有 __bool__ 时优先于 __len__ 决定 truthiness

小实验:缺省行为

python
class Plain:
    pass


p = Plain()
print(p == Plain())   # False — 不同实例,默认比身份
print(bool(p))        # True — 无 __len__/__bool__,非空对象即为真

Plain 没有任何 dunder,仍参与 ==bool()——靠的是默认规则。加上 __eq__ 后相等语义变了,hash 规则也可能跟着变,改之前想清楚调用方是否依赖旧行为。

文档字符串里的「Supported operations」小节可以列出实现了哪些 dunder,比贴一张完整表格更友好。维护者一眼能看到类型是否支持 ==、是否可哈希、能否 len()

第三方库 attrsdataclasses 替你生成部分 dunder(如 __repr____eq__)。手写类时参考它们生成的样式,保持项目内对象表示一致,减少「有的类 repr 详细、有的简陋」的割裂感。

boolhash:常与 eq 联动

自定义「空 / 满」语义时,可实现 __bool__ 覆盖默认的 __len__ 规则:

python
class Connection:
    def __init__(self, open: bool) -> None:
        self.open = open

    def __bool__(self) -> bool:
        return self.open


c = Connection(True)
print(bool(c))  # True

若定义了 __eq__ 且希望实例可哈希(当 dict 键、set 元素),须保证:相等对象 hash 相等,且参与比较的字段在对象生命周期内不变:

python
class Point:
    __slots__ = ("x", "y")

    def __init__(self, x: int, y: int) -> None:
        self.x = x
        self.y = y

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Point):
            return NotImplemented
        return (self.x, self.y) == (other.x, other.y)

    def __hash__(self) -> int:
        return hash((self.x, self.y))


p1 = Point(1, 2)
p2 = Point(1, 2)
print(p1 == p2)           # True
print({p1, p2})           # 一个元素
print({p1: "a"}[p2])      # a

只写 __eq__ 不写 __hash__ 时,Python 3 会把 __hash__ 置为 None,实例自动不可哈希——这是常见默认,别与「忘了写 hash」混淆。

比较与排序:lt

要让 sorted(items) 按自定义规则排,实现 rich comparison 之一即可(通常 __lt__),并配合 @functools.total_ordering 生成其余比较:

python
from functools import total_ordering


@total_ordering
class Score:
    def __init__(self, value: int) -> None:
        self.value = value

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Score):
            return NotImplemented
        return self.value == other.value

    def __lt__(self, other: "Score") -> bool:
        return self.value < other.value


print(sorted([Score(3), Score(1), Score(2)], key=lambda s: s))

==< 挂钩不同语法:==__eq__<__lt__,sort 还会用 < 比较元素。

容器与成员:contains

x in obj 优先找 __contains__;没有则回退到迭代并比较。自定义集合时可显式实现,避免低效扫描:

python
class TagSet:
    def __init__(self, tags: set[str]) -> None:
        self._tags = tags

    def __contains__(self, item: object) -> bool:
        return isinstance(item, str) and item in self._tags

    def __repr__(self) -> str:
        return f"TagSet({self._tags!r})"


ts = TagSet({"py", "dev"})
print("py" in ts)    # True
print("java" in ts)  # False

调试建议:从 repr 开始

新类型优先实现 __repr__,再看 API 是否参与 ==len。REPL 里 repr(obj) 能立刻反映状态,比自定义 print 格式更通用。Code review 里自定义类缺 __repr__ 时,补一行往往是最划算的维护投资。

若类型会进日志、监控或错误报告,稳定的 __repr__ 比花哨的 __str__ 更重要——运维同事在 grep 日志时依赖可预测的格式。__str__ 可以随产品文案调整,__repr__ 宜保持稳定、可机器解析。

与内置类型的协作

自定义容器若只包装 listdict,可考虑 __getitem__ / __iter__ 委托给内部对象,而不是重新实现整套协议。collections.abc 里的抽象基类可用来文档化「本类支持哪些协议」,运行时注册也依赖相应的 dunder。

序列化与 pickle 的提示

某些 dunder(如 __getstate__ / __setstate__)影响 pickle,属于进阶话题。日常 API 设计先保证 __repr____eq__ 语义清晰;若对象要跨进程传递,再单独查 pickle 协议与 __reduce__。多数业务类型不必一开始就实现这一组。

调用协议回顾

__call__ 让实例像函数一样被调用;它与 __getitem__ 不同——后者对应 obj[key] 下标语法。Callable 与 Sequence/Mapping 在 collections.abc 里分开抽象;一个类可以同时实现 __call____getitem__,分别服务两种语法。

实现 dunder 时尽量返回 NotImplemented 而不是抛 TypeError,除非确定没有回退路径——这与 __eq__、算术运算的协作规则一致。抛错会截断 Python 尝试 __radd__ 或反向比较的机会。

怎么选:少而准

你想让对象支持…优先实现
调试打印__repr__
用户可读输出__str__
len() / 空判断__len____bool__
值相等__eq__(注意 hash 语义)
算术 / 比较运算符对应 __add__
下标 / 迭代__getitem__ / __iter__

不必实现整张 dunder 表。先写 __repr__ 和与 API 直接相关的两三个,再按需扩展。

若你来自 Java,可把 dunder 类比为「运算符与内置函数调用的接口方法」——但 Python 不强制实现接口,缺了就不支持对应语法。若你来自 Ruby,则类似 to_s==,只是 Python 用固定方法名与解释器硬连线。

扩展已有类型时,优先用组合加转发(wrapper 把 __getitem__ 委托给内部 list),而不是继承内置类型再 override——内置类型的 dunder 交互复杂,继承 list/dict 常踩坑。需要「像一个 list」时,实现协议并持有私有 list 往往更干净。

写单元测试时,可对 dunder 做表驱动:cases = [(lambda o: o + o, expected), ...],避免每个运算符复制粘贴断言。重点覆盖 __eq____repr__,它们最容易被业务逻辑改坏而不自知。

回顾本篇四个核心:__repr__ 服务调试,__str__ 服务展示,__len__ 挂钩长度与空判断,__eq__ 挂钩值相等——其余 dunder 按 API 需要再加,不必一次凑齐整张清单。把协议当成可选能力表,而不是待办 checklist。下一篇描述符会接管「点号访问」这条线,与本篇的运算符、内置函数挂钩形成互补。

魔术方法是 Python 语法到底层协议的桥梁:print__str__,调试找 __repr__len()__len__==__eq__。其余 dunder 在用到时再查官方数据模型文档即可——重点深讲胜过 endless list。