内容意译文于下面文章, 水平有限,欢迎指正。
Enriching Your Python Classes With Dunder (Magic, Special) Methods
Python学习群:278529278 (欢迎交流)

什么是魔术方法?

在python中,有一些预定义的特殊方法可以用来增加自定义的类实现,他们很容易辨识,方法名的前后都是双下划线。

这些特殊方法有时候也会被称为魔术方法,尽管其实他们本身并不神秘,也不必把它们想的过于复杂。魔术方法能够让自定义类去模拟内置类的行为。
比如说,可以通过len()方法获取string的长度,但是一个空实现的自定义类是不能开箱支持这种操作的。

class NoLenSupport:
    pass

>>> obj = NoLenSupport()
>>> len(obj)
TypeError: "object of type 'NoLenSupport' has no len()"

要解决这个问题,需要在自定义添加len魔术方法

class LenSupport:
    def __len__(self):
        return 42

>>> obj = LenSupport()
>>> len(obj)
42

另一个典型的例子是切片,可以通过实现getitem方法来支持python list 的切片语法。

魔术方法和python数据模型

魔术方法这一优雅的设计也被称为python数据模型, 这也开发者能够充分利用和挖掘python语言特性,比如,序列,迭代,操作符重载,属性访问等。

Python的数据模型可以被看作一个强大的api,通过实现魔术方法与之沟通。当希望写出更pythonic代码,就需要知道如何在恰当的地方使用好魔术方法。

对于初学者来说,不需要担心太多的概念。在这篇文章中,将通过一个简单的账户来介绍魔术方法的使用。

丰富一个简单的帐号类

本篇将通过实现一系列魔术方法来丰富一个简单python类, 从而来解锁下面这些语言特性:

  • 新实例的初始化
  • 对象内容展示
  • 开启迭代功能
  • 操作符重载(比较)
  • 操作符重载(加法)
  • 方法调用
  • 上下文管理器(with语法)

对象实例初始化:__init__

当开始定义一个类的时候,已经需要一个魔术方法了,__init__方法是一个构造函数,用来产生一个类的对象实例。

class Account:
    """A simple account class"""

    def __init__(self, owner, amount=0):
        """
        This is the constructor that lets us create
        objects from this class
        """
        self.owner = owner
        self.amount = amount
        self._transactions = []

这个构造函数负责初始化实例对象,在上面的代码中,它接受拥有者的名字,一个可选参数:初始金额。并且定义一个内部交易列表用来追踪存取的行为。
有了这个魔术方法,就可以按如下的方式的定义新的对象实例

>>> acc = Account('bob')  # default amount = 0
>>> acc = Account('bob', 10)

对象表示:__str__, __repr__

提供一个对象的表示输出给使用类的使用者已经是大家公认的共识。有两个魔术方法和这个功能有关。

__repr__

这个方法提供一个实例对象的正式文本表达输出。它的目标是做到没有歧义

__str__

这个方法是非正式有关一个对象格式化输出的定义。

下面是这两个方法在账户类的实现

class Account:
    # ... (see above)

    def __repr__(self):
        return 'Account({!r}, {!r})'.format(self.owner, self.amount)

    def __str__(self):
        return 'Account of {} with starting amount: {}'.format(
            self.owner, self.amount)

如果不想写死类的名字Account,可以使用 self.__class__.__name__的方式来动态获取。
如果只想实现其中一个方法,请确保是 __repr__

现在可以使用各种方式来访问对象实例来获取对象的文本输出

>>> str(acc)
'Account of bob with starting amount: 10'

>>> print(acc)
"Account of bob with starting amount: 10"

>>> repr(acc)
"Account('bob', 10)"

迭代:__len__, __getitem__, __reversed__

为了能够迭代帐号对象实例,需要添加一些交易,所以首先定义一个简单的方法用来添加交易。因为只是用来说明魔术方法,这里的实现很简单。

def add_transaction(self, amount):
    if not isinstance(amount, int):
        raise ValueError('please use int for amount')
    self._transactions.append(amount)

此外,通过定义一个property,可以通过访问account.balance来快速计算当前这个account的收支平衡。这个属性方法将初始本金和交易金额总和相加。

@property
def balance(self):
    return self.amount + sum(self._transactions)

下面对这个账户做些存取的操作

>>> acc = Account('bob', 10)

>>> acc.add_transaction(20)
>>> acc.add_transaction(-10)
>>> acc.add_transaction(50)
>>> acc.add_transaction(-20)
>>> acc.add_transaction(30)

>>> acc.balance
80

这个时候对这个帐号内的交易信息我们会想了解:

  • 这个帐号总过有多少笔交易
  • 通过对帐号对象实例下标的访问方式获取对应的交易
  • 迭代获取所有交易

按照现有Account类的定义,我们是无法了解这些信息的,下面的这些语句都会抛出TypeError的异常。

>>> len(acc)
TypeError

>>> for t in acc:
...    print(t)
TypeError

>>> acc[1]
TypeError

魔术方法可以少量的代码让账户类实现可迭代。

class Account:
    # ... (see above)

    def __len__(self):
        return len(self._transactions)

    def __getitem__(self, position):
        return self._transactions[position]

# 之前的输出结果
>>> len(acc)
5

>>> for t in acc:
...    print(t)
20
-10
50
-20
30

>>> acc[1]
-10

如果需要反向迭代交易列表,可以实现 __reversed__魔术方法。

def __reversed__(self):
    return self[::-1]

>>> list(reversed(acc))
[30, -20, 50, -10, 20]

这里通过调用python反转list的reverse方法来调用魔术方法,但是魔术方法返回的还是一个迭代器,所以还需要使用list方法来把迭代对象转换成可以打印输出的列表对象。

操作符重载(比较):__eq__, __lt__

>>> 2 > 1
True

>>> 'a' > 'b'
False

平常对上面的类型比较,大家都习以为常。但是实际上是多个魔术方法提供了这些功能。我们可以通过dir()方法来检查对象实例的方法

>>> dir('a')
['__add__',
...
'__eq__',    <---------------
'__format__',
'__ge__',    <---------------
'__getattribute__',
'__getitem__',
'__getnewargs__',
'__gt__',    <---------------
...]

通过添加第二个账户对象,并与第一个对象进行比较操作发现,确实相关魔术方法会抛出异常错误。

>>> acc2 = Account('tim', 100)
>>> acc2.add_transaction(20)
>>> acc2.add_transaction(40)
>>> acc2.balance
160

>>> acc2 > acc
TypeError:
"'>' not supported between instances of 'Account' and 'Account'"

可以通过使用functools.total_ordering装饰器来只实现部分比较相关的魔术方法。
这里我们只实现了 __eq__, __lt__

from functools import total_ordering

@total_ordering
class Account:
    # ... (see above)

    def __eq__(self, other):
        return self.balance == other.balance

    def __lt__(self, other):
        return self.balance < other.balance

>>> acc2 > acc
True

>>> acc2 < acc
False

>>> acc == acc2
False

操作符重载(相加):__eq__, __lt__

>>> 1 + 2
3

>>> 'hello' + ' world'
'hello world'

python 中 数值类型相加是求和,字符类型的相加是拼接,这个逻辑在背后都是通过魔术方法来实现的。

>>> dir(1)
[...
'__add__',
...
'__radd__',
...]

>>> acc + acc2
TypeError: "unsupported operand type(s) for +: 'Account' and 'Account'"

我们的Account类不支持相加逻辑的魔术方法,所以抛出了类型错误。下面我们利用原有的基础来补充下这个方法。
合并两个帐号的名字和交易记录。

def __add__(self, other):
    owner = '{}&{}'.format(self.owner, other.owner)
    start_amount = self.amount + other.amount
    acc = Account(owner, start_amount)
    for t in list(self) + list(other):
        acc.add_transaction(t)
    return acc

>>> acc3 = acc2 + acc
>>> acc3
Account('tim&bob', 110)

>>> acc3.amount
110
>>> acc3.balance
240
>>> acc3._transactions
[20, 40, 20, -10, 50, -20, 30]

当需要实现右加逻辑的时候可以考虑实现 __radd__

可调用对象:__call__

通过增加一个__call__魔术方法可以让一个对象实例像普通的函数一样被调用。比如我们可以让Account对象实例输出详细交易信息

class Account:
    # ... (see above)

    def __call__(self):
        print('Start amount: {}'.format(self.amount))
        print('Transactions: ')
        for transaction in self:
            print(transaction)
        print('\nBalance: {}'.format(self.balance))

>>> acc = Account('bob', 10)
>>> acc.add_transaction(20)
>>> acc.add_transaction(-10)
>>> acc.add_transaction(50)
>>> acc.add_transaction(-20)
>>> acc.add_transaction(30)

>>> acc()
Start amount: 10
Transactions:
20
-10
50
-20
30
Balance: 80

这里需要注意的是,上面的例子只是用来说明__call__方法的用途,还需要考虑实现使这个对象可调用的真正目的是否有意义。
一般情况下,上面的功能可以封装在一个内置方法中,用对象进行显示的调用。 Account.print_statement()

上下文管理器(with 语句):__enter__, __exit__

这里关于上下文管理器的解释不做多的介绍,请参考别的资料。

通过上下文管理器,想实现的机制是当添加一个交易导致收支平衡为负数的时候,自动回滚到之前的状态。

class Account:
    # ... (see above)

    def __enter__(self):
        print('ENTER WITH: Making backup of transactions for rollback')
        self._copy_transactions = list(self._transactions)
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        print('EXIT WITH:', end=' ')
        if exc_type:
            self._transactions = self._copy_transactions
            print('Rolling back to previous transactions')
            print('Transaction resulted in {} ({})'.format(
                exc_type.__name__, exc_val))
        else:
            print('Transaction OK')

定义一个验证函数来测试下我们的回滚机制。

def validate_transaction(acc, amount_to_add):
    with acc as a:
        print('Adding {} to account'.format(amount_to_add))
        a.add_transaction(amount_to_add)
        print('New balance would be: {}'.format(a.balance))
        if a.balance < 0:
            raise ValueError('sorry cannot go in debt!')

#############################

# 正常的case
acc4 = Account('sue', 10)

print('\nBalance start: {}'.format(acc4.balance))
validate_transaction(acc4, 20)

print('\nBalance end: {}'.format(acc4.balance))

# 正常case 输出
Balance start: 10
ENTER WITH: Making backup of transactions for rollback
Adding 20 to account
New balance would be: 30
EXIT WITH: Transaction OK
Balance end: 30

#############################

# 异常触发回滚的case
acc4 = Account('sue', 10)

print('\nBalance start: {}'.format(acc4.balance))
try:
    validate_transaction(acc4, -50)
except ValueError as exc:
    print(exc)

print('\nBalance end: {}'.format(acc4.balance))

# 异常触发回滚的case输出

#############################

Balance start: 10
ENTER WITH: Making backup of transactions for rollback
Adding -50 to account
New balance would be: -40
EXIT WITH: Rolling back to previous transactions
ValueError: sorry cannot go in debt!
Balance end: 10
right