1. 魔法方法到底是什么:从一次好奇开始
1.1 一次真实的"不明觉厉"现场
先说个我自己的经历。很久以前我在看同事写的代码时,撞见了一个看起来非常"高级"的类:
class User: def __init__(self, name, age): self.name = name self.age = age def __repr__(self): return f"User(name={self.name!r}, age={self.age})" def __eq__(self, other): if not isinstance(other, User): return NotImplemented return (self.name, self.age) == (other.name, other.age) def __lt__(self, other): return self.age < other.age然后我看到他写了一句:
users.sort()列表里头一堆 User 对象,居然直接按年龄排序了。我当时的第一反应是:这代码怎么跑得起来?后来钻研了一下才发现,Python 之所以能做到这一点,靠的全是这些双下划线包裹的方法——也就是我们常说的魔法方法。
再比如print(user)能输出User(name='张三', age=25)这种友好的文本,不是天掉下来的能力,而是代码里实现了__repr__。user1 == user2能正确地比较内容而不是比较内存地址,是__eq__在起作用。
你现在可能已经写了一段时间 Python,或者正准备学。不管是哪类人,我建议你尽早花时间把魔法方法这套东西啃下来。原因很简单:Python 的很多"语法糖"、运算符行为、内置函数行为,本质都是调用这些方法。你理解了它们,就拿到了"Python 对象模型"的钥匙,而不只是背 API。
1.2 魔法方法的本质:解释器触发的协议
很多人第一次看到__init__的时候,心里想的是:这不就是构造函数吗?好,记住这个类比,但请再往前走一步。
在 C++、Java 里,"构造函数"是语言层面的关键字语法,你知道它是被new调用的。但在 Python 里,__init__不是被你在代码里主动调用的,而是被解释器在特定时机自动触发的。你写user = User("张三", 25)这行代码,Python 解释器会:
- 调用
__new__创建对象实例; - 调用
__init__初始化这个实例; - 把结果赋给变量
user。
这就是协议。所谓协议,就是"你只要实现了某个方法,Python 在某种条件下就会约定俗成地去调用它"。你不实现,对象就退化为默认行为;你实现了,对象就拥有了某种"超能力"。
我再打个比方。魔法方法就像插座接口。你不需要知道发电厂是怎么发电的,你只需要让你的电器有符合标准的插头,插上去就能通电。这里的"标准插头"就是协议中的方法签名和返回约定。比如实现__len__,你的对象就能被len()函数使用;实现__iter__,你的对象就能被for循环遍历。
理解到这一层,你就不再是"背方法"了,而是在"理解对象的运行规则"。
1.3 命名规律:双下划线的"防撞"设计
你一定很好奇,为什么魔法方法名字两侧都要加双下划线?直接命名成init、repr、len不行吗?
这里有个非常实际的原因:避免和用户自定义的方法名冲突。
在开发一个大型项目时,你可能会给类定义len方法表示"获取长度",其他同事可能会定义size、count。如果 Python 官方把这些名字都写死在底层协议里,那用户很容易不小心覆盖掉它们,导致解释器行为异常。加上双下划线之后,__len__这个命名空间就极大概率不会和普通业务方法撞车。
另外你还会发现一个规律:魔法方法是给解释器用的,不是给你主动调用的。你应该写len(obj),而不是obj.__len__()。当然你主动调用obj.__len__()也能跑,但这不符合惯例。官方之所以把内置函数设计成调用魔法方法,是为了让你始终面向"语义化"的 API 进行操作,而不是面向底层细节。
同样地,Python 里还有一些"半魔法方法",比如__getattr__、__setattr__、__getattribute__,这些名字都遵循同样的防撞逻辑。只要理解了双下划线的存在意义,你看到任何__xx__都不会觉得害怕了。
2. 最常用的构造与生命周期方法:从入门到会用
2.1init和new的分工与正确打开方式
绝大多数 Python 初学者接触到的第一个魔法方法是__init__,这很自然,因为每个类基本都需要它。但如果你觉得自己已经"会了__init__",我建议你停下来把__new__也搞清楚,因为这两个方法的关系太紧密了。
简单来说:
__new__负责创建对象,它在对象还没诞生时就被调用,必须返回一个新实例;__init__负责初始化对象,接收__new__返回的实例,给它填充属性。
class Point: def __new__(cls, x, y): print("new 被调用") obj = super().__new__(cls) return obj def __init__(self, x, y): print("init 被调用") self.x = x self.y = y当你执行p = Point(1, 2)时,控制台会依次打印new 被调用和init 被调用。注意__new__的第一个参数是cls(类本身),而__init__的第一个参数是self(实例本身)。
在 99% 的业务代码中,你只需要写__init__,不需要动__new__。但有两个经典场景必须用__new__:
第一个是不可变对象的子类。比如你想定义一个继承自tuple的类,给元组加一些行为,此时__init__不会起作用,因为元组一旦创建就不可更改。你只能在__new__里做文章:
class NamedTuple(tuple): def __new__(cls, *args): if len(args) != 2: raise ValueError("需要两个元素") return super().__new__(cls, args)第二个是单例模式。通过__new__控制每次创建实例时返回同一个对象:
class Singleton: _instance = None def __new__(cls, *args, **kwargs): if cls._instance is None: cls._instance = super().__new__(cls) return cls._instance这里有个容易踩的坑:如果同时实现了__new__和__init__,单例模式下__init__可能会被反复执行。你需要在__init__里做一次"是否已经初始化过"的判断,否则每次实例化都会重新覆盖属性。
2.2del、str与repr:别再把 str 当 repr 用
__del__是析构方法,在对象被垃圾回收时调用。我在实际开发中几乎不主动依赖它,因为 Python 的垃圾回收时机不可预测,你不能指望del obj之后就立刻执行__del__。它更多是留给那些需要释放外部资源的场景,比如关闭文件描述符、释放数据库连接。现代推荐做法是配合上下文管理器(后面会讲)使用,而不是依赖__del__。
真正和你天天打交道的是__str__和__repr__。这两者的区别很多人说不清,我帮你理顺:
__str__是给人看的,调用场景是str(obj)、print(obj);__repr__是给解释器看的,调用场景是交互式命令行里直接输入obj、repr(obj),理想的__repr__应当能"无歧义地重建这个对象"。
如果你只实现了其中一个,Python 的print(obj)会将就着用另一个。但为了调试体验,我强烈建议两个都写,尤其是__repr__。你在 IDE 的调试器里看到的一行行对象信息,基本都来自__repr__。
拿日志举例。假如你定义了一个Order类但不实现任何魔法方法,打印订单对象时你会看到<__main__.Order object at 0x7f9d8c5a3b20>。这串地址根本没法帮你定位问题。但如果你实现了:
class Order: def __init__(self, order_id, amount): self.order_id = order_id self.amount = amount def __repr__(self): return f"Order(order_id={self.order_id!r}, amount={self.amount})"然后打日志的时候,一条[2025-06-01 10:00:00] Order(order_id='A1001', amount=99.9) 已支付就能让排查效率翻倍。
2.3 一个综合的小实战:日志友好的实体类
来一个稍微综合的例子。假设你在写一个进销存系统,要定义一个商品Product类:
class Product: def __init__(self, sku, name, price, stock): self.sku = sku self.name = name self.price = price self.stock = stock def __repr__(self): return f"Product(sku={self.sku!r}, name={self.name!r}, price={self.price}, stock={self.stock})" def __str__(self): return f"{self.name}({self.sku}) 单价{self.price}元 库存{self.stock}件" def __eq__(self, other): if not isinstance(other, Product): return NotImplemented return self.sku == other.sku def __hash__(self): return hash(self.sku) def __lt__(self, other): return self.price < other.price这个类里出现了__eq__、__hash__、__lt__。它们的意义在于:
- 两个商品只要 SKU 相同就认为是同一个商品,不管 name 是否变动;
- 实现了
__hash__后,Product就可以放进set或作为dict的键; - 实现了
__lt__后,商品列表可以按price直接sort()。
这些行为在业务代码里非常有价值。你在做列表去重、排序、字典聚合时,不再需要写一堆冗长的 key 函数,对象自己就"懂规则"。
3. 运算符重载:让对象学会加减比较
3.1add的实战案例:一个金额计算的小问题
运算符重载是所有语言里最直观的"魔法",Python 允许你通过实现特定魔法方法,让+、-、*、==、<等运算符作用于自定义对象。这背后的机制非常简单:a + b会被解释为a.__add__(b)。
假设你在做财务计算,金额不能直接用浮点数(有精度问题),你封装了一个Money类:
from decimal import Decimal class Money: def __init__(self, amount: str): self.amount = Decimal(amount) def __add__(self, other): if not isinstance(other, Money): raise TypeError(f"Money 只能加 Money,不能加 {type(other)}") return Money(str(self.amount + other.amount)) def __repr__(self): return f"Money('{self.amount}')"于是你可以写:
wage = Money("3000.5") bonus = Money("1500.25") total = wage + bonus全程不需要手动把两个数字取出来相加再封装回去。代码的可读性和业务语义直接对齐。这就是运算符重载的核心价值:让自定义对象表现得像内建类型一样自然。
如果你要支持sum([wage, bonus, other])这种操作,还得额外实现一个方法叫__radd__(右侧加法)。因为sum函数从 0 开始累加,遇到Money时执行的是0 + wage,而整数 0 不知道怎么处理 Money,所以 Python 会退回到调用wage.__radd__(0)。常见写法:
def __radd__(self, other): if other == 0: return self return self.__add__(other)很多人在实现__add__之后发现sum用不了,原因就在这里。
3.2eq与hash的配对规则
==默认比较的是两个对象的身份标识(内存地址),即is。如果你希望两个内容相同的对象相等,就必须实现__eq__。但这里藏着一个大坑:一旦你实现了__eq__,Python 会把这个类的__hash__设为None,对象就变成不可哈希的了。
什么叫不可哈希?简单说就是不能放进set、不能作为字典的 key。你可能遇到过这样的报错:TypeError: unhashable type: 'User'。原因就是你定义了__eq__但没有重新定义__hash__。
修正方式很明确:如果需要保持可哈希,就同时实现__hash__,并且哈希值要跟相等的判断逻辑保持一致。上面的Product类里__hash__用的是hash(self.sku),而__eq__比较的也是self.sku,这两者就对齐了。
反过来也有反直觉的场景:如果你明确想让两个对象只按is比较(比如数据库连接池的对象、缓存 key),那干脆别实现__eq__,保持默认身份比较即可。
3.3 全套运算符协议速查
除了__add__,Python 还有一整套运算符协议。我在下表里列了出来,方便你查阅:
| 运算符 | 魔法方法 | 说明 |
|---|---|---|
+ | __add__/__radd__ | 加法,右加 |
- | __sub__/__rsub__ | 减法 |
* | __mul__/__rmul__ | 乘法 |
/ | __truediv__ | 真除法 |
// | __floordiv__ | 整除 |
% | __mod__ | 取余 |
< | __lt__ | 小于 |
<= | __le__ | 小于等于 |
> | __gt__ | 大于 |
>= | __ge__ | 大于等于 |
== | __eq__ | 等于 |
!= | __ne__ | 不等于 |
obj[key] | __getitem__ | 索引取值 |
obj[key] = value | __setitem__ | 索引赋值 |
in | __contains__ | 成员判断 |
看到这里你可能会想:这些东西平时用不到吧?其实用得到。比如写数据科学代码时自定义一个TimeSeries类,想让它支持ts1 + ts2拼接、ts[0]取值、date in ts判断,少了这些协议就得写一堆普通方法,可读性差好远。
另外,实现比较方法时有个省力技巧:只要实现了__lt__和__eq__,再用functools.total_ordering装饰器,Python 会自动补全<=、>、>=。虽然性能不如手写全部,但够用,也减少代码量。
4. 属性访问:getattr与setattr的陷阱和妙用
4.1 三兄弟的区别:getattr、getattribute、setattr
在 Python 里,属性访问不是表面看起来那么简单。每当你写obj.attr时,Python 会依次做检查。涉及三个核心方法,我先把它们的功能边界划清楚:
| 方法 | 触发时机 |
|---|---|
__getattribute__ | 每次访问属性时无条件调用(包括访问不存在的属性也一样会被拦截) |
__getattr__ | 只有在常规查找失败时才会调用 |
__setattr__ | 每次给属性赋值时调用,即obj.attr = value |
这个区别特别重要。__getattr__是"兜底"的存在,它只在属性不存在时被触发,所以实现它不会影响正常属性的访问性能。而__getattribute__是"全拦截",只要访问属性就过一道它的手,性能消耗大,还容易无限递归,不建议轻易重写。
新手最容易犯的错是在__getattribute__里写return self.attr,这会导致无限递归——因为访问self.attr再次触发__getattribute__。正确的写法是:
def __getattribute__(self, item): # 做一些处理 return object.__getattribute__(self, item)4.2 无限递归陷阱:setattr 里的经典翻车场面
__setattr__也是个容易翻车的地方。假设你想做一个类,限制某些属性是只读的:
class ReadOnly: def __init__(self): self.name = "readonly"如果直接写:
class ReadOnly: def __init__(self): object.__setattr__(self, "name", "readonly") def __setattr__(self, key, value): raise AttributeError(f"{key} 是只读的")其实不需要__init__里绕那么远,但这是后话。问题在于很多人会在__setattr__里写:
def __setattr__(self, key, value): if key == "locked": ... self.key = value # 错误!递归!这里的self.key = value又会触发__setattr__,一直循环到栈溢出。正确做法是调用父类方法:
def __setattr__(self, key, value): # 业务校验逻辑 super().__setattr__(key, value)我用一句话总结:在魔法方法内部,想绕过当前魔法方法去操作对象,就调用对应的object.__xxx__或super().__xxx__版本。这条规则适用于__setattr__、__getattribute__、__getattr__等多个地方。
4.3 实际应用:懒加载、动态代理和旧接口兼容
__getattr__有几个非常经典的生产场景。
第一个是懒加载。你有一个大对象,创建成本高,不想在类初始化时就加载:
class LazyDB: def __init__(self): self._data = None def __getattr__(self, item): if item == 'data': print("触发加载") self._data = load_from_disk() return self._data raise AttributeError(item)这种写法让数据在第一次真正访问时才加载,程序启动速度大大提升。
第二个是动态代理。假设你要封装一个第三方 SDK,SDK 提供了几十个方法,你不想一个个手动转写,可以用__getattr__把未知属性转发给内部对象:
class Proxy: def __init__(self, target): self._target = target def __getattr__(self, item): return getattr(self._target, item)第三个是旧接口兼容。你在重构代码时把User.get_name()改成了User.name属性,但线上还有大量调用老接口的地方。可以在新类里做个__getattr__:
def __getattr__(self, item): if item == "get_name": return lambda: self.name raise AttributeError(item)这样旧代码不用改,新代码用新写法,过渡期非常平滑。
5. 容器协议:把自己变成"可索引对象"
5.1len和getitem:最基础的序列协议
如果你想让自己写的类表现得像 list、tuple、dict,就需要实现容器协议。容器协议的核心是__len__和__getitem__。
__len__让len(obj)可用;__getitem__让obj[index]、切片obj[1:3]、循环for i in obj都可用。
来看一个简单的例子。假设你封装了一批传感器采集的温度数据:
class TemperatureSeries: def __init__(self, readings): self._readings = list(readings) def __len__(self): return len(self._readings) def __getitem__(self, index): return self._readings[index]这个类一写出来,你马上获得了这些能力:
temps = TemperatureSeries([22.5, 23.1, 24.0, 23.8]) len(temps) # 4 temps[2] # 24.0 temps[1:3] # [23.1, 24.0] for t in temps: # 循环迭代 print(t)几乎零成本地让自定义类"假装"成列表。为什么__getitem__能支持for循环?这是 Python 的旧式迭代协议:当一个对象没有__iter__时,解释器会退回去尝试用__getitem__从下标 0 开始不断取值,直到抛出IndexError。
5.2setitem、contains、iter:补齐字典和集合行为
只读的序列不够过瘾,大多数时候我们希望对象支持修改。这时候要加__setitem__:
def __setitem__(self, index, value): self._readings[index] = value加完之后,temps[0] = 21.0就合法了。如果你想支持某值 in obj的判断,可以加__contains__:
def __contains__(self, value): return value in self._readings不过我一般建议别在__contains__里做多余操作,直接返回布尔值即可。因为in运算符在内部对这个返回结果做了真值测试,返回什么类型不影响最终判断。
再有就是__iter__。前面说过,没有__iter__时解释器会用__getitem__当备用迭代方案,但那种方式性能一般,语义也受限(比如你希望迭代时按某种规则跳着取数)。一旦你实现了__iter__,它就是迭代的唯一通道:
def __iter__(self): return iter(self._readings)5.3 新旧迭代协议的兼容与一个小坑
Python 2 时代,迭代协议完全靠__getitem__从 0 开始累加索引完成。Python 3 时代,官方推荐使用__iter__+__next__。但很多老库还在依赖旧协议,所以你看到一个类没有__iter__却能被 for 循环遍历,别惊讶,那是__getitem__兜底了。
这里的坑是:如果你实现了__getitem__,但没有做越界检查,解释器在 for 循环里取到IndexError之前可能多取几次索引。比如你的数据从 1 开始编号,那 for 循环首先会尝试取 0,体会报错,然后认为没有更多元素了,导致遍历结果为空。解决办法有两个:要么老老实实实现__iter__,要么在__getitem__里处理越界时抛IndexError。
另外,还有一个判断"对象是否可迭代"的小技巧。很多人习惯写:
if hasattr(obj, "__iter__"): ...这个判断在遇到只实现了__getitem__的旧式迭代对象时会失灵。更可靠的判断方式是直接用iter(obj):
try: iter(obj) except TypeError: print("不可迭代")这样不管是新协议还是旧协议,都能正确识别。
6. 上下文管理器与可调用对象:with 语句和 callable 的本质
6.1enter和exit:实现自己的 with 支持
每个用 Python 的人都会写with open("file.txt", "r") as f:,但真正知道with背后原理的没那么多。with语句之所以能工作,是因为文件对象实现了__enter__和__exit__两个魔法方法。
__enter__在进入with块时被调用,返回值会被赋值给as后面的变量;__exit__在离开with块时被调用,无论是否发生异常都会执行。
这不只是open文件的专利。你可以给自己管理的资源类实现这两个方法,极大简化资源生命周期管理。举例,假设你有一个数据库连接类:
class DBConnection: def __init__(self, dsn): self.dsn = dsn self.conn = None def __enter__(self): print("建立连接") self.conn = create_connection(self.dsn) return self def __exit__(self, exc_type, exc_val, exc_tb): print("关闭连接") if self.conn: self.conn.close() return False然后调用方就不必每次 try/finally 处理关闭逻辑了:
with DBConnection("postgresql://localhost/mydb") as db: db.conn.execute("SELECT 1")这样即使 SQL 中途抛异常,__exit__里的关闭逻辑也会执行。代码的职责划分更清晰:资源获取和释放被固化在类里,业务代码只管用。
6.2exit的返回值:决定异常是否被吞掉
__exit__接收三个参数:exc_type(异常类型)、exc_val(异常实例)、exc_tb(traceback)。如果with块正常结束,这三个值都是None。如果块内抛了异常,它们就有内容了。
关于返回值,有一个非常容易被误解的规则:
- 返回
False(或不返回,默认就是None,相当于 False):异常会继续向外传播; - 返回
True:异常被吞掉,with块之后的代码继续执行。
我在实际项目中见过有人在这里踩坑。他想做一个"限流重试"的上下文管理器,希望在抛异常时重试,于是错误地返回了True,结果异常被吞掉,程序静默失败,日志一点线索都没有。正确用法应该是:__exit__只负责"清理资源",除非你明确想实现"异常屏蔽"(比如在某些特殊网关场景暂时容忍失败),否则都返回False。
提示:如果你在
__exit__里做了清理工作,清理操作本身又抛了异常,新异常会覆盖原有异常。这是资源清理代码要格外注意的。如果新旧异常都要保留,可以考虑用traceback模块手工拼接,或者至少把旧异常信息记到日志里。
6.3call:让实例变得可以调用
Python 里函数和对象的边界比很多语言要模糊。实现__call__之后,一个类的实例就可以像函数一样被调用。这个特性在"需要携带状态的可调用对象"场景里极其方便。
说个典型例子。你想实现一个"指数移动平均"的平滑器,它需要记住上一次的计算结果。普通函数每次调用没有记忆,再写个类又要在外面调用方法:
class EMASmoother: def __init__(self, alpha: float): self.alpha = alpha self.last = None def __call__(self, value: float): if self.last is None: self.last = value else: self.last = self.alpha * value + (1 - self.alpha) * self.last return self.last然后你可以直接写:
smooth = EMASmoother(0.2) smooth(10.0) smooth(12.5) smooth(11.0)调用方式跟使用普通函数一模一样,但它内部能保存状态。这种模式在信号处理、滚动统计、插件系统中都大量使用。
有些框架的装饰器本质上也让"类可调用"。所以当你看到@my_decorator或pytest.fixture这类用法时,其背后往往就是某个对象实现了__call__。
7. 进阶:描述符协议与属性的底层逻辑
7.1 描述符协议:属性访问的底层暗线
下面这部分属于进阶中的进阶了,但如果你已经看到这里,我觉得不写会亏。
描述符协议涉及的三个方法是__get__、__set__、__delete__。实现了其中一个方法的类,被称为描述符。当一个类的类属性是描述符实例时,访问这个实例属性的行为会被描述符拦截。
你可能从来没直接写过描述符,但你每天都在用它的产物——property、classmethod、staticmethod,这些都是由描述符实现的。
准备一个简单例子。比如你想写一个"校验年龄必须大于 0"的属性:
class PositiveNumber: def __init__(self): self._data = {} def __get__(self, instance, owner): if instance is None: return self return self._data.get(instance, 0) def __set__(self, instance, value): if value <= 0: raise ValueError("必须大于 0") self._data[instance] = value class Person: age = PositiveNumber() p = Person() p.age = 25 # 通过描述符 __set__,校验通过 p.age = -1 # 抛出 ValueError这里需要特别注意的是:_data用的是dict以实例为键,而不是存在实例的__dict__里。因为如果直接setattr(instance, 'age', value),而age是描述符属性,会又触发__set__,变成死循环。
7.2 自己用描述符实现一个 property
理解了原理之后,property就没那么神秘了。它本质上是一个数据描述符,在__get__时执行 getter 函数,在__set__时执行 setter 函数。你甚至可以自己做一个简化版:
class MyProperty: def __init__(self, fget=None, fset=None): self.fget = fget self.fset = fset def __get__(self, instance, owner): if instance is None: return self return self.fget(instance) def __set__(self, instance, value): if self.fset is None: raise AttributeError("can't set attribute") self.fset(instance, value) def setter(self, fset): self.fset = fset return self用起来:
class Temperature: def __init__(self, celsius): self._celsius = celsius @MyProperty def celsius(self): return self._celsius @celsius.setter def celsius(self, value): self._celsius = value你看,@property背后就是描述符协议。熟悉这个原理之后,你去看sqlalchemy里的Column、django里的Field、pydantic里的字段校验,都会有一种"水落石出"的贯通感。
7.3 我的学习路线建议
魔法方法数量很多,全部背下来没有必要,也没人能长期记住所有细节。我更推荐按"场景驱动"的方式去学:
- 先掌握
__init__、__repr__、__str__、__eq__这几个——它们能改善你 80% 的日常调试体验; - 遇到需要封装数据结构的场景,去查
__getitem__、__setitem__、__len__、__iter__; - 写库、写框架、写中间件的人,再深入研究
__new__、__getattr__、描述符协议和上下文管理器; - 读源码时遇到不懂的魔法方法,直接查官方文档的数据模型章节,那里是权威参考。
我在实际编程里有个习惯:写完一个类之后,会刻意想象"假如别人要用这个类,他们希望它支持哪些自然语法"。想让对象能打印出好读的信息,就写__repr__;想让两个对象能比较,就写__eq__;想让对象能被with管理,就写__enter__/__exit__。这种"以用户视角倒推协议需求"的思路,会让你的代码设计水平明显提升。
最后再说一个我踩过的坑:在实现__eq__的时候,记得先检查类型。很多新手直接写self.attr == other.attr,结果拿对象和None比较时AttributeError就蹦出来了。正确做法是先用isinstance(other, 你的类)判断一下,如果不匹配就返回NotImplemented,让 Python 尝试其他比较方式(比如反向调用对方的__eq__),而不是直接抛错。这个小细节,在跟别人写的库里的对象做混搭比较时尤其重要。