1. 从一行 print 说起:为什么你的对象输出是“天书”
很多刚上手 Python 的朋友都踩过同一个坑:辛辛苦苦定义了一个类,实例化之后想打印出来看看里面到底装了什么,结果终端里蹦出来一行<__main__.Point object at 0x7f8b1c0a3d90>。这串东西除了能看出它是个对象、大概在内存哪个位置之外,几乎没有任何信息量。你想要的可能是Point(x=3, y=5)这种一眼就能看懂的结果,但 Python 默认给你的却是这串“天书”。
这个问题的根源,就在于 Python 的魔术方法(Magic Method,也叫双下划线方法、Dunder Method)没有被正确实现。所谓魔术方法,就是那些以双下划线开头和结尾的特殊方法,比如__init__、__str__、__repr__、__len__等等。它们不是你主动去调用的,而是 Python 在特定场景下自动触发的。打印对象这件事,背后触发的就是__str__和__repr__这两个方法。
这篇文章我想聊的就是这个看似很小、但实际影响很大的话题:如何通过实现魔术方法,让你的对象实例打印出来是人话而不是天书。内容会从最基础的__str__和__repr__讲起,一路延伸到__format__、调试场景下的取舍、以及我在实际项目里踩过的那些坑。不管你是刚学完类和对象的新手,还是写了几年 Python 但一直没认真研究过魔术方法的老手,相信都能从中拿到一些可以直接抄作业的东西。
先说清楚适用人群:如果你写过class关键字、知道什么是实例化、能看懂self的含义,那这篇文章你就能顺畅读下去。如果你连类都还没定义过,建议先把基础语法过一遍再回来,不然有些地方会有点吃力。整篇内容我会尽量用生活化的类比来解释,同时给出可以直接复制运行的代码,保证你看完就能动手改自己的项目。
2. 打印对象背后的机制:str与repr到底谁在干活
2.1 一次 print 调用到底经历了什么
要搞清楚为什么打印出来是“天书”,得先弄明白print(obj)这一行代码在底层做了什么。流程大致是这样的:print函数接收到一个对象后,会去调用内置的str()函数把它转成字符串,而str()又会去找这个对象所属类里有没有定义__str__方法。如果定义了,就用它的返回值;如果没定义,Python 会退而求其次去找__repr__;如果连__repr__也没定义,那就只能使用object基类提供的默认实现,也就是那串<类名 object at 内存地址>。
这里有个关键点很多人会忽略:__str__没定义时会回退到__repr__,但反过来不成立。也就是说,如果你只定义了__repr__,那么print()和直接在交互式环境里敲对象名,输出是一样的;但如果你只定义了__str__,那么在交互式环境里直接敲对象名,看到的还是那串“天书”,因为交互式环境用的是repr()而不是str()。
我用一个简单的例子来说明这个回退关系。假设定义了一个Book类,只实现__repr__:
class Book: def __init__(self, title, pages): self.title = title self.pages = pages def __repr__(self): return f"Book(title={self.title!r}, pages={self.pages})" b = Book("Python入门", 320) print(b) # 输出 Book(title='Python入门', pages=320) b # 交互式环境同样输出 Book(title='Python入门', pages=320)可以看到,只实现__repr__的情况下,print和交互式输出是一致的。这就是为什么很多教程会建议:如果你只想实现一个,那就实现__repr__,因为它能覆盖更多场景。
2.2str和repr的定位差异
虽然两者都能控制对象的字符串表现,但它们的设计意图是不一样的,这一点在 Python 官方文档里有明确说明,但很多人没注意到。
__str__面向的是最终用户,追求的是可读性。比如一个日期对象,__str__返回2024-01-15就很合适,用户一看就懂。而__repr__面向的是开发者,追求的是准确性和无歧义,理想情况下它的返回值应该是一段能够重新创建这个对象的代码。还是日期对象,__repr__可能返回datetime.date(2024, 1, 15),这样开发者一看就知道怎么构造出同样的对象。
用生活化的类比来说:__str__像是商品的外包装说明,写给消费者看的,通俗易懂;__repr__像是商品的内部规格标签,写给工程师看的,精确到型号参数。两者服务对象不同,所以内容风格也应该不同。
我在实际项目里总结出一条经验法则:__repr__尽量做到“可重建”,__str__尽量做到“好阅读”。如果两者内容恰好一样,那完全可以只写__repr__,然后让__str__ = __repr__,省得重复。但如果你的对象有复杂的内部状态,两者还是分开写更合适。
2.3 默认实现为什么长那样
object基类提供的默认__repr__实现,返回的格式是<模块名.类名 object at 十六进制地址>。这个设计其实是有意为之的:它明确告诉你“这是一个对象,位于某个内存地址”,但故意不暴露任何内部状态。这样做的好处是安全——不会因为打印一个对象就泄露敏感数据;坏处就是调试时非常不方便。
理解了这一点,你就明白为什么自定义__repr__是调试效率的关键。每次你在调试器里看到一堆<xxx object at 0x...>,都得手动去展开属性,那效率低得让人抓狂。花五分钟给核心类加上__repr__,能省下后面无数次的调试时间,这笔账怎么算都划算。
3. 动手实现:给自定义类加上可读的打印输出
3.1 最小可用版本:三行代码搞定
先看一个最朴素的实现。假设我们在做一个几何计算的小工具,定义了一个表示点的类:
class Point: def __init__(self, x, y): self.x = x self.y = y def __repr__(self): return f"Point(x={self.x}, y={self.y})"就这么三行,效果立竿见影:
p = Point(3, 5) print(p) # Point(x=3, y=5)对比一下不加__repr__时的输出<__main__.Point object at 0x7f...>,可读性提升不是一点半点。这里用了 f-string,把属性值直接嵌进去,格式清晰。注意我在属性值两边加了x=和y=这样的标签,这样即使属性多了也不会看混。
有个细节值得说一下:f-string 里的!r转换符。如果你写f"Point(x={self.x!r}, y={self.y!r})",那么字符串属性会带上引号,比如Point(x='a', y='b'),这样能区分字符串和数字。对于调试用途,我强烈建议加上!r,因为它能避免“字符串'3'和数字3看起来一样”这种歧义。这个小技巧在排查类型相关的 bug 时特别有用。
3.2 处理嵌套对象:让打印结果层层展开
单个对象好办,但如果对象里嵌套了另一个自定义对象呢?比如一个Rectangle类,内部持有两个Point对象:
class Rectangle: def __init__(self, top_left, bottom_right): self.top_left = top_left self.bottom_right = bottom_right def __repr__(self): return f"Rectangle(top_left={self.top_left!r}, bottom_right={self.bottom_right!r})"因为Point已经实现了__repr__,所以Rectangle的打印结果会自动层层展开:
r = Rectangle(Point(0, 10), Point(10, 0)) print(r) # Rectangle(top_left=Point(x=0, y=10), bottom_right=Point(x=10, y=0))这就是实现__repr__的复利效应:你只需要在每个类里各写一次,嵌套结构就能自动获得可读的输出。如果Point没实现__repr__,那Rectangle打印出来就会夹杂着<__main__.Point object at 0x...>,非常难看。
这里有个坑要提醒:嵌套层级过深时,__repr__可能触发无限递归。比如一个对象持有对自身的引用,或者两个对象互相引用,直接递归调用repr()会导致栈溢出。解决办法是在__repr__里用reprlib.recursive_repr装饰器,或者手动控制递归深度。这个后面在问题排查章节会详细讲。
3.3 用 dataclass 自动生成:省事的现代写法
如果你用的是 Python 3.7 及以上版本,那有个更省事的办法:dataclasses模块会自动帮你生成__repr__。看代码:
from dataclasses import dataclass @dataclass class Point: x: int y: int就这么几行,Point(3, 5)打印出来就是Point(x=3, y=5),和手写的效果一模一样。dataclass默认会生成__repr__,而且格式相当规范。如果你不想要自动生成的,可以传repr=False关掉。
不过dataclass也不是万能的。它的自动__repr__只包含字段,不包含计算属性;如果你的类有复杂的初始化逻辑或者需要隐藏某些敏感字段,还是得手写。另外,dataclass生成的__repr__默认不带!r,字符串字段打印出来不带引号,这点和手写时加!r的习惯略有差异,需要留意。
我个人的选择标准是:纯数据容器用dataclass,有行为逻辑的类手写__repr__。前者省事,后者可控。
4. 进阶玩法:format与格式化输出
4.1 为什么还需要format
__str__和__repr__解决的是“打印出来长什么样”,但如果你用过 f-string 的格式说明符,比如f"{value:.2f}",就会遇到另一个问题:自定义对象能不能支持格式说明符?答案是能,但需要实现__format__方法。
默认情况下,object基类的__format__会忽略格式说明符,直接返回str(self)。也就是说,如果你写f"{p:>20}",期望右对齐宽度 20,但实际输出还是str(p),格式说明符被无声无息地丢掉了。要实现真正的格式化,就得自己写__format__。
4.2 一个支持格式说明符的实现
还是用Point举例,假设我们希望支持f"{p:.1f}"这样的精度控制,让坐标保留一位小数:
class Point: def __init__(self, x, y): self.x = x self.y = y def __repr__(self): return f"Point(x={self.x!r}, y={self.y!r})" def __format__(self, spec): if spec == "": return str(self) # 把格式说明符分别应用到 x 和 y 上 return f"({format(self.x, spec)}, {format(self.y, spec)})" def __str__(self): return f"({self.x}, {self.y})"测试一下:
p = Point(3.14159, 2.71828) print(f"{p}") # (3.14159, 2.71828) print(f"{p:.2f}") # (3.14, 2.72) print(f"{p:>20}") # 整个字符串右对齐到宽度 20这个实现的关键在于:把接收到的spec透传给内部的数值,让数值自己去处理格式化。这样Point就自动支持了所有数值类型支持的格式说明符,扩展性很好。
4.3 格式说明符的解析技巧
__format__接收的spec是一个字符串,可能包含填充字符、对齐方式、宽度、精度、类型码等多个部分。如果你想让自定义对象支持更复杂的格式,比如f"{p:>10.2f}"这种组合,就需要解析这个字符串。
Python 标准库里的string模块提供了一个Formatter类,可以帮你解析格式说明符。不过对于大多数场景,直接把spec透传给内部数值就够了,不需要自己解析。只有当你想对不同的部分做不同处理时,才需要手动解析。
这里分享一个我踩过的坑:__format__里不要忘记处理空字符串的情况。当用户写f"{p}"不带任何格式说明符时,spec是空字符串。如果你直接format(self.x, ""),对于数字类型是没问题的,但对于某些自定义类型可能会报错。稳妥的做法是先判断spec是否为空,为空时返回str(self)。
5. 调试场景下的取舍:什么时候该用哪个
5.1 调试器里的表现差异
在调试器(比如 pdb 或者 IDE 内置的调试面板)里查看对象时,用的通常是repr()而不是str()。这意味着如果你只实现了__str__,调试器里看到的还是那串“天书”。这一点非常关键,因为很多人写完__str__就觉得万事大吉了,结果调试时依然一脸懵。
所以我的建议很明确:优先实现__repr__。它覆盖的场景更广,包括调试器、容器打印、日志输出等。__str__可以作为补充,在需要面向最终用户展示时提供更友好的格式。
5.2 日志输出中的选择
写日志的时候,用%r还是%s也是个小有讲究的问题。%r调用的是repr(),%s调用的是str()。对于调试日志,我倾向于用%r,因为它能暴露更多细节,比如字符串的引号、类型信息等。对于面向用户的日志,用%s更合适。
举个实际例子:假设你在记录一个用户对象,__repr__返回User(id=42, name='张三'),__str__返回张三。调试日志里用%r能看到 id,方便定位问题;用户日志里用%s只显示名字,更简洁。
5.3 敏感信息的处理
这里要特别提醒一个安全问题:__repr__里不要包含敏感信息。因为__repr__会在很多你意想不到的地方被调用,比如异常堆栈、调试器、日志系统。如果你的对象持有密码、令牌之类的敏感字段,直接放进__repr__里可能会导致信息泄露。
正确的做法是在__repr__里对这些字段做脱敏处理,比如显示成password='***'。或者干脆不包含这些字段。这一点在写涉及认证、支付的代码时尤其重要,我见过不止一个项目因为__repr__泄露了敏感数据而出了问题。
6. 常见问题与排查技巧实录
6.1 打印出来还是“天书”怎么办
这是最常见的问题。排查思路按顺序来:
第一,确认你实现的方法名拼写正确。是__repr__不是__rep__,是双下划线不是单下划线。Python 不会因为你拼错了就报错,它只会默默地用默认实现。我见过有人写成_repr_,调试了半天才发现。
第二,确认方法定义在正确的类里。如果你有继承关系,子类没实现__repr__,会继承父类的实现。如果父类的实现不合适,子类需要覆盖。
第三,确认你打印的是实例而不是类。print(Point)打印的是类对象,用的是type的__repr__,和你定义的实例方法无关。要打印实例得用print(Point(3, 5))。
第四,检查是否有__slots__或者元类干扰。某些高级用法可能会影响方法查找,不过这种情况比较少见。
6.2 递归调用导致栈溢出
前面提到过,嵌套对象互相引用时,__repr__可能无限递归。看这个例子:
class Node: def __init__(self, name): self.name = name self.parent = None def __repr__(self): return f"Node(name={self.name!r}, parent={self.parent!r})"如果两个节点互相设为对方的 parent,打印时就会无限递归。解决办法是用reprlib.recursive_repr装饰器:
from reprlib import recursive_repr class Node: def __init__(self, name): self.name = name self.parent = None @recursive_repr() def __repr__(self): return f"Node(name={self.name!r}, parent={self.parent!r})"这个装饰器会检测递归调用,第二次进入时返回...,避免栈溢出。这个技巧在处理树形结构、图结构时特别有用。
6.3 性能问题:repr写得太重
__repr__会在很多地方被调用,如果里面做了耗时操作(比如查数据库、读文件、复杂计算),会严重拖慢程序。我见过有人在__repr__里做了一次网络请求,结果日志系统一打印就卡住。
原则很简单:__repr__只做字符串拼接,不做任何副作用操作。它应该是纯函数,快速返回。如果需要展示复杂信息,可以在__repr__里只放关键字段,详细信息通过其他方法获取。
6.4 常见问题速查表
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
打印出来是<xxx object at 0x...> | 没实现__repr__或__str__ | 实现__repr__ |
| 交互式环境是“天书”,print 正常 | 只实现了__str__ | 补上__repr__ |
| 格式说明符被忽略 | 没实现__format__ | 实现__format__并透传 spec |
| 打印时栈溢出 | 对象循环引用 | 用recursive_repr装饰器 |
| 日志打印很慢 | __repr__里有耗时操作 | 移除副作用,只做拼接 |
| 敏感信息泄露 | __repr__包含敏感字段 | 脱敏或排除敏感字段 |
7. 我在实际项目中的几点体会
聊了这么多技术细节,最后分享几个我在实际项目里总结的经验,都是踩过坑之后才明白的。
第一个体会是:给核心数据类加__repr__的投入产出比极高。一个项目里真正需要__repr__的类可能就十几个,每个花五分钟写一下,后面调试时省下的时间是以小时计的。我现在养成了一个习惯,定义完一个类之后顺手就把__repr__写上,已经成为肌肉记忆了。
第二个体会是:__repr__的格式要统一。如果一个项目里有的类写成Point(3, 5),有的写成<Point x=3 y=5>,看起来会很乱。我一般统一用类名(字段=值, 字段=值)的格式,和dataclass的默认风格保持一致,这样整个项目的输出风格是统一的。
第三个体会是:不要过度设计。有些人喜欢在__repr__里搞很多花活,比如根据环境变量切换格式、动态计算字段等。这些都会增加复杂度和出错概率。__repr__就应该简单直接,一眼能看懂就行。
第四个体会是:测试里可以断言__repr__。给__repr__写个单元测试,确保格式符合预期。这样以后重构时如果不小心改坏了,测试会立刻发现。这个习惯在团队协作中特别有价值,因为别人可能不知道你精心设计的格式约定。
关于后续扩展,如果你对魔术方法感兴趣,可以顺着这条线继续研究__eq__、__hash__、__lt__这些比较相关的魔术方法,它们和__repr__一样,都是让自定义对象融入 Python 生态的关键。把这些方法用好了,你写的类用起来就和内置类型一样自然。