
写这篇文章前我先说点实在的。很多人学Python时看到__init__、__str__这类带双下划线的方法总觉得神秘兮兮甚至觉得这是高手才需要懂的深奥东西。但如果你实际写过一些自定义类你会发现这些所谓的魔法方法一点都不魔法——它们就是Python解释器和代码之间定好的约定到了特定时机解释器会自动去调用这些方法。你写不写它们就在那里Python都有默认实现。你学会覆写它们就能让自己的类表现得更像Python原生的对象。这篇文章面向从零基础到进阶之间的读者也适合对Python语法熟悉但没系统梳理过对象协议的开发者。我会从最基础的魔法方法到底是什么讲起一路拆解__init__、__str__、运算符重载、容器协议、上下文管理器、属性管理最后附上我实际编码中踩过的问题和排查思路。全程用能跑通的代码示例说话尽量让每个结论都有凭有据。1. 揭开魔法的面纱魔法方法到底是什么1.1 所谓魔法其实是解释器约定我们在代码里写print(obj)或len(obj)时实际上是在调用对象背后定义好的协议方法。Python官方的叫法是特殊方法special methods或双下划线方法dunder methods之所以被称为魔法方法其实是社区给的昵称——因为它们在特定语法糖触发下自动执行看起来像暗搓搓地生效了。举个例子你现在写下class Person: def __init__(self, name): self.name name p Person(张三)第二行看似简单但解释器在背后做了这么件事先是调用Person.__new__(Person, 张三)创建一个空实例然后调用__init__给实例填充name属性。你看不到__new__的执行因为默认的object.__new__已经够用了只有极少数场景才需要覆写它。再比如你写str(p)或者print(p)解释器会查找p.__str__()如果没定义退回到__repr__()如果__repr__()也没定义就用object基类里的默认实现返回一串类似__main__.Person object at 0x10a1a2e80的字符串。这就是为什么很多初学者打印一个未定义__str__的对象时看到的是一串看不懂的内存地址。所以魔法方法并不是什么黑魔法。它的本质是Python把一些常用语法操作映射到了方法调用上。你覆写这些方法就能改变对象在这些语法操作下的行为。1.2 魔法方法在哪儿找dir()和官方文档一个新接触的类怎么看它支持哪些魔法方法最简单的办法是用内置函数dir()print([m for m in dir(list) if m.startswith(__)])你会看到__add__、__len__、__iter__、__getitem__等一长串名字。这说明每个内建类型其实就是一堆协议方法的组合。list能做切片、迭代、加法连接、长度计算本质上都是实现了对应协议方法。官方文档Data Model部分有一个完整表格列出了所有64个特殊方法包括算术运算、比较运算、容器操作、属性访问、上下文管理等。我的建议是不要硬背先掌握高频的几个遇到我希望这个对象支持某个操作时再回去查表。比如你想让自定义对象支持obj[key]这种下标访问那就查__getitem__想让对象支持with语句就去查__enter__和__exit__。实际工作中我80%以上的自定义类只会用到__init__、__repr__、__eq__这三个。其余的魔法方法通常是为了让自己的类用起来像标准库类型或者在写框架、库时给使用者提供更顺滑的体验才会全部用上。2. 第一梯队对象生命周期与字符串表示2.1init、new、__del__的配合__init__是每个人早就会用的方法作用是初始化一个已经创建好的实例。它的兄弟__new__是在实例创建之前被调用的类方法负责返回一个实例。两者关系可以理解为建房子和装修的关系__new__负责打地基、搭毛坯房__init__负责刷墙、装家具。平时我们只需要写__init__。但有两个场景必须碰__new__一是实现不可变类型的子类比如继承tuple、str。因为不可变对象在__init__之前就已经确定了自己的值覆写__new__才能设置初始内容。class UpperStr(str): def __new__(cls, value): return super().__new__(cls, value.upper()) s UpperStr(hello) print(s) # HELLO二是实现单例模式。但说实话Python里单例模式有更简单的方案直接用functools.lru_cache装饰构造函数或者模块级单例就可以了不一定要折腾__new__。__del__是析构方法在对象被垃圾回收时调用。这里有一条很实用的经验不要指望__del__里的代码一定会按你的预期执行。比如你写一个文件资源管理类想在__del__里关闭文件但实际上对象的引用计数降到0的时机不完全由你控制程序异常退出、循环引用的情况下__del__可能被延迟甚至不调用。官方更推荐的资源清理方式是用上下文管理器也就是我们后面要讲的with语句。2.2 __str__和__repr__的区别这两个方法新人最容易搞混。一句话讲清楚__str__面向用户追求可读性__repr__面向开发者追求无歧义、可复现通常返回的字符串应该能用来重新构造这个对象。看一个对比from datetime import datetime now datetime.now() print(str(now)) # 2025-01-18 14:30:00.123456 print(repr(now)) # datetime.datetime(2025, 1, 18, 14, 30, 0, 123456)str()给用户看repr()给调试时看。所以我的习惯是任何自定义类都优先实现__repr__因为print()、异常信息、日志记录在没有__str__时都会退回到__repr__。如果__repr__写得好日志里排查问题会轻松很多。给一个适合作为模板的写法class Vector: def __init__(self, x, y): self.x x self.y y def __repr__(self): return fVector({self.x!r}, {self.y!r}) def __str__(self): return f({self.x}, {self.y})注意!r这个格式化标志它的作用是调用repr()而不是str()。这样即使x本身是字符串返回的repr结果也会带引号之后复制能直接用于重构对象。这个小细节很多教程不会提但效果相当明显。3. 运算符重载让自定义类支持加减乘除3.1 __add__等二元运算符Python的运算符本质上是语法糖。比如a b解释器会尝试调用a.__add__(b)如果a的__add__没有实现或返回NotImplemented再尝试b.__radd__(a)。这个反向操作的机制在两边是不同类型时特别有用。我们实际写一个二维向量类让它支持加法和减法class Vector: def __init__(self, x, y): self.x x self.y y def __add__(self, other): if isinstance(other, Vector): return Vector(self.x other.x, self.y other.y) return NotImplemented def __sub__(self, other): if isinstance(other, Vector): return Vector(self.x - other.x, self.y - other.y) return NotImplemented def __repr__(self): return fVector({self.x!r}, {self.y!r}) v1 Vector(1, 2) v2 Vector(3, 4) print(v1 v2) # Vector(4, 6)注意我在类型不匹配时返回NotImplemented而不是报错。这是Python协议的一条重要约定如果方法不认这个类型应该返回NotImplemented让解释器去尝试对方的反射方法。如果你直接抛TypeError反而会阻断这个协商流程。乘法和除法类似对应__mul__、__truediv__。还有原地操作符对应__iadd__它和__add__的区别是__iadd__允许在原始对象上就地修改不新建对象。对于可变容器如listlist e的效率比list list e高不少因为后者要重新创建列表。3.2 比较运算符__eq__、__lt__等比较运算对应__eq__、__ne__、__lt__、__le__、__gt__、__ge__。理论上要写6个方法但实际中借助functools.total_ordering可以只实现一个__eq__加一个__lt__装饰器自动补齐其余比较方法。写一个学生成绩类来演示from functools import total_ordering total_ordering class Student: def __init__(self, name, score): self.name name self.score score def __eq__(self, other): if not isinstance(other, Student): return NotImplemented return self.score other.score def __lt__(self, other): if not isinstance(other, Student): return NotImplemented return self.score other.score def __repr__(self): return fStudent({self.name!r}, {self.score}) s1 Student(小明, 85) s2 Student(小红, 92) print(s1 s2) # True print(s1 s2) # True由total_ordering补全 print(s1 s2) # False还需要注意实现__eq__之后Python会默认把__hash__置为None导致对象不可哈希不能放进set和作为dict的key。原因很合理——两个对象相等时哈希值必须一致你改变了相等定义原来的哈希规则就可能失效。如果你的对象确实需要放进set要么不覆写__eq__要么同时覆写__hash__保证相等的两个对象返回相同的哈希值。这个坑我见过不少同事踩过报错信息是TypeError: unhashable type: Student排查半天才发现是__eq__引起的。4. 容器协议把自己变成list和dict4.1getitem、setitem、len如果你想让对象支持obj[i]、obj[i] x、len(obj)这些操作需要实现容器协议。Python中有两层容器协议只读容器实现__len__和__getitem__可变容器再加__setitem__和__delitem__写一个只读的卡片栈类class Deck: def __init__(self, cards): self._cards list(cards) def __len__(self): return len(self._cards) def __getitem__(self, index): return self._cards[index] def __repr__(self): return fDeck({self._cards!r}) deck Deck([A, K, Q, J]) print(len(deck)) # 4 print(deck[1]) # K print(deck[1:3]) # [K, Q]因为__getitem__支持切片 print(deck[::-1]) # [J, Q, K, A] print(A in deck) # True因为__getitem__提供成员判断支持这里有个非常有趣的效果只要实现了__getitem__和__len__你的对象就能免费获得切片、成员判断、倒序、迭代这些能力。因为Python的list内部操作会退回到协议方法层面来处理。for x in deck能遍历也是因为解释器发现没有__iter__时会尝试按__getitem__从0开始逐个取取到IndexError就停止。这个特性让我经常用一句话向同事解释为什么协议方法值得认真学习一个类实现了__getitem__它已经不是像序列了它在你写的很多通用代码里就是序列。4.2 __iter__和__next__实现可迭代上节说了只用__getitem__也能迭代但效率低且不够语义化。自定义类要成为名副其实的可迭代对象应该实现__iter__如果要同时成为一个迭代器还需要实现__next__。两者的关系经常有人搞混。简单区分可迭代对象iterable实现了__iter__每次iter(obj)都会返回一个新的迭代器迭代器iterator实现了__iter__和__next____next__每次调用都会推进一位直到StopIteration结束我自己写一个只在本地生成部分结果的Fibo类class Fibo: def __init__(self, n): self.n n self.a 0 self.b 1 def __iter__(self): return self def __next__(self): if self.a self.n: raise StopIteration result self.a self.a, self.b self.b, self.a self.b return result for x in Fibo(100): print(x, end ) # 0 1 1 2 3 5 8 13 21 34 55 89但要注意让对象自己也是自己的迭代器__iter__返回self有个副作用一旦迭代到底这个对象就耗尽了不能重新遍历。这也是Fibo只适合单次使用的原因。如果你需要支持多次遍历正确做法是每次__iter__都返回一个新的迭代器对象像range那样class FiboRange: def __init__(self, n): self.n n def __iter__(self): return FiboIterator(self.n) class FiboIterator: def __init__(self, n): self.n n self.a 0 self.b 1 def __iter__(self): return self def __next__(self): if self.a self.n: raise StopIteration result self.a self.a, self.b self.b, self.a self.b return result实际开发中我一般不会手写这类迭代器用生成器函数或iter()配合lambda更省事。但理解这个两层结构对排查为什么循环第二次没有数据这类问题至关重要。5. 上下文管理器与with语句5.1enter、exit凡是需要进入时准备资源退出时清理资源的场景就应该用上下文管理器。文件操作的with open(...) as f就是最经典的例子。自己实现一个计时上下文管理器感受一下协议的力量import time class Timer: def __enter__(self): self.start time.perf_counter() return self def __exit__(self, exc_type, exc_value, traceback): self.elapsed time.perf_counter() - self.start print(f耗时 {self.elapsed:.4f} 秒) # 返回 False 或 None 表示异常需要向上抛出 # 返回 True 表示吞掉异常 with Timer(): time.sleep(0.5)__exit__接收exc_type、exc_value、traceback三个参数分别代表异常类型、异常实例和堆栈信息。如果在with块内没有发生异常三个参数都是None。这里有一个很多教程会忽略的关键点__exit__的返回值决定异常是否被吞掉。返回False/None异常继续抛出返回True异常被吞掉with块正常结束。这个特性可以用来做资源清理时忽略特定异常但我建议平时不要轻易吞异常否则排查问题时会一头雾水。5.2 contextlib简化方案如果只是想要一个简单的上下文管理器没必要写一整个类。标准库contextlib提供了三个高频工具contextmanager装饰器和closing、suppress辅助函数。用生成器实现同样计时功能代码更紧凑from contextlib import contextmanager import time contextmanager def timer(): start time.perf_counter() try: yield # yield 之前的代码等价于 __enter__之后的等价于 __exit__ except Exception as e: raise finally: elapsed time.perf_counter() - start print(f耗时 {elapsed:.4f} 秒) with timer(): time.sleep(0.5)yield之前的部分在进入with时执行yield本身把控制权交还给with块内的代码yield之后的部分在退出with时执行。try/finally保证即便with块内抛异常清理代码也会跑。另一个实用工具是closing它自动调用任何对象的close()方法from contextlib import closing class MyResource: def close(self): print(资源已关闭) with closing(MyResource()) as res: ...这让我免于为一些只有一个close()方法的类写完整的上下文管理器类。在写爬虫时处理session连接在写文件处理时包装临时资源都比较顺手。6. 进阶技巧属性管理学的不只是调用6.1getattr、setattr、getattribute这几个方法是属性访问协议的核心但不建议新手一开始就深入因为它们引发的递归坑非常隐蔽。__getattr__仅在属性正常查找失败时被调用__getattribute__任何属性访问都会经过它介入面很大__setattr__任何属性赋值都会经过它经典应用是懒加载属性和动态代理。比如我们做一个配置类希望不存在的属性自动从环境变量读取import os class EnvConfig: def __getattr__(self, name): if name.isupper(): return os.environ.get(name) raise AttributeError(f{name}) config EnvConfig() print(config.PYTHON_HOME) # 输出环境变量PYTHON_HOME的值注意__getattr__有个陷阱如果内部代码访问了不存在的self._data属性解释器会再次调用__getattr__导致无限递归最终报RecursionError。所以上面代码中但凡需要访问实例内部属性都必须用object.__getattribute__(self, name)这种硬编码方式或者在__getattr__里避免访问self的其他属性。其实我这个示例里没有访问self._data但写这类代码时务必清楚这个边界。真实业务中__setattr__也很实用。比如想限制某个类的属性类型或者让赋值自动触发事件class Eventful: def __init__(self): object.__setattr__(self, data, {}) def __setattr__(self, name, value): print(f设置 {name} {value}) object.__setattr__(self, name, value) obj Eventful() obj.x 10 # 输出: 设置 x 10在__init__里给data赋值时如果不通过object.__setattr__绕过会递归调用__setattr__导致死循环。这是使用属性拦截方法时最需要养成的好习惯凡是给实例属性直接赋值都用object.__setattr__绕过拦截层。6.2 __call__让对象像函数一样可调用一个类实现了__call__它的实例就能像函数一样被调用。这个特性在搞状态持有型函数、实现轻量级装饰器时非常好用。举个例子记录函数被调用的次数class CallCounter: def __init__(self, func): self.func func self.count 0 def __call__(self, *args, **kwargs): self.count 1 return self.func(*args, **kwargs) CallCounter def greet(name): return f你好{name} print(greet(小明)) print(greet(小红)) print(f调用次数: {greet.count}) # 2CallCounter的本质是把greet函数替换成CallCounter实例每次调用greet(小明)实际触发的是实例的__call__。这是在你不使用functools.wraps技术的前提下自己实现带状态装饰器的最直观方式。很多人学到这里会问这和闭包有什么区别区别在于__call__实例是类对象天然支持继承、序列化、类型判断而闭包是函数对象。当装饰器逻辑较复杂或需要多个同类型装饰器时类加__call__会更清晰简单场景用闭包更简洁。两者没有孰优孰劣按需选。7. 常见错误与排查技巧实录7.1 覆写魔法方法却没调super()这个错误在继承场景中特别常见。比如子类覆写__init__但忘了调用super().__init__()父类初始化的属性就缺失了报AttributeError时很难从堆栈里直观看出是初始化不完整的问题。class Base: def __init__(self): self.base_attr 1 class Child(Base): def __init__(self): pass # 漏了 super().__init__() c Child() print(c.base_attr) # AttributeError覆写其他魔法方法也一样。比如你把__eq__写了但忘记调用super().__eq__在同时使用和hash的混合对象场景下会得到不兼容行为。我的习惯是基类能跑通就用super()传递一下不能确定父类做了什么就尽量显式标注。7.2print(obj)显示的明明是repr效果我写str没生效原因就一条print()调用str()str()优先找__str__找不到就退到__repr__。如果你只实现了__repr__打印自然显示repr效果。如果你实现了__str__但没生效先检查拼写是不是写成了__str_少一个下划线或者方法写在了错误类里。这属于新人高频低级错误。我用一个自查清单来排查现象可能原因排查/解决print(obj) 出现内存地址未实现__repr__至少实现__repr__print(obj) 输出了带引号字符串只实现了__repr__补充__str__返回无引号字符串len(obj) 报TypeError未实现__len__实现__len__返回整数对象无法放入set实现了__eq__导致__hash__被置空同时实现__eq__和__hash__with obj 报错未实现__enter__/__exit__检查上下文管理器协议属性访问递归爆炸__getattr__或__setattr__内访问self改用object.__getattribute__/object.__setattr__7.3NotImplemented和NotImplementedError是两个东西这两个名字太容易混淆但含义天差地远。NotImplemented是单例对象在运算符重载方法里返回告诉解释器这个操作我处理不了请试试对方的协议方法NotImplementedError是异常类代表方法应该被覆写但还没实现。把后者当作返回值返回代码不会报错但逻辑彻底错乱因为异常没被抛出一切还在正常运行但行为完全不对属于最难排查的隐性bug。我自己就在代码评审时看过同事把这两个写混。7.4 不滥用__getattribute____getattribute__是所有属性访问的必经之路覆写它相当于给整个类的属性访问都装上了一个拦截器运行开销大且极易误伤。比如在__getattribute__里访问self.data会再次触发__getattribute__形成无限递归。有两种安全写法对应不同意图class SafeProxy: def __init__(self, target): # 绕开拦截直接设置基础属性 object.__setattr__(self, _target, target) def __getattribute__(self, name): if name _target: # 放行内部属性避免递归 return object.__getattribute__(self, name) target object.__getattribute__(self, _target) return getattr(target, name)但是说实话真到需要代理的场景我通常优先考虑__getattr__因为它只在正常查找失败时触发影响范围小得多逻辑也更清楚。能用范围小的方案就不动用大范围的。顺便说一句很多人以为__getattr__可以实现默认属性值但要注意它也会拦截hasattr检查。当你调用hasattr(obj, xxx)判断某个属性是否存在时__getattr__如果返回了默认值hasattr会得到True。这在某些测试或配置类里会引发明明没这个配置却总说有这个配置的诡异现象。解决方式是区分场景比如限定的属性名才在__getattr__里做默认值处理其他一律抛AttributeError。8. 我的几个实操心得写魔法方法这个专题我印象最深的一个项目是给团队做一套内部ORM的外键字段。那个字段对象要支持比较算符、哈希、布尔判断还要在repr时输出可读的关联表名。一开始我老老实实把所有魔法方法一个个写出来结果代码行数飞快膨胀维护起来异常痛苦。后来我抽了一个基类把__eq__、__hash__、__repr__这些通用协议集中写好子类只需要定义几个数据属性复用起来非常顺。这套思路后来也影响了我写所有自定义类的方式先想清楚这个类的使用套路——它会被print吗会被放进set吗会被len()吗会被with吗然后按需逐个实现协议方法。不需要一次性把所有魔法方法都写齐只覆写真正影响使用体验的那几个。另一点值得提的是写魔法方法时最好配合类型注解。魔法方法的名字和签名是固定的但参数类型可以让注解表达清楚比如def __add__(self, other: Vector) - Vector。Python的协议方法签名比较特殊比如__eq__(self, other)的返回值类型注解最好是bool实际上返回NotImplemented也是被允许的。如果你用typing做严格检查可以借助overload让IDE提示更准确但这属于进阶玩法了解存在即可。还有一点实操经验调试魔法方法时与其一行行print推测不如直接打断点跟进去看解释器什么时机调用了哪个方法。比如在PyCharm或VS Code里给__getattr__、__setattr__这些拦截方法设断点你会发现很多莫名行为其实就是属性读取引发的连锁调用。这个方法速度远快过背文档所以我也建议每个走到进阶阶段的学习者都去建几个自定义类实际断点走一遍属性加载和运算符计算的过程。至于更深入的扩展方向比如用__enter__/__exit__实现事务型资源管理、用__setattr__做数据验证、用__getattr__做远程接口的延迟代理这些都是同类协议在不同业务下的落地方案。你把这套协议思维打通了遇到什么需求都能先想到哪几个魔法方法能帮我实现这个行为这比记任何语法清单都值钱。