
1. 为什么必须理解魔法方法从一道常见报错说起先抛一个几乎所有Python开发者都经历过的场景你定义了一个类存了几个字段然后print(instance)屏幕上出现一行__main__.Book object at 0x7f8c4b2a3d90。这行输出说不上是报错但你也知道它完全没有传递任何有效信息。我最初写Python的半年时间里遇到这种情况的处理方式非常粗暴——写一个普通的show()方法然后每次手动调用。直到有一回我写了一个日志系统需要把自定义对象直接塞进f-string和日志格式化器里我才发现手动show()根本不可行——底层库只认str()和repr()这几个内建函数。那一次我被迫去查资料才意识到 Python 里真正驱动一切语法糖的不是内置函数本身而是对象背后带的那些双下划线方法也就是魔法方法。魔法方法Magic Methods在官方文档里叫 Special Methods名字里的魔法来源于它们不需要你显式调用——Python 解释器会在特定场景自动触发。比如你写a b解释器实际执行的是a.__add__(b)你写if obj:解释器调用的是obj.__bool__()你写for item in obj解释器一路调用的则是obj.__iter__()和obj.__next__()。理解魔法方法的价值不在于多背几个方法名而在于你看待 Python 对象的方式会彻底改变你会明白 Python 中的运算符、for 循环、with 语句、属性访问、比较判断这些看起来语言自带的能力其实全都是定义在对象之上的协议接口。任何一个类只要实现了对应协议就能无缝接入 Python 的生态效果和内置类型别无二致。这篇文章适合两类人一是刚学完基础语法、卡在类和对象到底能干什么这个困惑上的新手二是已经写过不少业务代码、但总是靠面向搜索引擎编程查各种双下划线方法用法的中级开发者。我会把最常用、最能提升代码质量的那批魔法方法讲透配合真实的工程场景并把我踩过的坑一并交代清楚。看完之后你会发现Python 里真正高级的写法底层几乎都离不开这套协议体系。2. 字符串表示与对象比较先搞定两个出现频率最高的协议族魔法方法有几十个但日常开发中出现在日志、调试、集合操作里频率最高的绝对是字符串表示和对象比较这两组。它们直接决定了你的对象在打印、判等、哈希、放入集合时表现是否正常。这两组协议没有理解透彻后面的一切都容易出隐性 bug。2.1 打印与调试str和repr的分工先说所有魔法方法里我最先建议掌握的__str__和__repr__。二者都返回对象的字符串表示但服务对象完全不同。__str__是给人看的。str(obj)、print(obj)、f{obj}这些地方触发的都是它目标输出应该是人类友好、可读性强的一段描述。__repr__则是给解释器看的目标输出应该尽可能无歧义、可还原。它由repr(obj)触发并且在交互式命令行里直接敲对象名回车时展示的也是它。我见过大量代码只在类里写了__str__没写__repr__。后果就是print(obj)正常但一旦把这个对象放进列表再打印整个列表输出立刻退化成__main__.Book object at 0x...。因为列表的__repr__会对每个元素调用repr()而不是str()此时如果你的类没有定义__repr__就会一路回溯到object基类的默认实现也就是那个地址字符串。一个值得养成的习惯是__repr__的返回值尽量能直接还原对象或者至少包含足够定位问题的字段。比如class Book: def __init__(self, title, author, price): self.title title self.author author self.price price def __repr__(self): return fBook({self.title!r}, {self.author!r}, {self.price!r}) def __str__(self): return f《{self.title}》作者{self.author}售价{self.price}元这样设计之后直接print(book)输出的是给终端用户看的友好信息而记录日志或调试时用repr(book)拿到的则是带有完整字段、甚至可以直接粘贴回代码里重新构造对象的表达式。注意我在__repr__里用了!r转换标志它确保嵌入的字符串字段自带引号这一小细节能让日志里的输出更便于区分类型。2.2 判等与哈希eq和hash的成对法则接下来是另一个高频协议__eq__相等判断和__hash__哈希值计算。Python 里运算符默认走的是object基类的身份比较——即只有两个变量指向同一个对象时才算相等字段值相同但实例不同结果是False。这在很多业务场景里是反直觉的两张订单号相同的订单理应是同一笔订单才对。自定义__eq__的直觉很容易建立但随之而来的坑很隐蔽一旦你定义了__eq__Python 会同时把__hash__设为None。原因是可变对象不应该被哈希——两个对象现在相等了如果还能被当作字典的 key 或者放进 set那么你修改其中一个对象的字段后它的哈希值可能变化导致它存放在容器里的位置失效字典和集合的内在一致性就被破坏了。class Order: def __init__(self, order_id, amount): self.order_id order_id self.amount amount def __eq__(self, other): return isinstance(other, Order) and self.order_id other.order_id def __hash__(self): return hash(self.order_id)如果你确定自己的对象在用作字典 key 期间不会被修改那么像上面这样同时给__eq__和__hash__一个明确的实现是正确的。我个人的建议是除非对象不可变比如把字段都变成只读属性否则尽量别把它塞进 set 或作为字典 key——用dict按order_id单独建索引更安全、更直观也更容易排查问题。另外还有一个高频误用只写__eq__不写__ne__。在 Python 3 里!会默认取__eq__结果取反所以这个问题不大但如果你维护的是 Python 2 时代传下来的代码或者需要对接某些老式框架__ne__缺位就会导致!走身份比较行为怪异。新代码里我依然习惯把__ne__一并写上成本几乎为零却能避免未来考古式的排查。2.3 排序场景里的伴随者lt与 functools.total_ordering对象之间比大小也是一个高频需求。假设你有一个订单列表想按金额排序。直接调用sorted(orders)Python 会试图比较两个 Order 对象的大小但默认的对象不具备比较能力于是抛出TypeError。解决方案有两种。第一种是给类加__lt__小于判断只实现这一个方法就够排序用了因为sorted()内部依赖的__lt__是排序算法的核心比较操作。第二种是配合functools.total_ordering装饰器只需要在类里写__eq__和任意一个比较方法装饰器会自动补全__le__、__gt__、__ge__。from functools import total_ordering total_ordering class Order: # ... 省略其他方法 ... def __lt__(self, other): if not isinstance(other, Order): return NotImplemented return self.amount other.amount注意这里用了NotImplemented比直接抛TypeError更符合协议规范它告诉解释器我处理不了这种类型比较你试试右边的反向操作或者兜底逻辑。这样当你拿 Order 和 int 比较时解释器会尝试int的反向比较方法最终给出一个得体的报错而不是在自定义类内部抛异常。3. 让对象像函数一样可调用call与运算符重载背后的设计哲学字符串表示和比较解决了对象看起来像什么的问题现在讨论对象能做什么的问题。Python 里有一类对象的调用方式极度统一obj()后面跟一对圆括号就能执行。函数、方法、类本身、以及实现了__call__的对象全部遵循同一个机制。这背后是 Python 一贯的设计哲学——接口统一行为多态。3.1call当你的对象需要记住状态的函数__call__可以让一个类的实例表现得像函数一样可以直接调用。那么这个能力到底解决了什么问题最典型的场景是函数需要随身携带状态。举一个我实际做过的例子。当时需要写一个网络请求重试器不同接口的重试次数不同而且每次重试之间要按不同策略递增等待时间。如果写成普通函数状态已经重试了几次、当前等待多久都得通过额外参数或全局变量传递非常别扭。用__call__实现就自然得多class RetryPolicy: def __init__(self, max_retries3, base_delay1.0): self.max_retries max_retries self.base_delay base_delay self.retries 0 def __call__(self, func, *args, **kwargs): while self.retries self.max_retries: try: result func(*args, **kwargs) self.retries 0 return result except Exception: self.retries 1 delay self.base_delay * (2 ** (self.retries - 1)) time.sleep(delay) raise RuntimeError(重试次数耗尽)实现__call__之后这个重试器的实例可以像普通函数一样被传递、被调用同时状态保留在对象内部。这种模式在需要可配置策略 内部状态 可调用接口的场合非常顺手。另外经常用到__call__的地方还有各类装饰器工厂——用类实现装饰器时__call__方法就是装饰器返回的包装函数的载体。3.2 运算符重载的完整版图不只是addPython 的运算符重载远不止__add__一个。完整的算术运算协议包括正向运算__add__、__sub__、__mul__、__truediv__等、反向运算__radd__、__rsub__等、增量运算__iadd__、__imul__等、以及比较运算上一章讲的__eq__、__lt__等。理解反向运算尤其重要它负责处理左侧是普通类型右侧是你的自定义类型的情况。比如5 order这种写法Python 先尝试int.__add__(5, order)发现int不认识你的 Order于是转而来调order.__radd__(5)。没有实现反向方法交换律就失效了order 5能用而5 order报错。增量运算__iadd__对应的场景是a b。注意这里的细微区别如果类没有定义__iadd__Python 会退化成先执行a a b再重新赋值对不可变类型没问题但如果你自己的对象是可变的比如内部的列表需要原地修改那么实现__iadd__就能避免产生一个全新对象对内存和性能都有好处。我自己实现过一个简单的向量类把这些方法都写全了class Vector: def __init__(self, x, y): self.x x self.y y def __add__(self, other): if not isinstance(other, Vector): return NotImplemented return Vector(self.x other.x, self.y other.y) def __radd__(self, other): if other 0: return self # 处理 sum() 初始值的情况 if isinstance(other, Vector): return self.__add__(other) return NotImplemented def __iadd__(self, other): if not isinstance(other, Vector): return NotImplemented self.x other.x self.y other.y return self def __repr__(self): return fVector({self.x!r}, {self.y!r})这里有个工程细节__radd__里判断other 0是有实际意义的。Python 内建的sum()函数在对一个空序列求和时会把0作为初始值然后逐次执行result result item。如果用户拿sum([v1, v2, v3])来累加向量第一轮调用的就是0 v1你的__radd__必须能接住这个0否则直接报错。3.3 数值转换协议int、float、bool除了算术运算符类型转换也走协议机制。int(obj)触发__int__float(obj)触发__float__而bool(obj)触发的是__bool__。其中__bool__是很多人忽略但实际很常用的一个方法。默认情况下一个对象永远为真除非你定义了__bool__或者__len__。这个默认行为是很多隐藏 bug 的来源一个明明没有任何有效数据的对象在if obj:判断里却通过了。我见过一个实际案例某个统计配置对象在字段全部为空时仍然走入了业务逻辑分支导致后续生成了错误的报表排查到最后发现就是因为类里没实现__bool__。class ReportConfig: def __init__(self, metrics, time_range): self.metrics metrics self.time_range time_range def __bool__(self): return bool(self.metrics and self.time_range)实现__bool__之后if config:就能按业务语义判断配置是否有效。同时要注意如果类实现了__bool__但没实现__len__那么if obj走的是__bool__反过来如果只实现了__len__没实现__bool__那么空对象会被判定为False——这正是空列表、空字典、空字符串能参与布尔判断的底层原因。4. 让对象融入语言核心容器协议、迭代协议与属性访问如果说前面几章解决的是对象自身的表现那么容器协议和迭代协议解决的就是对象如何与 Python 的语言机制协作。这些协议掌握之后你的自定义类就能和内置的列表、字典、生成器一样无差别地参与切片、for 循环、成员判断等高阶用法。4.1 用len和getitem复刻不可变序列Python 里对于一个支持len()和下标访问的对象最底层的协议就是__len__和__getitem__。只要实现了这两个方法你的对象就能享受序列类型的很多免费能力for循环遍历、in成员判断、切片访问甚至reversed()、itertools里的各种工具函数都会自动生效。为什么__getitem__只实现一个方法就能支持这么多能力因为 Python 的迭代协议有一个向后兼容的机制当解释器发现对象没有__iter__时会回退到用__getitem__配合计数器依次取元素直到抛出IndexError为止。这也是为什么老代码里很多类只写了__getitem__就能被 for 循环遍历。一个典型的场景是封装数据库查询结果。假设你有一个 SQLite 查询结果对象希望它像列表一样可以使用就可以这么做class QueryResult: def __init__(self, rows): self._rows rows def __len__(self): return len(self._rows) def __getitem__(self, index): if isinstance(index, slice): return QueryResult(self._rows[index]) return self._rows[index]注意__getitem__的index参数不只是整数它也可以是 slice 对象。上面代码里已经处理了切片的情况返回一个新的QueryResult保持了类型一致。这样调用方拿到切片后依然可以继续用len()和for遍历体验和列表几乎一样。4.2 真正理解迭代协议iter与next如果你需要的是一次性遍历的对象或者需要逻辑更复杂的迭代行为就要实现__iter__和__next__。__iter__返回迭代器对象自身__next__每次返回下一个元素没有元素时抛StopIteration。这里有一个非常关键且常被误解的点迭代器和可迭代对象是两个概念。可迭代对象是实现了__iter__或__getitem__的对象它负责提供迭代器迭代器是实现__iter__和__next__的对象它负责具体产出元素。最简单的区分方式是看for循环这一步for i in obj先调用obj.__iter__()拿到一个迭代器然后循环调用迭代器的__next__()。很多新手在一个类里同时写__iter__和__next__让类的实例既充当可迭代对象又充当自己的迭代器。这种做法在小例子里能工作但有一个隐蔽缺陷同一个实例被两次 for 循环遍历时第二次会直接从上次结束的位置继续结果造成数据少了一段。标准做法是让__iter__返回一个独立的迭代器对象或者干脆用生成器实现class Playlist: def __init__(self, songs): self.songs songs def __iter__(self): for song in self.songs: yield song因为函数里包含了yield这个__iter__就是生成器函数每次调用都会返回一个全新的生成器对象。生成器自身就是迭代器同时每次迭代状态互不干扰这才是最干净、最符合直觉的方案。4.3 属性访问协议getattr与setattr的两面性属性访问协议包含三个核心方法__getattr__、__setattr__、__delattr__还有一个容易混淆的__getattribute__。它们控制着obj.attr和obj.attr value这两种语法的底层行为。__getattr__只在正常属性查找失败时才会被调用也就是兜底钩子适合用来实现懒加载、动态代理、兼容旧字段名等。__getattribute__则是每次属性访问都会触发性能开销大而且极易写出死循环我建议非必要不去动它。__setattr__是所有属性赋值都会触发的钩子。它最经典的用途是参数校验与规范化class Temperature: def __init__(self, celsius): self.celsius celsius def __setattr__(self, name, value): if name celsius and value -273.15: raise ValueError(温度不能低于绝对零度) super().__setattr__(name, value)这里必须调用super().__setattr__来真正完成赋值操作否则就会陷入无限递归——直接在__setattr__里写self.celsius value等于又一次触发__setattr__递归到栈溢出。这个错误基本上每个自定义__setattr__的新手都会踩一次我自己第一次写也中招了调试时一秒钟就爆出了RecursionError。5. 工程场景大杀器上下文管理器协议与描述符协议前面讨论的协议解决的是一个对象如何表现自己现在进入两个对工程代码结构影响最深远的协议__enter__/__exit__上下文管理协议以及__get__/__set__/__delete__描述符协议。前者让资源管理和异常处理变得优雅后者则是一切现代 Python 框架中的属性机制的基石。5.1 自定义上下文管理器with 语句背后的三件事with open(...) as f是每个 Python 开发者天天在用的语法但很多人没有意识到with语句的执行机制就是两个魔法方法进入时调用__enter__退出时调用__exit__。__exit__接收三个参数异常类型、异常实例、回溯信息。如果方法返回True表示异常已经被吞掉处理了不会向外抛出返回False或不返回异常会继续传递。实际操作中我最常用的场景有两个。第一个是数据库连接的事务管理class Transaction: def __init__(self, conn): self.conn conn def __enter__(self): cursor self.conn.cursor() cursor.execute(BEGIN) return cursor def __exit__(self, exc_type, exc_val, exc_tb): if exc_type is None: self.conn.commit() else: self.conn.rollback() return False这样每个事务块只管写业务逻辑提交和回滚交给上下文管理器统一处理。第二个场景是超时控制——通过信号量或线程定时器在__enter__里启动计时在__exit__里清理能有效防止某个外部 API 调用长期挂死。还有一个化简写法用标准库contextlib.contextmanager配合生成器和yield可以把上下文管理器压缩成一个函数适合简单的单块逻辑不必专门定义类。不过当你的上下文需要携带状态数据供 with 块内部使用时手动定义类的可读性更强也更适合后续扩展。5.2 描述符协议理解 Django、SQLAlchemy 属性系统的钥匙描述符是 Python 元编程里最核心的概念之一但多数初学者对它很陌生。简单说描述符是一个类它实现了__get__、__set__或__delete__中的一个或多个方法。当一个描述符对象被作为另一个类的类属性时对这个类实例进行属性访问会触发描述符的相关钩子。典型例子是property的实现方式。简化版的property本质就是一个描述符class PositiveNumber: def __set_name__(self, owner, name): self.name name def __get__(self, obj, objtypeNone): if obj is None: return self return getattr(obj, f_{self.name}) def __set__(self, obj, value): if value 0: raise ValueError(不允许负数) setattr(obj, f_{self.name}, value) class BankAccount: balance PositiveNumber() def __init__(self, initial_balance): self.balance initial_balance这个类里balance不是一个普通属性而是一个描述符实例。当执行account.balance 500时解释器发现BankAccount.balance描述符有__set__就会调用它去执行校验后再存到实例的_balance私有属性里。Django 里的models.CharField、SQLAlchemy 里的Column底层都是这个机制——通过描述符实现字段的类型转换、数据库读写、变更追踪等一系列横切逻辑。__set_name__是 Python 3.6 才加入的钩子它在描述符被绑定到类的阶段被调用一次告诉你描述符的变量名。这解决了老代码里描述符必须手动传入属性名的问题极大地提升了代码的整洁度。5.3 使用协议时最常踩的四个坑讲了那么多方法论现在集中盘点我在实际项目里见过、踩过的高频坑。第一个是可变对象参与哈希。只要你的对象重写了__eq__并且对象内部字段可变那它被放进set或作为字典 key 后一旦字段改变会直接导致该对象在容器中失踪——你说它在里面但查找时哈希值对不上。第二个坑是拷贝行为不受控。copy.copy和copy.deepcopy默认会递归复制实例的__dict__这对绝大多数类没问题但如果类里持有数据库连接、文件句柄、线程池这类不可序列化的资源深浅拷贝都会炸出奇怪异常。解决办法是手动实现__copy__和__deepcopy__在拷贝时对机器资源字段做特殊处理——比如共享连接而不是复制连接。第三个坑是__getattr__里抛 AttributeError 的怪圈。__getattr__本身就是兜底钩子如果它内部为了获取某个辅助字段又去访问了一个不存在的属性这第二次访问还会再触发一次__getattr__导致无限递归。在方法内部访问自身其他属性时最保险的方式是直接读self.__dict__。第四个坑是对可迭代对象反复使用迭代器自身。前面提过迭代器是一次性的。如果你把一个迭代器传给两次for循环第二次将直接跳过全部内容因为迭代器已经走到了StopIteration。把迭代器整体转成列表再复用或者每次调用都重新生成新的迭代器是两个可靠的规避方案。6. 从代码整洁度看魔法方法什么该实现什么不该碰聊完协议细节最后想讨论一个偏品味的话题。魔法方法虽然强大但并不是实现得越多越好。我见过一些过度设计的代码类上塞了十几个双下划线方法实际用到的只有两三个剩下的全是为了看起来专业而硬凑的。这种代码维护起来非常痛苦因为你根本不知道哪个方法正被某个第三方库隐式调用。一个务实的取舍原则是从调用场景反推方法。你的对象会被放进集合做去重吗就实现__eq____hash__。会被打印成日志吗就实现__repr__。会被排序吗就实现__lt__。会被with管理资源吗就实现__enter____exit__。其它方法等真遇到需求再加完全可以。与此相对的有一批魔法方法我建议永远不要碰__getattribute__每次属性访问都全量触发性能差且极易写坏__del__析构时机完全不可预知用它做清理往往不如with可靠__metaclass__这类元类协议没有处理多继承复杂局面的把握不要轻易进入。还有一个小细节值得养成习惯当方法收到的对象类型不匹配时返回系统常量NotImplemented而不是直接抛TypeError。NotImplemented告诉解释器当前对象不支持这个操作请尝试另一侧对象的反向方法这样你的类和别人的类可以组合出更多可能。直接抛异常看似严格实际上把协作的路都堵死了。最后聊一下我自己的学习路径。当年我把魔法方法完整过了一遍之后印象最深的其实不是方法本身而是 Python 官方文档里那句special methods are the way Python implements protocols。这句话让我意识到所谓Pythonic核心就是遵守协议。一个类如果有良好的协议实现它能以极低的成本接入标准库的海量工具函数——可以和itertools一起做数据管道可以被pickle序列化可以被dataclasses接手管理样板代码这在动态语言里的收益是巨大的。希望这篇笔记能帮你把魔法方法从神秘的双下划线变成工具箱里随时可用的工具。从今天起每当你写一个类不妨先问一句这个对象在 Python 的生态里应该扮演什么角色它需要遵守哪些协议答案会直接引导你写出更优雅、更少 bug 的代码。