Python 里的每个运算符和内置函数,都在向你的对象提一个问题。实现对应的魔术方法来回答,你的类就能用起来像内置类型一样。
len(x) 会调用 x.__len__()。a + b 会调用 a.__add__(b)。for i in x 会调用 x.__iter__()。Python 的语法是一组问题,而魔术方法(也叫双下划线方法)就是对象回答这些问题的方式。
实现了该实现的那几个,你的类用起来就不再像是硬塞进去的。
把值类型写对
class Money:
def __init__(self, amount, currency='BRL'):
self.amount, self.currency = amount, currency
def __repr__(self):
return f"Money({self.amount!r}, {self.currency!r})"
def __eq__(self, other):
if not isinstance(other, Money): return NotImplemented
return (self.amount, self.currency) == (other.amount, other.currency)
def __hash__(self):
return hash((self.amount, self.currency))
def __add__(self, other):
if not isinstance(other, Money): return NotImplemented
if other.currency != self.currency:
raise ValueError(f"cannot add {other.currency} to {self.currency}")
return Money(self.amount + other.amount, self.currency)
def __lt__(self, other):
return self.amount < other.amount
a, b = Money(10), Money(10)
print(a == b, a is b)
print(a + Money(5))
print(sorted([Money(30), Money(10), Money(20)]))
print({Money(10), Money(10), Money(20)})
输出:
True False
Money(15, 'BRL')
[Money(10, 'BRL'), Money(20, 'BRL'), Money(30, 'BRL')]
{Money(10, 'BRL'), Money(20, 'BRL')}
四个方法,换来四项好处。按值判断相等,而不是按标识。支持加法。排序不需要 key 函数,单靠 __lt__ 就行。集合能去重,靠的是 __hash__。
只有 __eq__ 没有 __hash__,对象就坏了
定义了 __eq__,Python 就会把 __hash__ 设为 None。因为相等的两个对象哈希值也必须相等,而 Python 猜不出该怎么算:
class NoHash:
def __init__(self, v): self.v = v
def __eq__(self, other): return self.v == other.v
try:
{NoHash(1)}
except TypeError as err:
print(type(err).__name__ + ':', err)
输出:
TypeError: unhashable type: 'NoHash'
这样的对象再也不能放进集合,也不能当字典的键。如果你的类是值类型,就像 Money 那样,基于同样的字段把两个方法都定义好。如果它确实是可变的,那不可哈希反而是正确的结果。
返回 NotImplemented,而不是 False
print(a == 'not money')
try:
a + 5
except TypeError as err:
print(type(err).__name__ + ':', err)
输出:
False
unsupported operand type(s) for +: 'Money' and 'int'
返回 NotImplemented 是在告诉 Python:“我处理不了,去问另一个操作数。”接着 Python 会退而求其次:== 退回到标识比较,+ 则抛出清楚的 TypeError。如果 __eq__ 返回的是 False,另一个类型就没机会回答了。
注意,NotImplemented 是你返回的值;NotImplementedError 是你抛出的异常,用于抽象方法。两者很容易搞混。
容器白送两种行为
class Deck:
def __init__(self, cards): self._cards = list(cards)
def __len__(self): return len(self._cards)
def __getitem__(self, i): return self._cards[i]
d = Deck(['A', 'K', 'Q', 'J'])
print(len(d), d[0], d[-1], d[1:3])
print([c for c in d]) # iteration for free from __getitem__
print('K' in d) # membership for free too
输出:
4 A J ['K', 'Q']
['A', 'K', 'Q', 'J']
True
两个方法换来了索引、负索引、切片、迭代和 in。迭代和成员检测是白送的,因为缺少 __iter__ 和 __contains__ 时,Python 会退回去用 __getitem__。切片能用,是因为索引被原样传给了列表。
真值
class Basket:
def __init__(self, items): self.items = items
def __len__(self): return len(self.items)
print(bool(Basket([])), bool(Basket(['apple'])))
输出:
False True
现在 if basket: 的意思就是“如果篮子里有东西”。Python 先问 __bool__,没有就退回到 __len__。两个都没有,所有对象都为真。所以在没有 __len__ 的类上写 if my_object:,结果永远是 True,根本不是你想做的检查。
可调用对象
class Multiplier:
def __init__(self, by): self.by = by
def __call__(self, x): return x * self.by
triple = Multiplier(3)
print(triple(5), list(map(triple, [1, 2, 3])))
输出:
15 [3, 6, 9]
对象用起来像函数,却能带着状态。用类实现的装饰器、可配置的回调,靠的都是这一点。
要点
__repr__总要写;值类型要把__eq__和__hash__一起写。- 运算符处理不了的情况,返回
NotImplemented,绝不要返回False。 - 有了
__len__和__getitem__,就有了迭代、成员检测和切片。 - 实现
__bool__(或__len__),让if obj:有意义。
用不上的魔术方法不要实现。每一个都是对象行为的承诺,而没人需要的承诺,只会让你多一件得一直守住的事。