
1. 从一行 print 说起为什么你的对象打印出来像乱码很多刚上手 Python 的朋友都遇到过这个场景定义了一个类创建了实例然后顺手print(obj)想看看里面到底装了什么结果终端里蹦出来一串__main__.Point object at 0x7f8b1c0a3d90。地址是有了可这玩意儿到底有啥用我想看的是坐标、是名字、是状态不是这串十六进制内存地址。这个问题的根源就在于 Python 默认的对象字符串表示机制。当你调用print(obj)时Python 并不是随便吐出一串字符它背后有一套明确的协议print会去调用对象的__str__方法如果这个对象没有定义__str__就退而求其次去找__repr__如果两个都没有那就用object基类提供的默认实现——也就是你看到的那串“类名 内存地址”。所以想让print输出人话核心就是重写魔术方法。魔术方法Magic Method也叫双下方法Dunder Method是 Python 里以双下划线开头和结尾的特殊方法。它们不是给你直接调用的而是被 Python 的内置操作比如print、len、、在背后自动触发。理解这一点非常关键你写的不是普通函数你是在给 Python 解释器留接口。这篇文章适合谁看如果你是刚学完类和对象、正准备写自己的第一个“正经类”的新手那这篇就是为你准备的。如果你已经写过一些代码但每次调试都靠print(obj.__dict__)或者手动拼字符串那看完这篇你可以把这些土办法全扔了。我会从__str__和__repr__的分工讲起一路说到__format__、__bytes__再配上可以直接抄的代码和我在实际项目里踩过的坑。2. 打印对象的两个核心魔术方法str与repr的分工2.1 为什么 Python 要设计两个方法而不是一个这是新手最容易困惑的地方既然都是“把对象变成字符串”为什么搞两个直接一个__str__不就完了答案在于受众不同。Python 的设计哲学里字符串表示有两个截然不同的使用场景一个是给人看的追求可读性比如日志里打印“用户张三余额 100 元”。一个是给开发者看的追求精确性和可还原性比如调试时看到User(name张三, balance100)一眼就知道怎么重建这个对象。__str__负责前者__repr__负责后者。官方文档里对__repr__的要求是如果可能的话它返回的字符串应该是一个合法的 Python 表达式用eval()求值后能得到一个等价的对象。这个要求听起来很严格实际项目中不总是能做到比如对象里有不可序列化的资源但它是你设计时的努力方向。我打个比方__str__像是产品的用户说明书通俗易懂__repr__像是产品的工程图纸精确到螺丝型号。两者服务对象不同所以不能合并。2.2 默认行为到底发生了什么当你写一个空类class Point: pass p Point() print(p)输出是__main__.Point object at 0x7f8b1c0a3d90。这串东西来自object.__repr__它的实现大致等价于def __repr__(self): return f{self.__class__.__module__}.{self.__class__.__name__} object at {hex(id(self))}注意这里有个细节print(p)调用的是str(p)而str(p)在没有__str__时会去调用__repr__。所以默认情况下str和repr输出是一样的。但一旦你定义了__str__print就会优先用它而交互式环境里直接敲变量名回车用的还是__repr__。这个区别在调试时特别重要。你在 Jupyter 或者 REPL 里敲p回车看到的是__repr__的结果你写print(p)看到的是__str__的结果。很多人调了半天__str__结果在 REPL 里看到的还是老样子就是因为没搞清这个触发规则。2.3 一个类里两个方法该怎么配合写我的习惯是永远先写__repr__再考虑要不要写__str__。理由很简单__repr__是兜底的__str__是锦上添花的。如果只写一个写__repr__收益最大因为它在调试、日志、容器打印等场景都会被用到。看一个完整的例子class Point: def __init__(self, x, y): self.x x self.y y def __repr__(self): return fPoint(x{self.x!r}, y{self.y!r}) def __str__(self): return f({self.x}, {self.y}) p Point(3, 4) print(p) # 输出: (3, 4) print(repr(p)) # 输出: Point(x3, y4) print([p]) # 输出: [Point(x3, y4)]注意__repr__里我用了!r这个格式化标志它表示对值调用repr()而不是str()。为什么这么做因为如果x本身是个字符串比如Point(a, b)不加!r会输出Point(xa, yb)这个表达式是没法eval回去的加了!r输出Point(xa, yb)才是合法的 Python 表达式。这是很多人写__repr__时忽略的细节。还有一个关键点print([p])打印列表时列表的__str__会去调用每个元素的__repr__而不是__str__。这是 Python 的约定——容器类型的字符串表示用元素的repr因为容器里元素类型可能五花八门用repr才能保证信息不丢失。所以如果你只写了__str__没写__repr__打印列表时看到的还是那串内存地址这个坑我踩过不止一次。3. 动手实现从零写一个可打印的类3.1 基础版一个坐标点类我们从一个最简单的Point类开始把上面讲的理论落地。这个类只有两个属性x和y但麻雀虽小五脏俱全。class Point: def __init__(self, x, y): self.x x self.y y def __repr__(self): return f{self.__class__.__name__}(x{self.x!r}, y{self.y!r}) def __str__(self): return f({self.x}, {self.y})这里我用self.__class__.__name__而不是硬编码Point好处是子类继承时不用重写__repr__也能显示正确的类名。比如class Point3D(Point): def __init__(self, x, y, z): super().__init__(x, y) self.z z p3 Point3D(1, 2, 3) print(repr(p3)) # 输出: Point3D(x1, y2, z3)等等这里输出会缺z因为Point3D没有重写__repr__继承的是父类的只打印x和y。所以子类如果有额外属性还是得自己重写。这个例子说明__repr__的继承需要谨慎属性变了就得跟着改。3.2 进阶版带格式控制的format__str__和__repr__解决了“打印出来是什么”但如果你想让对象支持 f-string 的格式说明符比如f{p:.2f}那就需要__format__。class Point: def __init__(self, x, y): self.x x self.y y def __repr__(self): return fPoint(x{self.x!r}, y{self.y!r}) def __str__(self): return f({self.x}, {self.y}) def __format__(self, spec): if spec : return str(self) if spec v: return fx{self.x}, y{self.y} if spec r: return repr(self) # 支持把格式说明符透传给坐标值 return f({format(self.x, spec)}, {format(self.y, spec)})用起来是这样p Point(3.14159, 2.71828) print(f{p}) # (3.14159, 2.71828) print(f{p:v}) # x3.14159, y2.71828 print(f{p:.2f}) # (3.14, 2.72)__format__的价值在于它让你的对象能无缝接入 Python 的格式化体系。日志系统、报表生成、模板渲染这些场景里格式说明符是刚需。如果你不实现__format__f{p:.2f}会直接抛TypeError因为默认的object.__format__只接受空字符串。注意__format__里一定要处理spec 的情况否则f{p}这种最常见的用法会报错。这是新手最容易漏的一行。3.3 特殊场景bytes和bool的联动有时候对象需要转成字节流比如写网络协议或者二进制文件。这时候__bytes__就派上用场了import struct class Point: def __init__(self, x, y): self.x x self.y y def __repr__(self): return fPoint(x{self.x!r}, y{self.y!r}) def __bytes__(self): return struct.pack(!dd, self.x, self.y)struct.pack(!dd, ...)里的!表示网络字节序大端dd表示两个双精度浮点数。这样bytes(p)就能得到一个 16 字节的二进制表示可以直接塞进 socket 或者写文件。顺带说一个容易忽略的点如果你定义了__len__或者__bool__会影响对象的真值判断。比如一个表示“空容器”的类如果__len__返回 0那if obj:就是 False。这和打印没直接关系但在调试时经常让人困惑——明明对象存在为什么if判断是假查一下__bool__和__len__就明白了。4. 实战中的坑与排查打印对象时那些让人抓狂的问题4.1 递归引用导致无限递归这是最经典的坑。假设你有个双向链表节点class Node: def __init__(self, value): self.value value self.next None self.prev None def __repr__(self): return fNode(value{self.value!r}, next{self.next!r}, prev{self.prev!r})如果你把两个节点互相连接然后print(node1)__repr__会去 reprnextnext又去 repr 它的prev也就是node1自己于是无限递归最后RecursionError。解决办法是用reprlib.recursive_repr装饰器from reprlib import recursive_repr class Node: def __init__(self, value): self.value value self.next None self.prev None recursive_repr() def __repr__(self): return fNode(value{self.value!r}, next{self.next!r}, prev{self.prev!r})这个装饰器会在检测到递归调用时返回...而不是继续展开。实测下来非常稳是处理图结构、树结构、双向链表的标配。4.2 属性太多导致输出爆炸有些对象属性几十个全塞进__repr__里打印出来刷屏。这时候可以只挑关键属性class Config: def __init__(self, **kwargs): self.__dict__.update(kwargs) def __repr__(self): keys [host, port, timeout] parts [f{k}{getattr(self, k)!r} for k in keys if hasattr(self, k)] return fConfig({, .join(parts)})只显示host、port、timeout三个关键字段其余省略。这样日志里一眼能看出重点不会被无关信息淹没。4.3 常见问题速查表现象可能原因排查方向print 输出内存地址没定义__str__和__repr__检查类里是否有这两个方法REPL 里输出和 print 不一样REPL 用__repr__print 用__str__确认两个方法都定义了打印列表时元素还是地址只定义了__str__没定义__repr__容器用元素的reprf-string 带格式符报错没实现__format__添加__format__并处理空 spec打印时 RecursionError对象间循环引用用recursive_repr装饰输出太长刷屏__repr__包含全部属性只挑关键属性输出4.4 几个我踩过的实操心得第一__repr__里尽量用!r不要用!s或者直接插值。原因前面说过!r能保证字符串带引号输出是合法表达式。我早期写代码时图省事直接fPoint({self.x}, {self.y})结果遇到字符串属性就露馅了。第二不要在__repr__里做耗时操作。有些朋友在__repr__里查数据库、读文件想着“打印时顺便加载一下”。这是大忌因为__repr__会在调试器、日志、异常回溯里被频繁调用一旦耗时整个程序卡死。__repr__必须是纯内存操作快进快出。第三__str__可以依赖__repr__反过来不行。常见写法是__str__里调用repr(self)再加工但__repr__里绝不能调用str(self)否则容易形成循环。第四测试时别忘了打印容器。单独print(obj)正常不代表print([obj])正常。养成习惯写完__repr__用print([obj])和print({k: obj})各测一遍。5. 把打印能力用起来日志、调试与数据展示5.1 日志系统里的对象打印Python 标准库的logging模块在格式化日志时对参数用的是str()也就是走__str__。所以如果你希望日志里输出简洁的人话重点打磨__str__如果希望日志里保留完整信息便于排查那__repr__要写全。import logging logging.basicConfig(levellogging.INFO, format%(asctime)s %(message)s) class Order: def __init__(self, order_id, amount): self.order_id order_id self.amount amount def __repr__(self): return fOrder(order_id{self.order_id!r}, amount{self.amount!r}) def __str__(self): return f订单 {self.order_id}金额 {self.amount} order Order(A1001, 299.0) logging.info(创建订单: %s, order) # 走 __str__ logging.info(订单详情: %r, order) # 走 __repr__注意%s和%r的区别和 f-string 里的!s、!r是一回事。日志里我一般用%s给人看排查问题时临时改成%r看全貌。5.2 调试器里的表现在 pdb 或者 IDE 的调试面板里变量值默认显示的是repr。所以__repr__写得好调试效率直接翻倍。我见过一个项目所有模型类的__repr__都只返回类名调试时满屏都是User、Order根本分不清哪个是哪个。后来统一改成带主键的__repr__比如User(id42)排查问题快了很多。5.3 数据展示场景的取舍如果你在写一个 CLI 工具需要把对象以表格形式输出那__str__和__format__就是主力。比如class Product: def __init__(self, name, price, stock): self.name name self.price price self.stock stock def __format__(self, spec): if spec row: return f{self.name:20}{self.price:10.2f}{self.stock:8} return str(self) def __str__(self): return f{self.name} {self.price} products [Product(键盘, 299.0, 15), Product(鼠标, 89.0, 40)] print(f{名称:20}{价格:10}{库存:8}) for p in products: print(f{p:row})输出对齐整齐直接能当报表用。这种场景下__format__比__str__更灵活因为你可以定义多种格式模式按需切换。5.4 一个完整的实战案例把前面所有知识点串起来写一个带完整打印能力的Matrix类from reprlib import recursive_repr class Matrix: def __init__(self, rows, cols, fill0.0): self.rows rows self.cols cols self.data [[fill] * cols for _ in range(rows)] def __getitem__(self, idx): return self.data[idx] def __setitem__(self, idx, value): self.data[idx] value recursive_repr() def __repr__(self): return fMatrix(rows{self.rows!r}, cols{self.cols!r}, data{self.data!r}) def __str__(self): lines [] for row in self.data: lines.append( .join(f{v:8.3f} for v in row)) return \n.join(lines) def __format__(self, spec): if spec shape: return f{self.rows}x{self.cols} if spec sum: total sum(sum(row) for row in self.data) return f{total:.3f} return str(self) def __bytes__(self): import struct flat [v for row in self.data for v in row] return struct.pack(f!{len(flat)}d, *flat)测试一下m Matrix(2, 3) m[0] [1.0, 2.0, 3.0] m[1] [4.0, 5.0, 6.0] print(m) # 对齐的矩阵 print(repr(m)) # 完整构造表达式 print(f{m:shape}) # 2x3 print(f{m:sum}) # 21.000 print(len(bytes(m))) # 48 (6 个 double)这个类覆盖了__repr__、__str__、__format__、__bytes__四个打印相关魔术方法可以直接作为模板套用到你自己的项目里。6. 几个容易被忽略的边界情况6.1 继承链上的方法解析顺序如果父类定义了__repr__子类没定义那子类实例打印时用的是父类的实现。这本身没问题但如果子类新增了属性父类的__repr__就显示不全。这时候要么子类重写要么父类用self.__dict__动态生成class Base: def __repr__(self): attrs , .join(f{k}{v!r} for k, v in self.__dict__.items()) return f{self.__class__.__name__}({attrs})这种写法在简单数据类里很好用但要注意__dict__的顺序不保证稳定而且如果属性里有不可 repr 的对象还是会出问题。所以它适合快速原型正式代码里我还是倾向于显式列出属性。6.2 属性访问有副作用的类有些类的属性是通过property动态计算的访问时会触发计算甚至 IO。这种情况下__repr__里遍历__dict__是安全的因为__dict__只存实例属性不含 property但如果你手动getattr就可能触发副作用。写__repr__时务必确认你访问的每个属性都是“纯读取”的。6.3 不可变对象与slots用了__slots__的类没有__dict__所以上面那种动态生成__repr__的写法会失败。这时候只能显式列出属性class ImmutablePoint: __slots__ (x, y) def __init__(self, x, y): self.x x self.y y def __repr__(self): return fImmutablePoint(x{self.x!r}, y{self.y!r})__slots__省内存但代价就是失去了__dict__的灵活性。这个取舍在写大量小对象时值得考虑。6.4 打印时的编码问题如果__str__返回的字符串包含非 ASCII 字符在某些终端环境下可能显示乱码。这不是魔术方法本身的问题而是终端编码的问题。稳妥的做法是确保源码文件用 UTF-8 编码并且在__str__里避免返回控制字符。我遇到过__str__里返回了\x1b开头的 ANSI 转义序列结果日志文件里全是乱码排查了半天才发现是颜色代码惹的祸。7. 从打印对象延伸出去的设计思考写__repr__和__str__看似是小事但它其实反映了一个更深的设计问题你的对象是否“自解释”。一个__repr__写得好的类调试时不需要额外查文档看一眼输出就知道对象状态。这在大型项目里价值巨大因为调试时间往往比写代码时间还长。我的经验是把__repr__当成类的“身份证”来设计。每个类都应该有一个能唯一标识实例的__repr__通常包含主键或者核心属性。__str__则当成“名片”面向最终用户追求简洁友好。两者分工明确不要混用。另外__format__是很多人忽略的宝藏。一旦你的对象支持格式说明符它就能无缝接入 f-string、str.format()、format()内置函数在报表、日志、模板场景里如鱼得水。花十分钟实现__format__能省下后面无数次的字符串拼接。最后分享一个小技巧如果你用dataclassesdataclass装饰器会自动帮你生成__repr__而且质量相当高。手写类的时候可以参考它生成的格式——类名(属性名值, ...)属性用!r顺序和定义一致。这个约定已经被社区广泛接受照着写准没错。from dataclasses import dataclass dataclass class User: name: str age: int u User(张三, 30) print(repr(u)) # User(name张三, age30)如果你不想手写__repr__又不想用dataclasses还可以用attrs这类第三方库它们都提供了自动生成的能力。但理解底层原理仍然重要因为自动生成的__repr__不一定符合你的展示需求关键时刻还是得自己动手改。