AI 技术博客
返回首页
Python 基础 30 分钟阅读

魔法方法:__str__/__repr__/__eq__ 等协议

Python面向对象魔法方法魔术方法协议

引言

第八章到目前为止,你已经认识了类与实例(第 1 篇)、__init__ 与实例属性(第 2 篇)、方法与 self(第 3 篇)、封装与 property(第 4 篇)、继承(第 5 篇)、多态(第 6 篇)。这些篇章回答的都是「我们自己怎么组织对象」;本篇换一个方向,回答「Python 解释器怎么看待我们的对象」。

细想一个问题:内置的列表能 len(x)、能 x[0]、能被 for 遍历、能被 print 打印、能被 sorted 排序,这些能力并不是 list 类里写了几个特殊的方法名那么简单——len() 凭什么知道一个对象「有多长」?答案是协议(protocol):Python 规定了一批以双下划线开头结尾的方法,内置函数和语法操作会在幕后主动调用它们。你只要实现协议,你的类就立刻获得与内置类型同等的能力。这类方法英文俗称 dunder 方法(double underscore,双下划线),中文常译作魔法方法、魔术方法或特殊方法。

本篇用九个可独立运行的小程序实测最常用的协议:__repr__ 与 __str__ 的开发者/用户视角分工、__eq__ 与 __hash__ 的联动陷阱、__lt__ 与排序、__len__/__getitem__ 的序列协议、__call__ 的可调用对象、__enter__/__exit__(呼应第四章第 9 篇的上下文管理器),以及 __bool__ 的真值测试。所有输出均基于本机 CPython 3.12.10(Windows)实测。读完本篇,你将拿到下一篇文章《实战:图书管理系统》的工具箱——那套系统里会出现 __repr__、__eq__、property 的协同作战。阅读前提:第八章第 1-6 篇的内容,以及第四章的异常处理语法。

概念与原理

特殊方法:语言与对象之间的接口

先厘清概念。普通方法是我们主动调用:obj.method()。特殊方法则相反——解释器遇到语法或内置函数时,主动在对象的类型上查找这些方法。例如执行 print(obj) 时,解释器会在 type(obj) 上找 __str__;执行 len(obj) 时找 __len__。这意味着两点。

第一,特殊方法的存在改变了「语法」的含义。for 循环、方括号索引、==、sorted()、print() 在 Python 里都不是魔法,它们只是带有统一约定的调度点:语法负责发起调用,语义交给对象自己定义。这也正是第 6 篇「多态」的延续——len 可以对字符串、列表、字典、元组乃至你自己的类一视同仁,只要对方实现了 __len__。

第二,查找发生在类上,而不是实例上。obj.__len__ = lambda: 100 这种给实例动态挂方法的行为,不会影响 len(obj) 的结果,因为内置函数走的查找路径是 type(obj).__len__(obj),实例字典里的同名属性根本不在路径上。这个细节解释了为什么给实例「加塞」一个特殊方法往往无效。

把协议想成插座标准:插座规定了火线、零线、地线的位置,任何按标准生产的电器插上去都能通电。Python 内置函数就是插座,你的类就是电器——只要实现标准接口(协议),就能接入 Python 的整个生态。协议的数量很多(加减乘除、位运算、索引、迭代、比较、哈希、上下文管理……),本篇只挑最常用的一批实测,但「查找发生在类型上」「语法是调度的外壳」这两条原理对所有协议都成立。

两种视角:开发者与用户

__repr__ 与 __str__ 的分工是整个章节最容易混淆、也最值得讲透的一对。设计哲学很简单:同一个对象有两种自我介绍,分别服务两类观众。

__repr__(representation,表示)面向开发者:要求无歧义(unambiguous)、信息完整,理想情况下 repr(x) 的结果直接贴回代码就能重建一个等价对象(eval 可还原),因此它的默认形式 Point(3, 4) 看起来像构造函数的调用。调试时你看到的全是它。__str__(string,字符串)面向最终用户:要求可读(readable),像人话,比如时间对象显示 2026-09-03 10:30:00 而不是充满构造参数的形式。

调用路径上:str(obj)、print(obj)、f-string 插值走 __str__;交互式 REPL 单独输入表达式回车时显示的是 __repr__ 的结果,容器(列表、字典、集合)打印内部元素用的也是 __repr__。所以 print([obj]) 和 print(obj) 常常显示不同——这不是 bug,是两个视角。下面逐一实测。

操作与实现

repr 与 str:两个视角的分工

先定义同时实现两者的类,观察四个打印入口各走哪条路:

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __repr__(self):
        return f"Point({self.x}, {self.y})"

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

p = Point(3, 4)
print("str():", str(p))
print("repr():", repr(p))
print("print 输出:", p)
print("放进列表显示:", [p, Point(5, 6)])

实际运行输出:

str(): (3, 4)
repr(): Point(3, 4)
print 输出: (3, 4)
放进列表显示: [Point(3, 4), Point(5, 6)]

同一个 Point(3, 4):print 和 str() 显示简洁的坐标形式,这是用户视角;repr() 与列表内部元素显示 Point(3, 4),这是开发者视角——它完整保留了类型与构造参数,调试时可以无歧义地重建对象。

如果类只实现 __repr__ 不实现 __str__,会发生回退(fallback):str() 找不到 __str__ 就直接借用 __repr__ 的结果;两个都不实现时,用 object 基类的默认实现,也就是「类型名 + 内存地址」:

class OnlyRepr:
    def __init__(self, name):
        self.name = name

    def __repr__(self):
        return f"OnlyRepr({self.name!r})"

class Plain:
    def __init__(self, name):
        self.name = name

o = OnlyRepr("小明")
print("repr(o):", repr(o))
print("str(o) 回退:", str(o))   # 没有 __str__ 时退回 __repr__
print("print(o):", o)

pl = Plain("小红")
print("repr(pl):", repr(pl))    # 默认 object 实现
print("str(pl):", str(pl))

实际运行输出:

repr(o): OnlyRepr('小明')
str(o) 回退: OnlyRepr('小明')
print(o): OnlyRepr('小明')
repr(pl): <__main__.Plain object at 0x0000012F42402F90>
str(pl): <__main__.Plain object at 0x0000012F42402F90>

最后两行的十六进制数字是对象在内存中的地址,每次运行、每台机器都不同。<__main__.Plain object at 0x...> 这种输出几乎不带任何信息量,所以实践建议:自定义类至少实现 __repr__——它同时惠及 REPL 调试、容器打印和日志输出;需要面向用户的可读文案时再补 __str__。

eq 与 hash:相等性与可哈希的契约

== 底层调用 __eq__。默认情况下它做的是身份比较(identity),即 is——只有同一个对象才相等。要让「ISBN 相同就算同一本书」这类按内容比较的语义生效,需要自己定义 __eq__。但这里藏着一个 Python 最著名的联动规则:一旦在类里定义了 __eq__,__hash__ 会被自动置为 None,对象立刻变得不可哈希(unhashable),无法放进集合、也无法作为字典的键。原因在于哈希契约:可哈希对象必须满足「相等则哈希值必相等」,而默认的身份哈希与内容相等天然冲突,解释器宁可让你显式表态,也不愿默默给出错误的哈希。

实测这个坑:

class Book:
    def __init__(self, isbn, title):
        self.isbn = isbn
        self.title = title

    def __eq__(self, other):
        return isinstance(other, Book) and self.isbn == other.isbn

b1 = Book("978-7-001", "书A")
b2 = Book("978-7-001", "书A")
print("b1 == b2:", b1 == b2)
print("试图 hash(b1)……")
try:
    print(hash(b1))
except TypeError as e:
    print("TypeError:", e)
print("b1.__hash__ is None:", b1.__hash__ is None)
print("试图放入集合……")
try:
    s = {b1, b2}
    print("集合长度:", len(s))
except TypeError as e:
    print("TypeError:", e)

实际运行输出:

b1 == b2: True
试图 hash(b1)……
TypeError: unhashable type: 'Book'
b1.__hash__ is None: True
试图放入集合……
TypeError: unhashable type: 'Book'

== 明明说两个书相等,集合却说它们「不可哈希」——程序首先在 hash(b1) 处抛出 TypeError: unhashable type: 'Book',集合构造是同一个 TypeError。修复方式是显式定义 __hash__,且必须基于与 __eq__ 相同的依据(这里都是 ISBN):

class Book:
    def __init__(self, isbn, title):
        self.isbn = isbn
        self.title = title

    def __eq__(self, other):
        return isinstance(other, Book) and self.isbn == other.isbn

    def __hash__(self):
        return hash(self.isbn)

b1 = Book("978-7-001", "书A")
b2 = Book("978-7-001", "书A")
b3 = Book("978-7-002", "书B")
print("hash(b1):", hash(b1))
print("b1 == b2:", b1 == b2)
s = {b1, b2, b3}
print("集合长度(去重后):", len(s))
print("逐元素打印(按 ISBN 排序):")
for book in sorted(s, key=lambda b: b.isbn):
    print(" ", book.isbn, book.title)

实际运行输出:

hash(b1): 8183762976262736742
b1 == b2: True
集合长度(去重后): 2
逐元素打印(按 ISBN 排序):
  978-7-001 书A
  978-7-002 书B

两本 ISBN 相同的书进入集合后被去重为 1 条,集合长度是 2。注意 hash(b1) 的巨大整数每次运行可能不同——字符串哈希带随机种子(PYTHONHASHSEED),但同一进程内「相等的字符串哈希必相等」这条契约永远成立。字典的去重、集合的查找就建立在这个契约上。逐元素打印前特意按 ISBN 排了序:集合本身无序,直接迭代的输出顺序取决于各元素的哈希值,每次运行可能不同,展示给用户前排序是稳妥做法。

lt 与排序:让对象可比较

sorted() 排序内置类型(数字、字符串)时按大小、字典序工作,但面对你的自定义对象,它需要被告知「谁大谁小」。实现 __lt__(小于)即可,排序算法只需这一个比较器。再配合 functools.total_ordering 装饰器,<=、>、>= 会由 __lt__ 与 __eq__ 自动推导出来,不必逐个手写:

from functools import total_ordering

@total_ordering
class Song:
    def __init__(self, title, seconds):
        self.title = title
        self.seconds = seconds

    def __eq__(self, other):
        if isinstance(other, Song):
            return self.seconds == other.seconds
        return NotImplemented

    def __lt__(self, other):
        if isinstance(other, Song):
            return self.seconds < other.seconds
        return NotImplemented

    def __repr__(self):
        return f"Song({self.title!r}, {self.seconds}s)"

songs = [
    Song("孤勇者", 235),
    Song("山丘", 312),
    Song("平凡之路", 280),
]
print("按时长升序:", sorted(songs))
print("最长的一首:", max(songs))
print("s1 <= s2:", Song("a", 235) <= Song("b", 312))
print("s1 > s2:", Song("a", 235) > Song("b", 312))

实际运行输出:

按时长升序: [Song('孤勇者', 235s), Song('平凡之路', 280s), Song('山丘', 312s)]
最长的一首: Song('山丘', 312s)
s1 <= s2: True
s1 > s2: False

三个要点。其一,sorted、max、min 全部只依赖 __lt__,这是一个完整的「可比较」协议的最小实现。其二,遇到不相关类型时返回 NotImplemented 而不是 False——这让 Python 有机会尝试对方的反向比较协议,而不是武断地说「不相等」;total_ordering 装饰器也要求比较方法遇到异类时返回 NotImplemented。其三,total_ordering 是「便捷」而非「魔法」:它用装饰器机制补全缺失的比较方法,代价是每次补全比较都多一层函数调用,对大批量排序有可忽略的开销,日常使用完全没问题。

len 与 getitem:序列协议

len() 与方括号索引的协议是 __len__ 与 __getitem__。实现这两个方法后,对象立刻获得四种能力:报告长度、下标取值、切片,以及——关键之处——被 for 直接遍历。后者是因为迭代协议有退路:iter() 在对象上没有找到 __iter__ 时,会退回「从 0 开始反复调用 __getitem__(0)、__getitem__(1)…… 直到抛出 IndexError」的经典序列协议:

class Playlist:
    def __init__(self, title):
        self.title = title
        self.items = []

    def add(self, name):
        self.items.append(name)

    def __len__(self):
        return len(self.items)

    def __getitem__(self, index):
        return self.items[index]

    def __repr__(self):
        return f"Playlist({self.title!r}, {len(self)} 首)"

pl = Playlist("通勤歌单")
pl.add("夜曲")
pl.add("晴天")
pl.add("七里香")
print("len(pl):", len(pl))
print("pl[0]:", pl[0])
print("pl[-1]:", pl[-1])
print("切片 pl[1:]:", pl[1:])
print("list(pl):", list(pl))
print("repr(pl):", repr(pl))
for name in pl:
    print("播放:", name)

实际运行输出:

len(pl): 3
pl[0]: 夜曲
pl[-1]: 七里香
切片 pl[1:]: ['晴天', '七里香']
list(pl): ['夜曲', '晴天', '七里香']
repr(pl): Playlist('通勤歌单', 3 首)
播放: 夜曲
播放: 晴天
播放: 七里香

负索引、切片能直接工作,是因为 __getitem__ 把参数原样转发给了内部列表——切片时 Python 传入的是一个 slice 对象,而列表恰好认识它。list(pl) 与 for name in pl 说明对象虽然没写 __iter__,依然可以迭代。注意只有 __len__ 而 list() 会把对象当成「不可迭代的容器」报错——迭代靠的是 __getitem__ 这条退路,两条协议各司其职。若追求更标准的行为,第 11 章还会系统讲 __iter__ 与生成器。

call:让实例像函数一样可调用

函数是对象(第四章),反过来,对象也可以表现得像函数——只要实现 __call__。这类对象被称为可调用对象,特别适合「携带状态的函数」:普通函数调用之间记不住东西,而可调用对象的内部属性天然就是它的记忆。用计数器演示:

class CallCounter:
    """记录自己被调用了多少次,并返回累计次数。"""

    def __init__(self):
        self.count = 0

    def __call__(self):
        self.count += 1
        return self.count

counter = CallCounter()
print("第一次调用:", counter())
print("第二次调用:", counter())
print("是否可调用 callable():", callable(counter))
print("直接访问 count:", counter.count)

实际运行输出:

第一次调用: 1
第二次调用: 2
是否可调用 callable(): True
直接访问 count: 2

counter() 的括号语法与函数调用完全一致,callable() 也如实报告它是可调用的。工程上可调用对象常用于三类场景:需要跨调用保存状态的处理器(如统计调用次数的装饰器雏形)、把「行为」作为参数传递但还附带配置的对象、以及替代闭包记录状态的需求。

enter / exit:回到 with 语句

第四章第 9 篇详细讲过上下文管理器与 with 语句:with 只是「调用 __enter__、执行块、无论如何调用 __exit__」的语法糖。现在从协议的角度回头看,那篇文章主角的两个方法正是魔法方法家族的一员——with 对象 之所以成立,纯粹因为对象实现了这两个方法:

class SavePoint:
    """进出 with 块时自动打印状态(教学用简化版)。"""

    def __enter__(self):
        print("保存检查点:开始")
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        if exc_type is not None:
            print(f"检查点回滚:{exc_type.__name__} 发生")
        else:
            print("检查点提交:完成")
        return False  # 不吞异常,让异常继续向外传播

with SavePoint():
    print("第 1 步写入记录")
    print("第 2 步写入记录")

try:
    with SavePoint():
        print("第 1 步写入记录")
        raise ValueError("中途失败")
except ValueError:
    print("主程序捕获到异常")

实际运行输出:

保存检查点:开始
第 1 步写入记录
第 2 步写入记录
检查点提交:完成
保存检查点:开始
第 1 步写入记录
检查点回滚:ValueError 发生
主程序捕获到异常

正常退出时 __exit__ 的三个参数全为 None,打印「提交」;块内抛出异常时 exc_type 携带异常类,打印「回滚」。return False 表示「我没有消化这个异常,请继续向外传播」,因此主程序的 try/except 稳稳接住了它。读写文件、数据库连接的清理都建立在这两个方法上——这是魔法方法中与标准库结合最紧密的一对。

bool:真值测试协议

if 对象:、while 对象:、and、or、not 都要做真值测试(第二章第 3 篇的真值规则)。对自定义对象,bool() 的查找顺序是:先找 __bool__,没有就退回 __len__(长度为零即假),两者都没有则恒为真。实测两种路径:

class Wallet:
    """钱包:余额为零时视为"空",bool 为 False。"""

    def __init__(self, balance):
        self.balance = balance

    def __bool__(self):
        return self.balance != 0

class Rope:
    """绳子(只定义 __len__):任何长度都能被 bool 化,长度为零时为 False。"""

    def __init__(self, length):
        self.length = length

    def __len__(self):
        return self.length

rich = Wallet(1200)
poor = Wallet(0)
print("bool(rich):", bool(rich))
print("bool(poor):", bool(poor))
if poor:
    print("钱包有钱")
else:
    print("钱包没钱")

empty_rope = Rope(0)
rope = Rope(5)
print("bool(Rope(5)):", bool(rope))
print("bool(Rope(0)):", bool(empty_rope))

实际运行输出:

bool(rich): True
bool(poor): False
钱包没钱
bool(Rope(5)): True
bool(Rope(0)): False

Wallet 用 __bool__ 自定义「零余额即空」的语义,Rope 只实现 __len__,bool() 自动退路成立。这也解释了内置类型的行为来源:空列表、空字符串、空字典的 if 为假,正是它们 __len__ 返回零的结果。需要警惕的是:定义 __len__ 会同时改变真值,如果某个对象的长度语义与真值语义不一致(例如「长度为零但对象仍然有意义」),务必显式写 __bool__ 覆盖。

协议速查表

语法/内置函数查找的方法作用
str(obj)、print(obj)、f-string__str__用户可读的字符串
repr(obj)、REPL、容器内部显示__repr__开发者用的无歧义表示
obj1 == obj2__eq__相等性(定义后会置空 __hash__)
hash(obj)、集合、字典键__hash__与 __eq__ 配套的哈希值
obj1 < obj2、sorted()__lt__小于比较,排序的最小依赖
len(obj)__len__长度(同时影响真值测试)
obj[i]、obj[a:b]__getitem__索引与切片(还能让 for 退路迭代)
obj()__call__实例可调用
with obj as x:__enter__ / __exit__上下文管理协议
bool(obj)、if obj:__bool__(无则退 __len__)真值测试

易错点与陷阱

陷阱一:定义了 eq 忘掉 hash

本文已完整实测:只要在类体里定义 __eq__,__hash__ 自动变 None,hash(obj)、放入集合、作为字典键全部抛 TypeError: unhashable type。注意这个坑在调试时很有迷惑性——== 明明正常工作,看起来毫无异常征兆。修复口诀:__eq__ 与 __hash__ 要么都不写(用默认身份语义),要么一起写(基于相同字段)。若你的对象是可变的(如施工中的订单),还要进一步思考:放进集合后修改参与哈希的字段,会让对象在集合里「丢失」——这也是可变对象不该做字典键的根本原因。

陷阱二:两个字符串方法只写一个,而且方向写反

只实现 __str__ 不实现 __repr__ 时,print(obj) 正常但 REPL 调试、[obj] 列表打印会退化成 <__main__.X object at 0x...>。反之,把用户友好的文案写进 __repr__ 而 __str__ 缺失,开发者就失去了无歧义表示。常见反模式是把调试信息(如内部缓存状态)写进 __str__——用户看到的 print 输出变成了实现细节。经验法则:先写 __repr__(信息完整),需要再补 __str__(用户可读);__str__ 缺失自动回退,不会出错,但方向反了就会漏信息。

陷阱三:eq 不做类型检查

def __eq__(self, other): return self.isbn == other.isbn 在 other 没有 isbn 属性时会抛 AttributeError;就算不抛(比如 other 恰好也有 isbn),「书 与 任意对象 相等」也会破坏哈希契约的对称性——a == b 与 hash(a) == hash(b) 不再一致,集合去重会出现诡异行为。标准姿势是:

def __eq__(self, other):
    if isinstance(other, Book):
        return self.isbn == other.isbn
    return NotImplemented

NotImplemented 告诉解释器「我处理不了这个类型,去试试对方的反向方法」,这才符合协议的初衷。

陷阱四:只实现了 lt 却想用 > / >=

total_ordering 装饰器之外,如果你手写比较却只实现 __lt__,sorted() 完全没问题,但 a > b 会报 TypeError: '>' not supported between instances。要么用 total_ordering 一键补齐,要么把需要的比较符都实现;同时记住所有比较方法都要处理「异类返回 NotImplemented」的分支,否则跨类型比较会以 AttributeError 或错误的 True 收场。

小结

魔法方法(特殊方法)是 Python 内置语法与自定义对象之间的接口:解释器在类型上查找这些双下划线方法,实现它们即可让对象获得内置类型的能力。本篇实测了十条最常用协议:__repr__/__str__ 的开发者与用户视角、__eq__/__hash__ 的联动契约、__lt__ 与 total_ordering 排序、__len__/__getitem__ 的序列与迭代退路、__call__ 可调用对象、__enter__/__exit__ 上下文管理、__bool__ 真值测试。记住三条铁律:定义 __eq__ 必须同步定义 __hash__;至少实现 __repr__;比较方法遇异类返回 NotImplemented。下一篇实战《图书管理系统》将把这些协议与类的基础全部用起来。

练习与思考题

  1. 实现一个三维向量类 Vec3:字段 x、y、z;实现 __repr__(形如 Vec3(1, 2, 3))与 __eq__(要求类型相同且三个分量相等)。再试着不写 __hash__ 把它放进集合,观察异常,然后补上 __hash__ 使集合去重生效。(答案要点:__hash__ 用 hash((self.x, self.y, self.z)),元组可哈希。)

  2. 给 Vec3 加排序能力:只实现 __lt__(按模长比较可借助 math.sqrt 或直接比较平方和),验证 sorted([...]) 按模长升序排列,并利用 total_ordering 验证 >= 可用,且模长相等时 == 为真——注意此时「相等」与「分量相等」语义冲突,请说明你选择了哪个语义并解释原因。

  3. 给「倒计时器」类实现序列协议:类内部维护秒数列表,实现 __len__ 与 __getitem__,验证 len()、t[0]、for 遍历与切片 t[1:] 全部可用;再删掉 __getitem__ 只留 __len__,运行 for 循环观察 TypeError: 'Countdown' object is not iterable,理解两条协议的分工。

  4. 思考题:为什么 hash(obj) 要求「相等对象哈希必须相等」,而集合却能容忍哈希碰撞(不同对象哈希相同)?(提示:把集合想象成「根据哈希值分桶的列表」,碰撞只是让桶内比较变多,而相等但哈希不同会让对象被分到不同的桶里,彻底失联。)