这阵子我在整理 Python 3.12 的魔术方法系列,写到第五篇,总算轮到__repr__。这是每个 Python 开发者都会接触、但很少有人真正写好的方法。我平时喜欢用 conda 单独建环境,比如最近在折腾一个 Zotero PDF 翻译服务时,命令行里敲的就是conda create -n zotero-pdf2zh-server python=3.12。现在这套魔术方法示例也全部在 3.12 环境里跑。之所以单独把__repr__拿出来写,是因为实际排坑时发现,好多数据对不上、日志找不到线索,根子就是自定义类没有实现这个魔术方法,打印出来全是一堆十六进制内存地址。不管你做后端、写脚本还是搞数据分析,只要经常定义类,__repr__就决定了你在终端和日志里看到的是“天书”还是“人话”。这篇文章我从默认行为讲起,到手写实现,再到 3.12 环境下的注意事项,尽量让你看完就能写出真正好用的 repr。
1. 为什么repr值得单独写一篇
1.1 默认的 object.repr有多难用
Python 里所有新式类最终都继承自 object,而 object 基类已经提供了默认的__repr__实现,所以你什么都不写也能repr(obj)。问题是这个默认实现的输出永远固定成<模块.类名 object at 0x...>这种格式,类名、模块名、内存地址三件套,对业务调试几乎没有帮助。很多初学者第一次看到这个结果,以为 Python 有什么隐藏问题,其实它只是告诉你“这是一块内存对象”,至于这个对象里装了什么数据,它一概不管。为了看得直观,我用 Python 3.12 跑了一个最简单例子:
class User: def __init__(self, name, email): self.name = name self.email = email u = User("张三", "zhang@example.com") repr(u)输出大约是<__main__.User object at 0x000001D8AF35FF80>。看到的瞬间,你唯一能确认的信息是“这确实是一个 User 实例”,但它的 name 和 email 是什么完全不知道。更麻烦的是,当列表里放十个用户对象,你用print(users)看到的是一堆<__main__.User object at 0x...>,这些地址长得都差不多,你想找某个邮箱对应的用户,只能一个个比对内存地址,完全没法用。我见过不少同事遇到这种输出,第一反应是把整个列表自己循环一遍再打印。但我更主张从源头解决:给类写一个合格的__repr__,让所有打印、日志、调试面板都自动变得可读。
还有一个很多人没意识到的点:内存地址在不同进程里没有可比性。如果你在日志里记录对象 at 0x7ff...,换一次进程重跑,地址全变了;想用这些地址做问题归类、聚合统计,根本不可能。在 Python 3.12 和更早的版本里,这个默认行为都没变,所以解决问题的唯一办法就是自己覆盖__repr__。CPython 底层这么做有它的理由,但对上层应用开发者来说,缺失信息才是关键。
1.2 repr 与 str 的分工
__repr__和__str__是很多人纠结过的两个魔术方法。简单说,repr()调用__repr__,str()调用__str__;repr面向开发者,追求精确和无歧义,str面向普通用户,追求好看和自然。但这里有一个容易踩的坑:print(obj)通常调用str(),如果类没有定义__str__,str()会回退到__repr__;反过来,repr(obj)不会因为定义了__str__就变好看。更隐蔽的是容器打印行为:print([obj])、print({"k": obj})在打印内部元素时调用的是repr(),不是str()。我举个例子:
class User: def __init__(self, name): self.name = name def __str__(self): return self.name u = User("张三") print(u) # 张三,调用 __str__ print([u]) # [<__main__.User object at 0x...>],调用 __repr__如果你只写了__str__,单看变量还行,塞进列表或字典后立刻打回原形。所以在实际项目里,我通常两个都写,或者至少保证__repr__存在。甚至可以一句话让__str__复用__repr__:def __str__(self): return repr(self)。这样日志和打印的一致性会好很多。这个分工在 Python 3.12 里没有变化,但很多新手依然会被默认输出误导,所以我把它放在第一节讲清楚。调试时,repr的信息密度通常更高,因为它包含了类型名称和构造参数,而str可能只是给人看的短文本,两者定位不同,不能互相替代。
2. 手把手写一个合格的repr
2.1 目标:一看就懂,还能用
合格的__repr__不追求花哨,它要做到两点:一眼看出对象类型,一眼看出关键状态。最简单的实现就是在构造函数参数附近做文章,让输出接近一段可以被识别的“构造代码”。以用户对象为例:
class User: def __init__(self, name, email): self.name = name self.email = email def __repr__(self): return f"User(name={self.name!r}, email={self.email!r})"这段代码里,!r是真正不能省的细节。f"{self.name!r}"等价于repr(self.name),对字符串来说会补上引号,并对内部特殊字符做转义。如果不用!r,输出会变成User(name=张三, email=zhang@example.com),名字没有引号,看着像变量名,歧义很大。用了!r后输出是User(name='张三', email='zhang@example.com'),这种写法已经接近一段可以构造对象的 Python 表达式了。在交互式终端里输入变量名回车,在 VS Code 调试器的变量悬停框里,在 Jupyter 的 cell 输出里,用的都是repr。也就是说,把__repr__写好,整个开发环节的工具都会受益。
选择哪些字段也有讲究。我的习惯是挑能唯一区分实例的核心字段,比如User放name和email,Order放order_id和status,不要试图把 20 个属性全部塞进去。信息太多等于没有信息,日志里一张订单占三行,反而什么都看不清。核心目标是“哪个对象出问题一眼能定位”,所以只放能区分的字段就好。
2.2 eval 重建与取舍
不少官方文档和社区文章会说,__repr__的黄金标准是返回一个可通过eval()重建对象的字符串。比如Point(1, 2),你确实能eval(repr(point))得到一个新对象。这在很多纯数据类上做得到,也确实方便:调试时可以快速从日志里复制一个表达式,临时重建现场。我自己的经验是,不要把它当成铁律。对象如果包含打开的文件、网络连接、数据库事务、已经计算完的缓存,你没有想出一个能无损重建的表达式。那怎么办?答案是用<...>把普通描述包起来,告诉别人“这儿没法 eval,别试”。Python 标准库就是这个风格,比如内置 socket 对象的 repr 是<socket.socket fd=3, family=AddressFamily.AF_INET ...>,没人会拿去 eval。
也就是说,__repr__返回的不一定是可执行代码,但一定要“可读且诚实”。如果你硬造一个能 eval 的表达式,却携带错误属性,反而误导排障。举个例子,一个DatabaseConnection对象,内部有连接句柄和连接配置,你如果返回DatabaseConnection(host='db.example.com', password='secret'),看起来能重建,但数据库连接是不能这样凭空重建的,况且密码还泄露了。正确做法是返回<DatabaseConnection host="db.example.com">,让所有阅读日志的人明白“这是内部对象的描述,不是构造代码”。
我还想多说一句:即使你的 repr 长得很像一个字面量,也不要在线上用eval(repr(obj))来做反序列化,真想要安全解析,就用ast.literal_eval,这个函数只处理字符串、数字、元组、列表、字典等字面量,不会执行任意代码。在 Python 3.12 里它依然是最稳妥的字面量解析方式。
2.3 格式化与细节控制
写__repr__到后期,你会开始抠细节。第一个细节是不要硬编码类名。我用type(self).__name__拿到当前对象的真实类名:
class BaseTask: def __repr__(self): return f"{type(self).__name__}(id={self.id!r}, status={self.status!r})" class PdfTask(BaseTask): pass这样子类继承后,repr 自动显示PdfTask,不用在每个子类里重复写。如果你硬编码BaseTask(...),子类打印出来全是基类名,排障时容易混淆。
第二个细节是控制长度。一个购物车里可能有几十件商品,repr 里全放会刷屏。可以只显示前三件,超出用省略号:
def __repr__(self): shown = self.items[:3] more = "..." if len(self.items) > 3 else "" return f"Cart(owner={self.owner!r}, items={shown!r}{more}, total={self.total:.2f})"输出看起来就是Cart(owner='Alice', items=[('book', 1), ('pen', 2), ('desk', 3)]..., total=238.50)。第三个细节是浮点数格式化,用:.2f或:.3f固定小数位,避免把 0.30000000000000004 这种东西直接输出。第四个细节是敏感字段绝对不进 repr,比如密码、token、内部密钥。这个我在后面的安全陷阱里还会展开。这些细节组合起来,会让__repr__从“能看”进化到“好用”。
3. Python 3.12 下的一些新情况和注意事项
3.1 在 3.12 环境里,repr的好搭档们
这一篇冠着 Python 3.12 的名字,我想先聊聊环境。最近我在配置一个 PDF 翻译服务的运行环境,用的命令是conda create -n zotero-pdf2zh-server python=3.12。学习魔术方法也是一样,我建议你建一个独立的 3.12 环境,比如conda create -n magic-py312 python=3.12,这样不会和业务环境冲突。跑完示例后,如果想再验证其他第三方库的兼容性,独立环境也方便随时删除重建。3.12 本身对__repr__的语义没有大改,仍然是repr(obj)调用__repr__,但版本升级后,底层对象布局和 C 扩展行为都有变化,所以最好实际跑一遍示例。
3.12 里和__repr__配合最好用的特性,我首推 f-string 调试语法:
user = User("张三", "zhang@example.com") print(f"{user=}") # user=User(name='张三', email='zhang@example.com')这一行在日志里会同时出现变量名和对象详情,比手写print("user:", user)少一次拼字符串。之所以能显示得这么干净,正是因为你实现了__repr__。另外推荐reprlib模块,它可以配置全局的巨型对象打印上限:
import reprlib r = reprlib.Repr() r.maxlist = 3 print(r.repr(list(range(100)))) # [0, 1, 2, ...]当你处理超大列表、超长字符串时,reprlib能帮你避免日志被撑爆。Python 3.12 的reprlib.RecursiveRepr相关工具也依旧稳定,配合后面讲到的循环引用场景,是一个很实用的工具箱。
3.2 dataclasses 自动生成的 repr 和 field 参数
Python 3.7 以来,dataclasses一直是我写数据模型的首选,到了 3.12 依然如此。使用@dataclass时,如果不手动覆盖,它会自动生成__repr__,格式是ClassName(field1=value, field2=value)。绝大多数情况这个默认 repr 已经很合格,不需要手写。比如:
from dataclasses import dataclass, field @dataclass(slots=True) class Token: user_id: int expires: int token: str = field(repr=False, default="")这里的关键是field(repr=False)。token 是敏感凭证,放进 repr 等于把密钥散落在日志里,因此必须在字段级别关掉。这样即使有人打印 Token 对象,看到的也只是Token(user_id=42, expires=1710000000)。3.12 里 dataclass 还支持slots=True,每个实例的内存占用更小,自动生成的 repr 不受影响。
如果你对默认的字段顺序不满意,或者想精简输出,可以手动覆盖__repr__。但要注意:一旦覆盖,dataclass 就不会再帮你生成,所有字段的展示自己负责。我通常在 dataclass 上先看默认 repr,只有输出太冗余时才覆盖。另外,frozen=True的 dataclass 搭配 repr,可以让不可变配置对象在调试时看起来非常整齐。比如服务配置类,字段多、必须不可变,用@dataclass(frozen=True, slots=True),repr 输出一目了然,又不担心对象状态在运行中期被偷偷改掉。
4. 常见问题与排查技巧
4.1 循环引用和递归爆炸
这里说的坑,不是语法问题,而是运行时问题。如果对象持有指向自身或互相引用的属性,而你又在__repr__里把这个属性展开,很容易递归到死。最常见的场景是树结构和链表:
class Node: def __init__(self, name, parent=None): self.name = name self.parent = parent def __repr__(self): return f"Node(name={self.name!r}, parent={self.parent!r})"当 child 的 parent 指向 parent,parent 的 children 没有直接引用回去,这通常没问题。但如果父节点有一个children列表,且列表里的 child 再引用父节点,打印其中一个节点时会一路递归,直到 RecursionError。解决办法有两种。第一种是在 repr 里只展示 id 而不是整个对象,比如parent_id={id(self.parent)}。第二种更优雅:用reprlib.recursive_repr()装饰器。它通过一个内部栈记录当前正在执行的 repr 调用,一旦检测到同一个对象再次进入,就输出...而不是继续递归。示例:
import reprlib class Node: def __init__(self, name, parent=None): self.name = name self.parent = parent @reprlib.recursive_repr() def __repr__(self): return f"Node(name={self.name!r}, parent={self.parent!r})"碰到循环时,输出会类似Node(name='child', parent=Node(name='parent', parent=...))。这个装饰器是官方标准库方案,我做了几次实验,在 3.12 下表现稳定,强烈建议加入你自己的工具库。需要注意的是它只处理同一线程内同一对象的递归,跨线程同时打印同一个对象的极端情况不在保护范围内。
4.2 安全陷阱
写__repr__时最容易被忽视的是安全。日志系统、监控平台、调试工具都会调用 repr,甚至可能在你不注意时把输出发送到远端。如果你在 repr 里放了数据库密码、API token、会话 cookie,那这些机密就等于被写进了日志文件。我之前见过一个项目,配置类把 redis 连接串整个放在 repr 里,日志一出问题,密钥跟着进采集系统,这就是大事故。所以凡是有敏感属性的类,要么用field(repr=False)排除,要么在__repr__里只输出缩略标识。
第二个陷阱是 eval。虽然咱们的 repr 不一定要可 eval,但一旦你的格式是ClassName(...),调试者往往会顺手复制到 Python 里执行。如果 repr 里的某个字符串来自用户输入,又正好被拼接成恶意表达式,会造成代码执行风险。保持谨慎:不要让__repr__直接使用未经转义的外部输入;如果只是想安全地从字符串解析字面量,用ast.literal_eval,不要用裸eval。
第三个陷阱是 repr 本身里的字符串未处理换行。用户输入一个包含\n的字段,直接f"{self.name}"会让日志格式错乱,用!r可以转义成\n文本,保证日志分行正常。最后,不要在__repr__里做重操作,比如访问数据库、发起网络请求,打印一个对象突然卡死几秒钟,调试体验极差。如果某些属性需要懒加载,repr 里就别碰它,只输出当前已有状态的字段。
4.3 调试实战:日志里的救命稻草
说一个我最近的实战。我在调 Zotero 文档翻译服务时,请求先被封装成一个TranslationTask,里面有源 PDF 路径、目标语言、状态、重试次数。最早没实现__repr__,服务异常时日志里写的是<task.TranslationTask object at 0x7fb4a...>,想定位是哪个文件失败了,得翻上下文。后来我给这个类加了一行:
def __repr__(self): return f"TranslationTask(path={self.path!r}, lang={self.lang!r}, status={self.status!r})"日志立刻变成:
ERROR: TranslationTask(path='/tmp/2025-03-01.pdf', lang='zh', status='failed')我从日志第一行就能确认是路径/tmp/2025-03-01.pdf这个任务出了问题,排查时间至少缩短一半。所以我现在写日志时有个习惯:凡是打印业务对象,优先用%r,比如logger.info("task %r", task),确保日志调用的是 repr 而不是 str。在核心数据类上实现__repr__,表面上是多写两行,实际上是给未来排障铺路。你可以把这当成项目规范:所有 service、model、task 类,必须能repr()出可读状态。
老实说,刚开始学 Python 的时候,我也觉得__repr__是考卷里的基础分,不如迭代器、上下文管理器那样有炫技空间。但踩过几次“日志天书”的坑之后,我反而觉得它是最有性价比的魔术方法。每次写完一个类,我都会问自己:直接print(obj)出来,我看得懂吗?如果答案是否定的,就花两分钟补上__repr__。这个习惯在 Python 3.12 项目里帮我省了大量时间。另外,魔术方法系列既然写到第五篇,后续我会继续用 3.12 实测其余方法,每个方法都尽量给出可落地的经验和真实的排障案例。如果你在项目里也有被默认 repr 坑过的经历,不妨从今天开始,把那些核心类的__repr__认真补起来。