本节摘要:魔术方法是 Python 让自定义类"融入语言"的钩子——定义了
__len__,你的对象就能用len();定义了__iter__,你的对象就能被for遍历。本节讲清楚常用魔术方法的作用、迭代器协议的两个方法,以及生成器作为简化迭代器的写法。通过自定义容器和斐波那契序列两道练习,把"让类像内置类型一样好用"的能力练出来。
阅读完本节,你应当能够:
__str__ 和 __repr__ 控制对象的字符串表示__iter__ 和 __next__),让自定义类可被 for 遍历yield 写生成器函数,作为迭代器的简化写法你有没有想过,为什么 len([1,2,3]) 能算列表长度,len("abc") 能算字符串长度,但 len(你的自定义对象) 就报错?为什么 for x in [1,2,3] 能遍历列表,for x in 你的自定义对象 就不行?
答案在于:列表、字符串这些内置类型,背后实现了特定的方法。len() 实际上是在调用对象的 __len__ 方法,for 循环实际上是在调用对象的 __iter__ 和 __next__ 方法。这些以双下划线开头和结尾的方法叫魔术方法(也叫双下方法、dunder methods),它们是 Python 留给你"让自定义对象融入语言"的接口。
如果你定义的 ShoppingCart 类实现了 __len__,那 len(cart) 就能返回商品数量;实现了 __iter__,那 for item in cart 就能遍历购物车里的商品。你的对象用起来就像内置类型一样自然,这就是魔术方法的价值——让自定义类拥有和内置类型一致的、符合直觉的使用方式。
迭代器是魔术方法的一个重要应用。它定义了"如何逐个产出元素"的标准协议,让 for 循环、列表推导式等都能统一地处理任何"可迭代"的对象。理解迭代器,就理解了 Python 数据处理的底层机制。
| 魔术方法 | 触发的操作 | 典型用途 |
|---|---|---|
__init__ |
MyClass(...) 创建实例 |
初始化属性 |
__str__ |
print(obj)、str(obj) |
给用户看的友好字符串 |
__repr__ |
直接显示 obj、repr(obj) |
给开发者看的精确表示 |
__len__ |
len(obj) |
返回长度 |
__getitem__ |
obj[key] |
按索引/键取值 |
__setitem__ |
obj[key] = value |
按索引/键赋值 |
__contains__ |
x in obj |
成员判断 |
__eq__ |
obj1 == obj2 |
相等比较 |
__add__ |
obj1 + obj2 |
加法运算 |
__iter__ |
for x in obj、iter(obj) |
返回迭代器 |
__next__ |
next(it) |
产出下一个元素 |
__str__ 与 __repr__ 的区别这两个都控制对象的字符串表示,但定位不同:
class Point: def __init__(self, x, y): self.x = x self.y = y def __str__(self): return f"({self.x}, {self.y})" # 给用户看,简洁 def __repr__(self): return f"Point({self.x}, {self.y})" # 给开发者看,能重建对象 p = Point(3, 4) print(p) # (3, 4) 触发 __str__ p # Point(3, 4) 在交互环境触发 __repr__
💡 关键直觉:
__str__追求可读,__repr__追求准确(理想情况下eval(repr(obj))能重建对象)。如果只实现一个,先实现__repr__——因为没定义__str__时,__repr__。
迭代器协议要求一个对象实现两个方法:
__iter__ 返回迭代器对象本身(对迭代器而言)或一个新的迭代器(对可迭代容器而言)__next__ 返回下一个元素,没有更多元素时抛出 StopIterationclass CountDown: def __init__(self, start): self.current = start def __iter__(self): return self # 自己就是迭代器 def __next__(self): if self.current <= 0: raise StopIteration self.current -= 1 return self.current + 1 # 返回倒数前的值 for n in CountDown(3): print(n) # 3, 2, 1
for 循环的机制就是:调用 iter(obj) 拿到迭代器,反复调用 next(it) 直到 StopIteration。
手写 __iter__ 和 __next__ 略显啰嗦,生成器用 yield 关键字把这件事简化了。函数里有 yield,它就不再是普通函数,而是生成器函数,调用它返回一个生成器对象,自动实现迭代器协议:
def count_down(start): n = start while n > 0: yield n n -= 1 for x in count_down(3): print(x) # 3, 2, 1
yield 暂停函数并产出一个值,下次调用 next 时从暂停处继续。效果和手写迭代器类完全一样,代码量少很多。绝大多数需要迭代器的场景,用生成器函数更简洁。
通过组合多个魔术方法,能让自定义类表现得像列表或字典:
class UniqueList: """只存不重复元素的列表""" def __init__(self): self._data = [] def __len__(self): return len(self._data) def __contains__(self, item): return item in self._data def __getitem__(self, index): return self._data[index] def add(self, item): if item not in self: # 触发 __contains__ self._data.append(item) def __iter__(self): return iter(self._data) # 委托给内部列表
有了这些方法,len(ul)、x in ul、ul[0]、for x in ul 全都能用,用户用起来毫无学习成本。
手写的迭代器(把 __iter__ 返回 self 的那种)遍历完就耗尽了,再次 for 不会有元素:
cd = CountDown(3) for n in cd: print(n) # 3, 2, 1 for n in cd: print(n) # 没输出,已耗尽
要支持反复遍历,应该让 __iter__ 每次返回一个新的迭代器(生成器函数天然如此)。这是可迭代容器与迭代器的关键区别:容器可重复遍历,迭代器是一次性的。
⚠️ 常见坑:在手写迭代器类里把
__iter__写成return self,结果对象只能遍历一次。如果希望像列表那样可重复遍历,要么用生成器函数,要么让__iter__返回一个新的迭代器实例。
__eq__ 与可哈希性的冲突实现 __eq__ 后,对象默认变得不可哈希(不能当字典键、不能放进集合),除非同时实现 __hash__:
class Point: def __init__(self, x, y): self.x, self.y = x, y def __eq__(self, other): return self.x == other.x and self.y == other.y def __hash__(self): return hash((self.x, self.y)) # 配合 __eq__ 定义哈希 p1, p2 = Point(1, 2), Point(1, 2) print(p1 == p2) # True print(p1 in {p2}) # True,因为哈希和相等都一致
生成器是"惰性"的——它不一次性产出所有元素,而是按需产出。处理大数据时这是巨大优势:
# 一次性生成一百万个数,占内存 nums = [x * 2 for x in range(1000000)] # 生成器,几乎不占内存,按需产出 nums_gen = (x * 2 for x in range(1000000))
💡 关键直觉:只需遍历一次的大数据序列,用生成器而不是列表。内存占用能从几个 G 降到几乎为零,第 4 章会更系统地讲这个取舍。
题面:在 3.1 的 Book 类基础上,实现 __str__ 和 __repr__。__str__ 返回"《书名》- 作者",__repr__ 返回能重建对象的 Book("书名", "作者") 形式。
思路点拨:直接用 f-string 拼接。__repr__ 要包含类名和足以重建对象的参数。
参考骨架:
class Book: def __init__(self, title, author): self.title = title self.author = author def __str__(self): return f"《{self.title}》- {self.author}" def __repr__(self): return f"Book({self.title!r}, {self.author!r})" b = Book("Python入门", "张三") print(b) # 《Python入门》- 张三 print(repr(b)) # Book('Python入门', '张三')
⚠️ 常见坑:
__repr__里字符串用{self.title}会丢引号,用{self.title!r}会自动加引号并转义,后者才是"能重建对象"的正确写法。
题面:写一个 ShoppingCart 类,内部用一个列表存商品名。实现 __len__(返回商品数)、__contains__(判断商品是否在车中)、__iter__(能 for 遍历所有商品)。再加一个 add 方法添加商品。
思路点拨:__iter__ 最简单的实现是 return iter(self._items),把遍历委托给内部列表。这样还能支持反复遍历。
参考骨架:
class ShoppingCart: def __init__(self): self._items = [] def add(self, item): self._items.append(item) def __len__(self): return len(self._items) def __contains__(self, item): return item in self._items def __iter__(self): return iter(self._items) cart = ShoppingCart() cart.add("苹果") cart.add("牛奶") print(len(cart)) # 2 print("苹果" in cart) # True for item in cart: print(item) # 苹果, 牛奶
题面:用生成器函数写一个 fib_gen(n),产出前 n 个斐波那契数。然后写一个迭代器类 FibIterator 实现同样的功能,对比两种写法。
思路点拨:生成器用两个变量滚动保存前两项,循环 yield。迭代器类要在 __init__ 里初始化状态,__next__ 里更新状态并返回当前值,到达 n 次时抛 StopIteration。
参考骨架:
# 生成器写法 def fib_gen(n): a, b = 0, 1 for _ in range(n): yield a a, b = b, a + b # 迭代器类写法 class FibIterator: def __init__(self, n): self.n = n self.count = 0 self.a, self.b = 0, 1 def __iter__(self): return self def __next__(self): if self.count >= self.n: raise StopIteration value = self.a self.a, self.b = self.b, self.a + self.b self.count += 1 return value print(list(fib_gen(10))) # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34] print(list(FibIterator(10))) # 同上
💡 思考延伸:对比 2.1 节那个会超时、栈溢出的递归
fib——这里用迭代(滚动变量)的写法,计算前 1000 个斐波那契数也是瞬间的事。这就是"迭代 vs 递归"的效率差异,生成器还顺手解决了内存问题(不一次性存所有值)。
魔术方法能自己发明吗,比如写个 __swap__? 不能直接生效。魔术方法是解释器按协议查找的,只有语言定义过的名字会被内置函数和语法触发,自造的双下划线名字只是普通方法。想扩展运算行为,要在既有协议里做文章:想让两个对象支持 a + b 就实现 __add__,想要 abs(obj) 有意义就实现 __abs__。这也解释了为什么魔术方法表值得浏览一遍——知道协议清单,才知道自定义类的能力边界在哪。
__getattr__ 和 __getattribute__ 有什么区别? 名字相近,时机完全不同。__getattribute__ 在每次属性访问时都被调用,重写它稍有不慎就会无限递归;__getattr__ 只在常规查找失败后才被调用,是实现"动态属性""代理对象"的安全入口。比如一个配置对象,把未知的属性访问转发给默认配置,十几行代码就能写完。记住顺序:先走常规查找,找不到才轮到 __getattr__。
生成器能不能同时当协程用? 早期确实有人用 send 往生成器里传值实现协程,但那是 asyncio 普及前的民间偏方,现在不建议再手写。不过理解 yield 的双向能力有教育意义:它既产出值,也能接收值,这个"暂停-恢复-传值"的机制正是现代协程的概念前身。有兴趣了解异步编程的读者,从这里入手会比直接看 asyncio 文档顺畅得多。
什么时候该手写迭代器类而不是生成器? 只有两种情况值得:状态足够复杂、需要多个方法操作同一份状态;或者需要"多次独立遍历但共享底层容器"。生成器函数是无状态的工厂——每次调用生成新的生成器,这恰好覆盖了绝大多数需求。默认永远写生成器,手写迭代器类是确认必要后的最后选择。
题面:定义 Temperature 类,内部用摄氏度存储,__init__ 接收摄氏度。实现:__add__(两个温度相加,返回新的 Temperature)、__eq__(按摄氏度判等)、__lt__(小于比较,配合内置 sorted 用)、__str__(输出 "36.5°C")。最后把几个温度对象放进列表排序并打印。
思路点拨:__add__ 里要注意返回新对象而不修改自身——运算符的语义是"产生结果",不是"改变操作数",这条纪律能避开一大类隐蔽 bug。比较方法都基于内部的摄氏度字段,几行就能写完。
参考骨架:
class Temperature: def __init__(self, c: float): self.c = c def __add__(self, other): return Temperature(self.c + other.c) def __eq__(self, other): return self.c == other.c def __lt__(self, other): return self.c < other.c def __str__(self): return f"{self.c}°C" readings = [Temperature(36.8), Temperature(36.2), Temperature(37.5)] for t in sorted(readings): print(t) # 36.2°C 36.8°C 37.5°C print(Temperature(36) + Temperature(1)) # 37.0°C
排序能直接工作,是因为 sorted 只要求元素之间支持小于比较——实现了 __lt__ 就够了,不必把所有比较运算符写全。用最少的协议满足需要,是魔术方法使用的分寸感。
再补两个容易忽略的细节。其一,实现了 __eq__ 却没实现 __hash__,Temperature 对象就进不了集合、当不了字典键(3.3 小节讲过的规则在这里立刻应验),需要就补一个基于 self.c 的哈希。其二,__add__ 只定义了"温度 + 温度",写 36 + t 会失败——左操作数是整数时,Python 会尝试整数的加法并返回"不支持"的错误。要让"数字在左边"也工作,需要再实现右操作数版本的方法。这些边角不必一次记全,遇到报错时能想起"运算符背后是一对方法"这个线索,就知道往哪查了。
顺带回答一个练习中常见的疑问:print(merged) 打印列表里的对象时,显示的是 __repr__ 而不是 __str__——容器对元素统一用开发者表示。所以只想看到友好格式时,要么循环逐个 print,要么顺手把 __repr__ 也实现了。两分钟的小实验就能验证:给 Temperature 同时实现两个方法,对比 print(t) 与 print([t]) 的输出差异,这个区别从此就再也不会混淆。
__len__ 就能用 len(),定义 __iter__ 就能被 for 遍历。__str__ 给用户、__repr__ 给开发者:只实现一个先实现 __repr__。__iter__ + __next__:耗尽时抛 StopIteration,for 循环自动处理。yield 暂停函数并产出值,代码比手写类简洁得多。__iter__ 返回新迭代器或用生成器。__eq__ 要同时实现 __hash__:否则对象不可哈希,不能当字典键或放集合。下一章我们系统地学 Python 的进阶数据结构——列表、字典、集合的高阶用法,以及推导式和生成器表达式。