使用魔术方法丰富Python Class
2017-06-25
内容意译文于下面文章, 水平有限,欢迎指正。 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