1 魔法方法是什么 —— 名字带「魔法」,背后是约定
是什么?为什么学?
魔法方法(也叫「特殊方法」)是名字前后各两个下划线的方法,如 __init__、__str__、__eq__。它们不用你手动调用,Python 在特定场景下自动触发:print(obj) 自动找 __str__,obj1 == obj2 自动找 __eq__,len(obj) 自动找 __len__。学会魔法方法,你的对象就能支持 ==、<、len()、+、in 这些「内置待遇」——代码写起来和用 Python 内置类型一样自然。
底层原理
魔法方法本质是约定:解释器在实现 str()、len()、==、+、in 等内置操作时,内部都会去查对象的类里有没有对应的 __xxx__ 方法,有就调用,没有就报错或走默认行为——比如 len(obj) 的实现大致是 obj.__len__()。这也是为什么「一切皆对象」:语法层面的事件,底层都是方法调用。
生活类比
魔法方法像酒店房间的门牌约定:门上写着「请勿打扰」(约定),服务员路过就自动不敲门;门上写着「请打扫」,服务员路过就自动进来。你不需要喊住每个服务员交代一遍——挂对牌子,服务自动发生。双下划线就是那块牌子。
注意事项总结
1 六个常见魔法方法,看看它们分别在什么场景被自动调用:
class Demo:
def __init__(self, value):
self.value = value # 创建对象时自动调用
def __str__(self):
return f"Demo(值={self.value})" # print/str 时自动调用
def __eq__(self, other):
return self.value == other.value # == 时自动调用
def __len__(self):
return self.value # len() 时自动调用
def __contains__(self, item):
return item in str(self.value) # in 时自动调用
d1 = Demo(10)
d2 = Demo(10)
print(d1) # 触发 __str__
print(d1 == d2) # 触发 __eq__
print(len(d1)) # 触发 __len__
print("1" in d1) # 触发 __contains__
运行结果:
Demo(值=10)
True
10
True
整个程序里没有一行手动调用魔法方法,全是 Python 自动触发。这就是魔法方法的全部秘密:你只管按约定定义,Python 负责在合适时机调用。
2 没定义对应魔法方法时,Python 会怎样:
class Empty:
pass
e = Empty()
# print(len(e)) # 会报错:TypeError: object of type 'Empty' has no len()
len(e) 去找 __len__,没找到,于是抛出 TypeError——内置操作只会走魔法方法这一条路。看到这类报错,第一反应就是去补对应的魔法方法。
易错点
- 错误写法:把魔法方法名拼错(
__str_少一个下划线) → 问题:不报错!但永远不会被自动调用,行为「静默失效」 → 正确写法:前后各两个下划线,写完数一遍。 - 错误写法:以为魔法方法要手动调用:
obj.__str__()→ 问题:能用但不规范,失去了「自动触发」的意义 → 正确写法:用str(obj)、print(obj),让 Python 自动找。 - 错误写法:定义魔法方法却不 return 正确类型 → 问题:报
TypeError: __str__ returned non-string等 → 正确写法:每种魔法方法有约定返回类型:__str__返回字符串、__eq__返回布尔……
记忆口诀:双下划线是约定,Python 自动来触发;魔法方法不用调,内置操作走这条路。
2 str 与 repr —— 「给人看」与「给调试看」
是什么?为什么学?
__str__ 面向最终用户:print()、str() 调用,追求友好可读;__repr__ 面向开发者:交互式终端直接输对象名时显示,追求「明确、无歧义」,最好能看出这个对象是怎么构造出来的。就像用户说明书 vs 维修工单:同一件产品配两份文档,读者不同,信息量完全不同。
底层原理
两者的回退规则是:str(obj) 优先找 __str__,找不到就退而用 __repr__;而 repr(obj)(以及交互式终端显示)只用 __repr__,找不到就用 object 基类的默认地址格式。另外记住:容器打印元素时用的是 repr——print([obj]) 里列表元素走 __repr__ 而不是 __str__。
注意事项总结
1 str 与 repr 的分工:
class Book:
def __init__(self, title, price):
self.title = title
self.price = price
def __str__(self):
return f"《{self.title}》{self.price} 元"
def __repr__(self):
return f"Book(title={self.title!r}, price={self.price!r})"
b = Book("Python 入门", 39.9)
print(str(b)) # 走 __str__:友好
print(repr(b)) # 走 __repr__:带构造信息
print([b]) # 容器里的元素走 __repr__!
运行结果:
《Python 入门》39.9 元
Book(title='Python 入门', price=39.9)
[Book(title='Python 入门', price=39.9)]
print(str(b)) 输出书名加价格,是给人看的;repr(b) 输出「用这个表达式能再造出这个对象」的风格——!r 表示「用 repr 方式格式化字符串」(带引号);print([b]) 是关键细节:**列表打印元素时调用的是元素的 __repr__**。
2 回退规则:如果类只定义 __repr__ 而没定义 __str__,print(obj) 会自动回退调用 __repr__,两种输出相同;两个都没定义时才显示内存地址。
易错点
- 错误写法:只定义
__str__不定义__repr__,在终端直接输对象名 → 问题:显示难看的内存地址(终端只用 repr) → 正确写法:调试类至少定义__repr__。 - 错误写法:以为
print([obj])走__str__→ 问题:容器元素走__repr__,输出和预期不一样 → 正确写法:想让列表里的对象也好看,定义__repr__。 - 错误写法:
__repr__里用!r却不知道含义 → 问题:输出不带引号,看不出是字符串 → 正确写法:!r表示按 repr 格式化(字符串会带引号)。
记忆口诀:str 给用户,repr 给调试;str 缺了找 repr,容器打印用 repr。
3 eq 与哈希 —— 相等的定义影响 set / dict
是什么?为什么学?
不定义 __eq__ 时,== 比较的是内存地址(本质是 is),两个内容相同的对象也不相等。定义 __eq__ 后,你可以规定「什么算相等」——学生分数相同算相等、坐标相同算相等。但这里藏着一个大坑:定义 __eq__ 会让对象默认变得「不可哈希」(unhashable),放进 set 或当 dict 的键会报错。
底层原理
Python 里 == 调 __eq__,hash() 调 __hash__。规则是:两个相等的对象,哈希值必须相同(否则 set/dict 的去重和查找就乱套了)。object 基类默认用 id 做哈希(与默认的 == 语义一致)。一旦你自定义了 __eq__,Python 无法保证你定义的相等关系和默认哈希还兼容,于是自动把 __hash__ 设为 None——对象不可哈希。想要 set 正常工作,必须同时重写 __hash__。
生活类比
默认的 == 只认「同一个人」(id),定义了 __eq__ 后「同名就算同一人」;但 set 查重靠的是哈希值 = 档案柜编号,你改了相等规则却没改编号规则,档案柜就乱套了——所以 Python 干脆禁止你把人放进档案柜。
注意事项总结
1 默认 == 比较内存地址:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
p1 = Point(1, 2)
p2 = Point(1, 2)
print(p1 == p2) # 内容一样,但默认比较地址
print(p1 is p2) # 地址当然不同
运行结果:
False
False
没定义 __eq__ 时 == 退化成 is(比较内存地址)。
2 定义 eq 后,内容相等即相等:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __eq__(self, other):
if not isinstance(other, Point):
return False
return self.x == other.x and self.y == other.y
p1 = Point(1, 2)
p2 = Point(1, 2)
print(p1 == p2) # 坐标相同,True
print(p1 == "Point") # 类型不同,返回 False
运行结果:
True
False
__eq__ 里先判断类型(不是 Point 直接返回 False,避免和字符串等比较时抛异常),再比较坐标。
3 **定义 eq 后 set 会拒绝——必须同时定义 hash**:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __eq__(self, other):
if not isinstance(other, Point):
return False
return self.x == other.x and self.y == other.y
def __hash__(self):
return hash((self.x, self.y)) # 用坐标元组的哈希
p1 = Point(1, 2)
p2 = Point(1, 2)
print(len({p1, p2})) # 内容相同,set 只保留一个
print(hash(p1) == hash(p2)) # 相等的对象哈希必须相同
运行结果:
1
True
同时定义 __eq__ 和 __hash__ 后 set 就能正常工作了。**铁律:定义 __eq__ 就一定要定义 __hash__**。
易错点
- 错误写法:只定义
__eq__就把对象放 set → 问题:报TypeError: unhashable type: 'Point'→ 正确写法:同时定义__hash__,或换个数据结构。 - 错误写法:
__eq__里用self.x == other.x却不检查类型 → 问题:和别的类型比较时抛AttributeError→ 正确写法:先isinstance(other, Point)再比属性。 - 错误写法:
__eq__返回非布尔(如返回数字、None) → 问题:行为怪异(if判断时按真值解释) → 正确写法:__eq__明确返回 True / False。 - 错误写法:两个相等对象哈希不同 → 问题:set/dict 行为错乱:去重失效、查找不到 → 正确写法:保证相等 ⇒ 哈希相等(一起定义)。
记忆口诀:eq 定相等,hash 定档案;两个都定义,set 才不乱。
4 lt 与排序 —— 只写一个方法,sorted 全自动
是什么?为什么学?
sorted() 排序对象列表时,需要知道「谁在前谁在后」——它就是靠 < 比较(调用 __lt__)做到的。所以只定义 __lt__ 一个方法,你的类就能被 sorted()、min()、max() 使用。
底层原理
sorted() 内部用 Timsort 算法对元素两两比较,比较时执行的是 a < b,即 a.__lt__(b)。所以对象要能排序,**最小要求是定义 __lt__**。Python 3 里 >、<=、>= 各有独立的方法(__gt__、__le__、__ge__),缺了就用不了——@total_ordering 装饰器会依据你定义的 __eq__ 和 __lt__,自动推导补齐其余四个比较方法。
生活类比
__lt__ 像排队规则:老师只说了一条规则「按个子矮的在前」,全班同学就能自动排好队;total_ordering 像校规自动补全:知道了「矮在前」,「高在后」(>)、「不比他矮」(>=)这些说法自动推导出来,不用每条都写。
注意事项总结
1 只定义 __lt__,sorted / min / max 全能用:
class Student:
def __init__(self, name, score):
self.name = name
self.score = score
def __lt__(self, other):
return self.score < other.score # 按分数升序
def __repr__(self):
return f"{self.name}({self.score}分)"
students = [
Student("李四", 78),
Student("张三", 90),
Student("王五", 85),
]
print(sorted(students)) # 排序:两两比较 <
print(min(students)) # 最小值:一路比较 <
print(max(students)) # 最大值:一路比较 <
运行结果:
[李四(78分), 王五(85分), 张三(90分)]
李四(78分)
张三(90分)
一个方法,三种能力。
2 total_ordering 补齐全部比较运算:
from functools import total_ordering
@total_ordering
class Student:
def __init__(self, name, score):
self.name = name
self.score = score
def __eq__(self, other):
return self.score == other.score
def __lt__(self, other):
return self.score < other.score
def __repr__(self):
return f"{self.name}({self.score}分)"
a = Student("张三", 90)
b = Student("李四", 78)
print(a > b) # 自动推导:90 > 78
print(a >= b) # 自动推导:90 >= 78
运行结果:
True
True
只写了 __eq__ 和 __lt__,但 >、>= 也能用了。注意它要求你必须提供 __eq__ 和至少一个排序方法,否则会报错。
易错点
- 错误写法:只定义
__lt__就用>比较 → 问题:报TypeError: '>' not supported between instances→ 正确写法:用@total_ordering补齐,或定义__gt__。 - 错误写法:
__lt__里不 return 布尔 → 问题:排序结果错乱 → 正确写法:__lt__必须返回 True/False。 - 错误写法:用
@total_ordering却没定义__eq__→ 问题:报错:total_ordering 要求至少一个排序方法 +__eq__→ 正确写法:两个都定义再装饰。 - 错误写法:忘记
from functools import total_ordering→ 问题:报NameError→ 正确写法:先导入再使用。
记忆口诀:lt 一个就能排,sorted min max 全通吃;想要全比较,total_ordering 帮你补。
5 add / len / contains —— 「返回新对象」的约定
是什么?为什么学?
__add__ 让对象支持 +,__len__ 让对象支持 len(),__contains__ 让对象支持 in。这三个魔法方法让自定义容器(购物车、书架、队伍)用起来和 list/dict 一样顺手。特别要注意 __add__ 的约定:返回一个新对象,而不是修改自己。
底层原理
a + b 会调用 a.__add__(b);len(x) 调用 x.__len__();item in x 调用 x.__contains__(item)(没有 __contains__ 时,Python 会退而用 __iter__ 逐个遍历——Day 18 讲迭代器)。__add__ 返回新对象是不可变语义:加法在数学上不改变操作数,Python 内置类型(int、str、tuple)都遵守这点,自定义类也应遵循,否则 a + b 之后 a 悄悄变了,调用方会一脸懵。
生活类比
__add__ 像把两盒积木倒进一个新盒子:原来的两盒原封不动(不修改自身),新盒子装的是合起来的那份。如果倒完旧盒子里的积木没了,你就没法「再来一次」了。
注意事项总结
1 购物车三件套——+ 合并、len 长度、in 成员:
class Cart:
def __init__(self, items=None):
self.items = items if items else []
def __add__(self, other):
return Cart(self.items + other.items) # 新对象
def __len__(self):
return len(self.items)
def __contains__(self, item):
return item in self.items
def __repr__(self):
return f"Cart({self.items})"
cart_a = Cart(["苹果", "牛奶"])
cart_b = Cart(["面包"])
cart_c = cart_a + cart_b # 触发 __add__
print(cart_c) # 合并结果
print(cart_a) # 原车没变
print(cart_a is cart_c) # 确实是两个不同的对象
print(len(cart_c)) # 触发 __len__:3
print("牛奶" in cart_c) # 触发 __contains__:True
print(len(Cart())) # 空购物车长度为 0
运行结果:
Cart(['苹果', '牛奶', '面包'])
Cart(['苹果', '牛奶'])
False
3
True
0
cart_a + cart_b 触发 cart_a.__add__(cart_b),用 self.items + other.items 拼出新的列表、包进新的 Cart 返回——cart_a 没被改动;len(cart) 走 __len__;"牛奶" in cart 走 __contains__。注意 Cart() 没传参数时 items 默认是 [](items if items else [] 处理了「传 None」的情况)——空容器也应该是合法容器。
易错点
- 错误写法:
__add__里直接self.items += other.items然后 return self → 问题:原对象被修改,与内置+语义不符,a + b后 a 变了 → 正确写法:新建对象:return Cart(self.items + other.items)。 - 错误写法:默认参数写成
def __init__(self, items=[])→ 问题:所有实例共享同一个列表,互相污染 → 正确写法:写items=None,方法里self.items = items if items else []。 - 错误写法:
__contains__忘 return → 问题:返回 None,in结果恒为 False(None 是假值) → 正确写法:必须 return 布尔值。 - 错误写法:
__len__返回负数或非整数 → 问题:报ValueError/TypeError→ 正确写法:返回len(self.items)这类非负整数。
记忆口诀:add 加出新对象,原物不动守约定;len 与 contains,容器三件套。
6 动手实践:增强版对象体验程序
做一个 Book 类,把今天学的魔法方法全部用上,再写一个演示函数:
# magic_books.py —— 增强版对象体验程序
class Book:
def __init__(self, title, price):
self.title = title
self.price = price
def __str__(self):
return f"《{self.title}》{self.price} 元"
def __repr__(self):
return f"Book({self.title!r}, {self.price!r})"
def __eq__(self, other):
if not isinstance(other, Book):
return False
return self.title == other.title
def __lt__(self, other):
return self.price < other.price
def __len__(self):
return len(self.title)
def __add__(self, other):
return Book(f"{self.title} + {other.title}", self.price + other.price)
def __contains__(self, keyword):
return keyword in self.title
def demo():
book1 = Book("Python 入门", 39.9)
book2 = Book("Python 入门", 99.0) # 不同版本不同价
book3 = Book("Python 进阶", 59.9)
print("--- __str__ / __repr__ ---")
print(book1)
print(repr(book1))
print("--- __eq__ 按书名比较 ---")
print(book1 == book2) # 同名,True
print(book1 == book3) # 不同名,False
print("--- __lt__ + sorted 按价格排序 ---")
for b in sorted([book3, book1, book2]):
print(b)
print("--- __len__ 书名长度 ---")
print(len(book1))
print("--- __add__ 合并 ---")
bundle = book1 + book3
print(bundle)
print("--- __contains__ ---")
print("入门" in book1)
print("爬虫" in book1)
if __name__ == "__main__":
demo()
运行结果:
--- __str__ / __repr__ ---
《Python 入门》39.9 元
Book('Python 入门', 39.9)
--- __eq__ 按书名比较 ---
True
False
--- __lt__ + sorted 按价格排序 ---
《Python 入门》39.9 元
《Python 进阶》59.9 元
《Python 入门》99.0 元
--- __len__ 书名长度 ---
9
--- __add__ 合并 ---
《Python 入门 + Python 进阶》99.8 元
--- __contains__ ---
True
False
排序输出了三本书(39.9、59.9、99.0),按价格升序排列,说明 __lt__ 被 sorted() 正确调用。注意 len("Python 入门") 计算的是字符个数(含空格):Python 6 + 空格 1 + 入门 2 = 9。
升级挑战:给 Book 加 __sub__(买书抵扣)和 __getitem__(用下标取书名中的字),再定义一个 BookShelf 类,用 __len__ 统计书架上的书,用 __getitem__ 按下标取书。
今日总结
魔法方法 = 双下划线 + 自动触发;内置操作(==、+、in、len()、print)底层全是方法调用
__str__ 给用户、__repr__ 给调试;str 缺了退 __repr__,容器元素打印走 __repr__
默认 == 比较内存地址;定义 __eq__ 后内容相同即相等,但必须同时定义 __hash__ 才能进 set/dict
只定义 __lt__ 就能被 sorted() / min() / max() 使用;@total_ordering 一键补齐全部比较
__add__ 返回新对象、不修改自己;__len__ 返回非负整数;__contains__ 返回布尔
可变默认参数的坑:def __init__(self, items=[]) 会让所有实例共享列表
增强版对象体验程序(Book 类)所有演示功能运行正常
| 报错 | 原因 | 修复 |
|---|---|---|
TypeError: 'Book' object is not callable | 把对象当函数调用(如 book()) | 检查是否把 __call__ 相关用法搞混,或调用了不存在的函数 |
TypeError: '<' not supported between instances | 没定义 __lt__ 就用 < 或 sorted() 比较对象 | 在类里定义 __lt__ |
TypeError: 'Book' object does not support indexing | 对象不支持下标 [] | 定义 __getitem__ |
RecursionError | __str__ 里 print(self) 又触发 __str__,无限递归 | __str__ 里用 self.属性,不要再 print(self) |
__add__ 返回值不对 | 忘了返回新对象,或返回了别的类型 | 确认返回同类新对象,不修改原对象 |
TypeError: unhashable type: 'Point' | 定义了 __eq__ 却没定义 __hash__,对象不能进 set/dict | 同时定义 __hash__,或避免使用 set/dict |