Python 的魔法方法(双下划线方法)本质上是解释器在特定语法下自动调用的钩子:print(obj) 查 __str__,len(obj) 查 __len__,obj1 + obj2 查 __add__。只有把这套调用规则摸清楚,自定义类才能像内置类型一样参与运算、迭代、比较和资源管理。下面按使用频率从高到低,把构造与销毁、字符串表示、比较与算术重载、容器化、可调用对象、上下文管理器的写法以及容易踩的坑梳理一遍。
一、构造与销毁:__init__、__new__、__del__
三个方法的职责分工很明确:__init__ 在对象创建之后执行初始化;__new__ 负责创建实例本身,一般只在继承不可变类型或实现单例时重写;__del__ 在对象被垃圾回收时触发。由于回收时机由 GC 决定,__del__ 不适合承载必须执行的清理逻辑,资源释放优先交给上下文管理器。
- class Resource:
- def __init__(self, name):
- self.name = name
- print(f"{name} 被创建")
- def __del__(self):
- print(f"{self.name} 被销毁")
- r = Resource("连接") # 连接 被创建
- del r # 连接 被销毁
复制代码
二、字符串表示:__str__、__repr__、__format__
这三个方法面向不同的使用场景。__str__ 给终端用户看,print() 和 str() 会调用它;__repr__ 给开发者看,调试输出、交互式环境和容器内打印都走它;__format__ 则接管 f-string 中的格式说明符,可以实现类似 {obj:verbose} 的自定义格式。
- class Point:
- def __init__(self, x, y):
- self.x, self.y = x, y
- def __str__(self):
- # 面向终端用户:print()、str()
- return f"({self.x}, {self.y})"
- def __repr__(self):
- # 面向开发者:调试、交互式环境、容器内打印
- return f"Point({self.x}, {self.y})"
- def __format__(self, spec):
- # 支持 f-string 自定义格式
- if spec == "verbose":
- return f"横坐标={self.x}, 纵坐标={self.y}"
- return str(self)
- p = Point(3, 4)
- print(f"{p}") # (3, 4)
- print(f"{p:verbose}") # 横坐标=3, 纵坐标=4
- print([p]) # [Point(3, 4)],容器内走 __repr__
复制代码
实践建议是至少写出 __repr__,调试时能省下大量排查时间;容器打印不会调用 __str__,只写 __str__ 往往会在日志里看到一串默认的内存地址。
三、比较运算符重载与 __hash__ 的连带影响
__eq__、__lt__、__le__、__gt__、__ge__、__ne__ 分别对应 ==、<、<=、>、>=、!=。全部手写虽然直观,但重复度高,更省事的做法是用 functools.total_ordering 装饰器,只实现 __eq__ 和 __lt__,其余比较方法由装饰器补齐。补齐之后,sorted() 之类的排序函数就能直接作用于自定义对象。
- from functools import total_ordering
- @total_ordering
- class Money:
- def __init__(self, amount):
- self.amount = amount
- def __eq__(self, other):
- return self.amount == other.amount
- def __lt__(self, other):
- return self.amount < other.amount
- def __repr__(self):
- return f"Money({self.amount})"
- m1, m2 = Money(100), Money(200)
- print(m1 < m2) # True
- print(sorted([m2, m1])) # [Money(100), Money(200)]
复制代码
这里有一个必须留意的连带效应:一旦类中定义了 __eq__,Python 会把 __hash__ 置为 None,对象随之变成不可哈希,既不能放进 set,也不能作为 dict 的 key。解决办法是显式补一个与相等判断保持一致的 __hash__。
- class Money:
- def __init__(self, amount):
- self.amount = amount
- def __eq__(self, other):
- return self.amount == other.amount
- def __hash__(self):
- return hash(self.amount) # 补上后即可哈希
- print({Money(1), Money(2)})
复制代码
四、算术运算符重载:正反向运算与返回新对象
算术重载的关键在于返回一个新对象,而不是原地修改 self。以二维向量为例,__add__、__sub__、__mul__ 分别对应加减乘,__rmul__ 处理操作数顺序颠倒的情况,__neg__ 对应一元负号,__abs__ 对应内置函数 abs()。
- class Vector:
- def __init__(self, x, y):
- self.x, self.y = x, y
- def __add__(self, other):
- return Vector(self.x + other.x, self.y + other.y)
- def __sub__(self, other):
- return Vector(self.x - other.x, self.y - other.y)
- def __mul__(self, n):
- return Vector(self.x * n, self.y * n)
- def __rmul__(self, n):
- return self.__mul__(n) # 支持 n * vector
- def __neg__(self):
- return Vector(-self.x, -self.y)
- def __abs__(self):
- return (self.x ** 2 + self.y ** 2) ** 0.5
- def __repr__(self):
- return f"Vector({self.x}, {self.y})"
- v1, v2 = Vector(1, 2), Vector(3, 4)
- print(v1 + v2) # Vector(4, 6)
- print(v2 - v1) # Vector(2, 2)
- print(v1 * 3) # Vector(3, 6)
- print(3 * v1) # Vector(3, 6),靠 __rmul__ 兜底
- 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 遍历能力。内部数据建议放在带下划线的属性里,避免与容器协议方法产生命名冲突。
- class Playlist:
- def __init__(self):
- self._songs = []
- def add(self, song):
- self._songs.append(song)
- def __len__(self):
- return len(self._songs)
- def __getitem__(self, index):
- return self._songs[index] # 支持下标与切片
- def __setitem__(self, index, value):
- self._songs[index] = value
- def __contains__(self, item):
- return item in self._songs # 支持 in 判断
- def __iter__(self):
- return iter(self._songs) # 支持 for 遍历
- def __repr__(self):
- return f"Playlist({len(self)} songs)"
- pl = Playlist()
- pl.add("晴天")
- pl.add("稻香")
- print(len(pl)) # 2
- print(pl[0]) # 晴天
- print("稻香" in pl) # True
- for song in pl:
- print(song)
复制代码
六、可调用对象:__call__
定义 __call__ 之后,实例可以像函数一样加括号调用,callable(obj) 也返回 True。这类写法适合承载带状态的“函数”,装饰器内部就大量依赖这一机制保存状态。
- class Counter:
- def __init__(self):
- self.count = 0
- def __call__(self):
- self.count += 1
- return self.count
- c = Counter()
- print(c()) # 1
- print(c()) # 2
- print(callable(c)) # True
复制代码
七、上下文管理器:__enter__ 与 __exit__
支持 with 语句只需要两个方法:__enter__ 返回的对象会绑定到 as 后面的变量,__exit__ 在代码块结束或抛出异常时执行,其返回值决定是否吞掉异常——返回 True 表示异常被吞掉,返回 False 或 None 则继续向上抛出。计时器是最直接的示例。
- import time
- class Timer:
- def __enter__(self):
- self.start = time.time()
- return self
- def __exit__(self, exc_type, exc_val, exc_tb):
- self.elapsed = time.time() - self.start
- print(f"耗时 {self.elapsed:.3f} 秒")
- return False # False 表示不吞异常
- with Timer() as t:
- sum(range(1_000_000))
复制代码
更贴近实际开发的用法是资源管理。下面的 DBConnection 在 __enter__ 中建立连接并返回连接对象,在 __exit__ 中统一关闭连接;如果代码块内发生异常,还能通过 exc_type、exc_val、exc_tb 三个参数拿到异常信息做日志记录。
- class DBConnection:
- def __init__(self, dsn):
- self.dsn = dsn
- self.conn = None
- def __enter__(self):
- print(f"连接数据库 {self.dsn}")
- self.conn = {"connected": True}
- return self.conn
- def __exit__(self, exc_type, exc_val, exc_tb):
- print("关闭数据库连接")
- self.conn = None
- if exc_type:
- print(f"发生异常:{exc_val}")
- return False
- with DBConnection("sqlite:///app.db") as conn:
- 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__ 写一个临时切换工作目录的上下文管理器。把这几类方法写顺手之后,自定义类型在运算、迭代和资源管理上的表现就能与内置类型保持一致。 |