ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python魔法方法完全指南:从对象模型到描述符协议

Python魔法方法完全指南:从对象模型到描述符协议 1. 魔法方法到底是什么从一次好奇开始1.1 一次真实的不明觉厉现场先说个我自己的经历。很久以前我在看同事写的代码时撞见了一个看起来非常高级的类class User: def __init__(self, name, age): self.name name self.age age def __repr__(self): return fUser(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张三, age25)这种友好的文本不是天掉下来的能力而是代码里实现了__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 fOrder(order_id{self.order_id!r}, amount{self.amount})然后打日志的时候一条[2025-06-01 10:00:00] Order(order_idA1001, amount99.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 fProduct(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(fMoney 只能加 Money不能加 {type(other)}) return Money(str(self.amount other.amount)) def __repr__(self): return fMoney({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)这种写法让数据在第一次真正访问时才加载程序启动速度大大提升。第二个是动态代理。假设你要封装一个第三方 SDKSDK 提供了几十个方法你不想一个个手动转写可以用__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_tbtraceback。如果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, fgetNone, fsetNone): 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(cant 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__而不是直接抛错。这个小细节在跟别人写的库里的对象做混搭比较时尤其重要。
返回列表