查看: 22|回复: 3

Python魔法方法实战:运算符重载与容器化对象设计

[复制链接]
发表于 11 分钟前 | 显示全部楼层 |阅读模式
Python 的魔法方法(双下划线方法)本质上是解释器在特定语法下自动调用的钩子:print(obj) 查 __str__,len(obj) 查 __len__,obj1 + obj2 查 __add__。只有把这套调用规则摸清楚,自定义类才能像内置类型一样参与运算、迭代、比较和资源管理。下面按使用频率从高到低,把构造与销毁、字符串表示、比较与算术重载、容器化、可调用对象、上下文管理器的写法以及容易踩的坑梳理一遍。

一、构造与销毁:__init__、__new__、__del__

三个方法的职责分工很明确:__init__ 在对象创建之后执行初始化;__new__ 负责创建实例本身,一般只在继承不可变类型或实现单例时重写;__del__ 在对象被垃圾回收时触发。由于回收时机由 GC 决定,__del__ 不适合承载必须执行的清理逻辑,资源释放优先交给上下文管理器。
  1. class Resource:
  2.     def __init__(self, name):
  3.         self.name = name
  4.         print(f"{name} 被创建")
  5.     def __del__(self):
  6.         print(f"{self.name} 被销毁")
  7. r = Resource("连接")   # 连接 被创建
  8. del r                  # 连接 被销毁
复制代码

二、字符串表示:__str__、__repr__、__format__

这三个方法面向不同的使用场景。__str__ 给终端用户看,print() 和 str() 会调用它;__repr__ 给开发者看,调试输出、交互式环境和容器内打印都走它;__format__ 则接管 f-string 中的格式说明符,可以实现类似 {obj:verbose} 的自定义格式。
  1. class Point:
  2.     def __init__(self, x, y):
  3.         self.x, self.y = x, y
  4.     def __str__(self):
  5.         # 面向终端用户:print()、str()
  6.         return f"({self.x}, {self.y})"
  7.     def __repr__(self):
  8.         # 面向开发者:调试、交互式环境、容器内打印
  9.         return f"Point({self.x}, {self.y})"
  10.     def __format__(self, spec):
  11.         # 支持 f-string 自定义格式
  12.         if spec == "verbose":
  13.             return f"横坐标={self.x}, 纵坐标={self.y}"
  14.         return str(self)
  15. p = Point(3, 4)
  16. print(f"{p}")           # (3, 4)
  17. print(f"{p:verbose}")   # 横坐标=3, 纵坐标=4
  18. print([p])              # [Point(3, 4)],容器内走 __repr__
复制代码

实践建议是至少写出 __repr__,调试时能省下大量排查时间;容器打印不会调用 __str__,只写 __str__ 往往会在日志里看到一串默认的内存地址。

三、比较运算符重载与 __hash__ 的连带影响

__eq__、__lt__、__le__、__gt__、__ge__、__ne__ 分别对应 ==、<、<=、>、>=、!=。全部手写虽然直观,但重复度高,更省事的做法是用 functools.total_ordering 装饰器,只实现 __eq__ 和 __lt__,其余比较方法由装饰器补齐。补齐之后,sorted() 之类的排序函数就能直接作用于自定义对象。
  1. from functools import total_ordering
  2. @total_ordering
  3. class Money:
  4.     def __init__(self, amount):
  5.         self.amount = amount
  6.     def __eq__(self, other):
  7.         return self.amount == other.amount
  8.     def __lt__(self, other):
  9.         return self.amount < other.amount
  10.     def __repr__(self):
  11.         return f"Money({self.amount})"
  12. m1, m2 = Money(100), Money(200)
  13. print(m1 < m2)           # True
  14. print(sorted([m2, m1]))  # [Money(100), Money(200)]
复制代码

这里有一个必须留意的连带效应:一旦类中定义了 __eq__,Python 会把 __hash__ 置为 None,对象随之变成不可哈希,既不能放进 set,也不能作为 dict 的 key。解决办法是显式补一个与相等判断保持一致的 __hash__。
  1. class Money:
  2.     def __init__(self, amount):
  3.         self.amount = amount
  4.     def __eq__(self, other):
  5.         return self.amount == other.amount
  6.     def __hash__(self):
  7.         return hash(self.amount)   # 补上后即可哈希
  8. print({Money(1), Money(2)})
复制代码

四、算术运算符重载:正反向运算与返回新对象

算术重载的关键在于返回一个新对象,而不是原地修改 self。以二维向量为例,__add__、__sub__、__mul__ 分别对应加减乘,__rmul__ 处理操作数顺序颠倒的情况,__neg__ 对应一元负号,__abs__ 对应内置函数 abs()。
  1. class Vector:
  2.     def __init__(self, x, y):
  3.         self.x, self.y = x, y
  4.     def __add__(self, other):
  5.         return Vector(self.x + other.x, self.y + other.y)
  6.     def __sub__(self, other):
  7.         return Vector(self.x - other.x, self.y - other.y)
  8.     def __mul__(self, n):
  9.         return Vector(self.x * n, self.y * n)
  10.     def __rmul__(self, n):
  11.         return self.__mul__(n)   # 支持 n * vector
  12.     def __neg__(self):
  13.         return Vector(-self.x, -self.y)
  14.     def __abs__(self):
  15.         return (self.x ** 2 + self.y ** 2) ** 0.5
  16.     def __repr__(self):
  17.         return f"Vector({self.x}, {self.y})"
  18. v1, v2 = Vector(1, 2), Vector(3, 4)
  19. print(v1 + v2)   # Vector(4, 6)
  20. print(v2 - v1)   # Vector(2, 2)
  21. print(v1 * 3)    # Vector(3, 6)
  22. print(3 * v1)    # Vector(3, 6),靠 __rmul__ 兜底
  23. print(abs(v2))   # 5.0
复制代码

常用算术方法对照:a + b 对应 __add__,a - b 对应 __sub__,a * b 对应 __mul__,a / b 对应 __truediv__,a // b 对应 __floordiv__,a % b 对应 __mod__,a ** b 对应 __pow__,-a 对应 __neg__,abs(a) 对应 __abs__,a += b 对应 __iadd__。

五、容器化:让对象像列表一样工作

实现 __len__、__getitem__、__setitem__、__contains__、__iter__ 之后,对象就具备了下标访问、切片、in 判断和 for 遍历能力。内部数据建议放在带下划线的属性里,避免与容器协议方法产生命名冲突。
  1. class Playlist:
  2.     def __init__(self):
  3.         self._songs = []
  4.     def add(self, song):
  5.         self._songs.append(song)
  6.     def __len__(self):
  7.         return len(self._songs)
  8.     def __getitem__(self, index):
  9.         return self._songs[index]      # 支持下标与切片
  10.     def __setitem__(self, index, value):
  11.         self._songs[index] = value
  12.     def __contains__(self, item):
  13.         return item in self._songs     # 支持 in 判断
  14.     def __iter__(self):
  15.         return iter(self._songs)       # 支持 for 遍历
  16.     def __repr__(self):
  17.         return f"Playlist({len(self)} songs)"
  18. pl = Playlist()
  19. pl.add("晴天")
  20. pl.add("稻香")
  21. print(len(pl))        # 2
  22. print(pl[0])          # 晴天
  23. print("稻香" in pl)   # True
  24. for song in pl:
  25.     print(song)
复制代码

六、可调用对象:__call__

定义 __call__ 之后,实例可以像函数一样加括号调用,callable(obj) 也返回 True。这类写法适合承载带状态的“函数”,装饰器内部就大量依赖这一机制保存状态。
  1. class Counter:
  2.     def __init__(self):
  3.         self.count = 0
  4.     def __call__(self):
  5.         self.count += 1
  6.         return self.count
  7. c = Counter()
  8. print(c())          # 1
  9. print(c())          # 2
  10. print(callable(c))  # True
复制代码

七、上下文管理器:__enter__ 与 __exit__

支持 with 语句只需要两个方法:__enter__ 返回的对象会绑定到 as 后面的变量,__exit__ 在代码块结束或抛出异常时执行,其返回值决定是否吞掉异常——返回 True 表示异常被吞掉,返回 False 或 None 则继续向上抛出。计时器是最直接的示例。
  1. import time
  2. class Timer:
  3.     def __enter__(self):
  4.         self.start = time.time()
  5.         return self
  6.     def __exit__(self, exc_type, exc_val, exc_tb):
  7.         self.elapsed = time.time() - self.start
  8.         print(f"耗时 {self.elapsed:.3f} 秒")
  9.         return False   # False 表示不吞异常
  10. with Timer() as t:
  11.     sum(range(1_000_000))
复制代码

更贴近实际开发的用法是资源管理。下面的 DBConnection 在 __enter__ 中建立连接并返回连接对象,在 __exit__ 中统一关闭连接;如果代码块内发生异常,还能通过 exc_type、exc_val、exc_tb 三个参数拿到异常信息做日志记录。
  1. class DBConnection:
  2.     def __init__(self, dsn):
  3.         self.dsn = dsn
  4.         self.conn = None
  5.     def __enter__(self):
  6.         print(f"连接数据库 {self.dsn}")
  7.         self.conn = {"connected": True}
  8.         return self.conn
  9.     def __exit__(self, exc_type, exc_val, exc_tb):
  10.         print("关闭数据库连接")
  11.         self.conn = None
  12.         if exc_type:
  13.             print(f"发生异常:{exc_val}")
  14.         return False
  15. with DBConnection("sqlite:///app.db") as conn:
  16.     print(conn)   # {'connected': True}
复制代码

八、常见坑与排查思路

定义 __eq__ 后对象不能哈希:解释器自动把 __hash__ 置为 None,需要手动补回与相等判断逻辑一致的 __hash__。
在 __repr__ 中调用 str(self) 造成递归:直接读取字段拼接字符串,不要绕回字符串协议方法。
__exit__ 返回 True 吞掉异常:除刻意压制异常的场景外,统一返回 False 或 None,否则问题会被静默隐藏。
__getattr__ 写错导致无限递归:内部访问不存在的属性时要 raise AttributeError,而不是再次触发属性查找。
算术方法忘记 return:重载方法返回 None 会让后续表达式直接报错,必须返回新的对象实例。

九、速查与练习

按分类整理:构造销毁为 __init__、__new__、__del__;字符串表示为 __str__、__repr__、__format__;比较为 __eq__、__lt__、__le__、__gt__、__ge__、__ne__、__hash__;算术为 __add__、__sub__、__mul__、__truediv__、__neg__、__abs__;容器为 __len__、__getitem__、__setitem__、__contains__、__iter__;调用为 __call__;上下文为 __enter__、__exit__;属性访问为 __getattr__、__setattr__、__getattribute__;布尔判断为 __bool__。日常编码中必写的是 __init__ 和 __repr__,高频使用的是 __str__、__eq__、__len__。

想巩固的话可以做三个小练习:给 Money 补上 __add__ 与 __sub__,支持两笔金额相加减;实现 Stack 类,支持 push、pop、len()、下标访问、in 判断和 for 遍历;用 __enter__ 与 __exit__ 写一个临时切换工作目录的上下文管理器。把这几类方法写顺手之后,自定义类型在运算、迭代和资源管理上的表现就能与内置类型保持一致。
回复

使用道具 举报

发表于 6 分钟前 | 显示全部楼层

Re: Python魔法方法实战:运算符重载与容器化对象设计

楼主这篇梳理得挺系统的,从构造销毁一路到比较重载,节奏很顺。不过帖子好像被截断了,第四部分算术运算符那里代码到 `def _` 就没了,后面的 `__sub__`、`__mul__`、`__rmul__`、`__neg__`、`__abs__` 示例和第五部分容器化都没显示出来,估计是论坛长度限制或者贴代码时出问题了,方便的话补一下? 已经能看到的部分里,`__repr__` 那条建议特别有共鸣。我以前图省事只写了 `__str__`,结果对象塞进 list 一 print,满屏 ``,排查时完全不知道哪个是哪个,后来养成习惯先写 `__repr__`,调试体验直接不一样。 `__eq__` 一写就把 `__hash__` 置 None 这个坑也踩过,而且是隔了很久才反应过来为什么对象突然不能当 dict key 了。 另外借这个话题补充一点我自己的教训:`total_ordering` 虽然省事,但它补出来的比较方法默认假设 `other` 是同类型对象,如果直接拿 `Money(100) < 100` 比较,`other.amount` 就会抛 AttributeError。所以 `__eq__` 和 `__lt__` 里最好先判断一下类型,类型不匹配就 `return NotImplemented`,让解释器去尝试反向运算或者直接返回 False,比抛异常干净很多。 期待楼主
回复 支持 反对

使用道具 举报

发表于 6 分钟前 | 显示全部楼层

Re: Python魔法方法实战:运算符重载与容器化对象设计

楼主这个梳理挺成体系的,按使用频率分层来讲,比那种一股脑列清单的方式好消化多了。几个点补充一下平时踩过的坑。 一是 __eq__ 里对类型不一致的情况最好返回 NotImplemented 而不是直接 False,这样解释器会继续去试对方的反向比较,否则父类子类混用、或者和别的类型比较时容易出反直觉的结果。二是 __hash__ 必须和 __eq__ 用完全一致的字段来算,这个楼主说到了,但实践中经常出现 __eq__ 后来改了字段、__hash__ 忘了同步,结果 set 去重静默失效,很难查。三是 __del__ 除了时机不确定,还容易跟循环引用搅在一起,新版虽然能回收,但清理顺序没法保证,所以凡是有外部资源的场景一律上 with 是稳妥的。 另外第四部分好像断在 def _ 那里了,应该是贴的时候被截掉了?正反向运算那块挺想看完的,特别是 __radd__ 碰到非同类操作数时是返回 NotImplemented 还是就地转成向量,这两种策略差别挺大。还有后面容器化部分,__getitem__ 要不要支持切片、__contains__ 不写的时候靠 __iter__ 兜底的性能问题,以及 __len__ 返回非整数会直接抛 TypeError,这些细节也都值得展开聊聊。期待后续。
回复 支持 反对

使用道具 举报

发表于 6 分钟前 | 显示全部楼层

Re: Python魔法方法实战:运算符重载与容器化对象设计

写得很系统,尤其是把 __str__、__repr__、__format__ 三者的使用场景拆开讲这点很到位,容器打印只走 __repr__ 这个坑我踩过不止一次,日志里看到一串内存地址的时候才想起来补。 __eq__ 定义后 __hash__ 被置成 None 这条也值得反复强调,很多人是在把对象塞进 set 或当 dict key 时才突然报 TypeError,回头找半天。补 __hash__ 的时候记得和 __eq__ 用同一批字段,否则同一个对象算出来的哈希和相等判断对不上,集合里会出现“重复”元素。 另外提个小建议:__eq__ 里遇到类型不匹配的情况,最好返回 NotImplemented 而不是直接 False,这样 Python 会继续尝试对方的反射方法,跨类型比较时行为更符合预期。total_ordering 那个例子也是同样思路,装饰器补齐的比较方法内部其实就是靠 __lt__ 和 __eq__ 组合推导的,所以这两个的语义一定要写准。 不过第四节 Vector 的代码好像贴到 `def _` 就断了,__rmul__、__neg__、__abs__ 的实现和返回新对象的注意事项没显示出来,方便的话补一下?正反向运算这块正是最容易写漏的地方,比如 2 * v 和 v * 2 的调用顺序差异,还有 __radd__ 在 other 是不同类型时才会被触发,挺想看
回复 支持 反对

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-11-11 06:11 , Processed in 0.020594 second(s), 28 queries , Gzip On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部