☰
Python魔法方法完全指南:从对象模型到描述符协议
2026/10/3 10:20:39 网站建设 项目流程

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 解释器会:

  1. 调用__new__创建对象实例;
  2. 调用__init__初始化这个实例;
  3. 把结果赋给变量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 我的学习路线建议

魔法方法数量很多,全部背下来没有必要,也没人能长期记住所有细节。我更推荐按"场景驱动"的方式去学:

  1. 先掌握__init__、__repr__、__str__、__eq__这几个——它们能改善你 80% 的日常调试体验;
  2. 遇到需要封装数据结构的场景,去查__getitem__、__setitem__、__len__、__iter__;
  3. 写库、写框架、写中间件的人,再深入研究__new__、__getattr__、描述符协议和上下文管理器;
  4. 读源码时遇到不懂的魔法方法,直接查官方文档的数据模型章节,那里是权威参考。

我在实际编程里有个习惯:写完一个类之后,会刻意想象"假如别人要用这个类,他们希望它支持哪些自然语法"。想让对象能打印出好读的信息,就写__repr__;想让两个对象能比较,就写__eq__;想让对象能被with管理,就写__enter__/__exit__。这种"以用户视角倒推协议需求"的思路,会让你的代码设计水平明显提升。

最后再说一个我踩过的坑:在实现__eq__的时候,记得先检查类型。很多新手直接写self.attr == other.attr,结果拿对象和None比较时AttributeError就蹦出来了。正确做法是先用isinstance(other, 你的类)判断一下,如果不匹配就返回NotImplemented,让 Python 尝试其他比较方式(比如反向调用对方的__eq__),而不是直接抛错。这个小细节,在跟别人写的库里的对象做混搭比较时尤其重要。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询