Python 进阶指南(编程轻松进阶):十七、Python 风格 OOP:属性和魔术方法
原文:http://inventwithpython.com/beyond/chapter17.html
很多语言都有 OOP 特性,但是 Python 有一些独特的 OOP 特性,包括属性和魔术方法。学习如何使用这些 Python 风格技巧可以帮助您编写简洁易读的代码。
属性允许您在每次读取、修改或删除对象的属性时运行一些特定的代码,以确保对象不会进入无效状态。在其他语言中,这段代码通常被称为获取器或设置器。魔术方法允许您通过 Python 的内置操作符来使用对象,比如+
操作符。这就是你如何组合两个datetime.timedelta
对象,比如datetime.timedelta(days=2)
和datetime.timedelta(days=3)
,来创建一个新的datetime.timedelta(days=5)
对象。
除了使用其他例子,我们将继续扩展我们在第 15 章开始的WizCoin
类,通过添加属性和用魔术方法重载操作符。这些特性将使WizCoin
对象更具表现力,并且在任何导入wizcoin
模块的应用中更容易使用。
属性
我们在第 15 章中使用的BankAccount
类通过在名字的开头加一个下划线把它的_balance
属性标记为私有。但是请记住,将一个属性指定为私有只是一种约定:Python 中的所有属性从技术上来说都是公共的,这意味着它们可以被类外的代码访问。无法阻止代码有意或恶意地将_balance
属性更改为无效值。
但是你可以防止意外的对这些带有属性的私有属性的无效更改。在 Python 中,属性是专门分配了获取器、设置器和删除器方法的属性,这些方法可以控制属性如何被读取、更改和删除。例如,如果属性应该只有整数值,将其设置为字符串'42'
可能会导致错误。属性将调用设置器方法来运行代码,该代码修复设置无效值,或者至少提供对设置无效值的早期检测。如果您认为,“我希望每次访问、用赋值语句修改或用del
语句删除该属性时都能运行一些代码”,那么您希望使用属性。
将特性转换为属性
首先,让我们创建一个简单的类,它有一个常规属性而不是属性。打开一个新的文件编辑器窗口,输入以下代码,保存为regular attribute example . py:
class ClassWithRegularAttributes:
def __init__(self, someParameter):
self.someAttribute = someParameter
obj = ClassWithRegularAttributes('some initial value')
print(obj.someAttribute) # Prints 'some initial value'
obj.someAttribute = 'changed value'
print(obj.someAttribute) # Prints 'changed value'
del obj.someAttribute # Deletes the someAttribute attribute.
这个ClassWithRegularAttributes
类有一个名为someAttribute
的常规属性。__init__()
方法将someAttribute
设置为'some initial value'
,但是我们直接将属性值更改为'changed value'
。当您运行该程序时,输出如下所示:
some initial value
changed value
该输出表明代码可以轻松地将someAttribute
更改为任何值。使用常规属性的缺点是您的代码可能会将someAttribute
属性设置为无效值。这种灵活性简单方便,但也意味着someAttribute
可能会被设置为一些无效值,从而导致错误。
让我们使用属性重写这个类,按照以下步骤为名为someAttribute
的属性重写这个类:
- 使用下划线前缀重命名属性:
_someAttribute
。 - 用
@property
装饰器创建一个名为someAttribute
的方法。这个获取器方法有所有方法都有的self
参数。 - 用
@someAttribute.setter
装饰器创建另一个名为someAttribute
的方法。这个设置器方法有名为self
和value
的参数。 - 用
@someAttribute.deleter
装饰器创建另一个名为someAttribute
的方法。这个删除方法有所有方法都有的self
参数。
打开一个新的文件编辑器窗口,输入以下代码,保存为propertiesExample.py
:
class ClassWithProperties:
def __init__(self):
self.someAttribute = 'some initial value'
@property
def someAttribute(self): # This is the "getter" method.
return self._someAttribute
@someAttribute.setter
def someAttribute(self, value): # This is the "setter" method.
self._someAttribute = value
@someAttribute.deleter
def someAttribute(self): # This is the "deleter" method.
del self._someAttribute
obj = ClassWithProperties()
print(obj.someAttribute) # Prints 'some initial value'
obj.someAttribute = 'changed value'
print(obj.someAttribute) # Prints 'changed value'
del obj.someAttribute # Deletes the _someAttribute attribute.
这个程序的输出与regular attribute example . py代码相同,因为它们有效地完成了相同的任务:它们打印一个对象的初始属性,然后更新该属性并再次打印。
但是请注意,类外的代码从不直接访问_someAttribute
属性(毕竟它是私有的)。相反,外部代码访问someAttribute
属性。这个属性的实际组成有点抽象:获取器、设置器和删除器方法组合在一起构成了这个属性。当我们在为一个名为someAttribute
的属性创建获取器、设置器和删除器方法时,将它重命名为_someAttribute
,我们称之为someAttribute
属性。
在这个上下文中,_someAttribute
属性被称为支持字段或支持变量,并且是属性所基于的属性。大多数(但不是全部)属性使用支持变量。我们将在本章后面的“只读属性”中创建一个没有支持变量的属性。
永远不要在代码中调用获取器、设置器和删除器方法,因为 Python 会在以下情况下为您调用:
- 当 Python 在后台运行访问属性(如
print(obj.someAttribute)
)的代码时,它调用获取器方法并使用返回值。 - 当 Python 在后台运行一个带有属性的赋值语句(比如
obj.someAttribute = 'changed value'
)时,它调用设置器方法,为value
参数传递'changed value'
字符串。 - 当 Python 在后台运行带有属性的
del
语句时,比如del obj.someAttribute
,它调用删除器方法。
属性的获取器、设置器和删除器方法中的代码直接作用于支持变量。您不希望获取器、设置器和删除器方法作用于该属性,因为这可能会导致错误。在一个可能的例子中,获取器方法将访问属性,导致获取器方法调用自己,这使得它再次访问属性,导致它再次调用自己,等等,直到程序崩溃。打开一个新的文件编辑器窗口,输入以下代码,保存为badPropertyExample.py
:
class ClassWithBadProperty:
def __init__(self):
self.someAttribute = 'some initial value'
@property
def someAttribute(self): # This is the "getter" method.
# We forgot the _ underscore in `self._someAttribute here`, causing
# us to use the property and call the getter method again:
return self.someAttribute # This calls the getter again!
@someAttribute.setter
def someAttribute(self, value): # This is the "setter" method.
self._someAttribute = value
obj = ClassWithBadProperty()
print(obj.someAttribute) # Error because the getter calls the getter.
当您运行这段代码时,获取器会不断调用自己,直到 Python 引发一个RecursionError
异常:
Traceback (most recent call last):
File "badPropertyExample.py", line 16, in <module>
print(obj.someAttribute) # Error because the getter calls the getter.
File "badPropertyExample.py", line 9, in someAttribute
return self.someAttribute # This calls the getter again!
File "badPropertyExample.py", line 9, in someAttribute
return self.someAttribute # This calls the getter again!
File "badPropertyExample.py", line 9, in someAttribute
return self.someAttribute # This calls the getter again!
[Previous line repeated 996 more times]
RecursionError: maximum recursion depth exceeded
为了防止这种递归,获取器、设置器和删除器方法中的代码应该始终作用于支持变量(其名称中应该有下划线前缀),而不是属性。这些方法之外的代码应该使用属性,尽管与私有访问下划线前缀约定一样,无论如何都不能阻止您在支持变量上编写代码。
使用设置器验证数据
使用属性最常见的需求是验证数据或者确保它是您想要的格式。您可能不希望类之外的代码能够将属性设置为任意值;这可能会导致错误。您可以使用属性来添加检查,以确保只将有效值分配给属性。这些检查可以让您在代码开发的早期发现错误,因为一旦设置了无效值,它们就会引发异常。
让我们更新第 15 章的wizcoin.py
文件,把galleons
、sickles
和knuts
属性变成属性。我们将更改这些属性的设置器,以便只有正整数有效。我们的WizCoin
对象代表一个硬币的数量,你不能有半个硬币或者硬币数量小于零。如果类外的代码试图将galleons
、sickles
或knuts
属性设置为无效值,我们将引发WizCoinException
异常。
打开您在第 15 章中保存的wizcoin.py
文件,并将其修改为如下所示:
class WizCoinException(Exception): # 1
"""The wizcoin module raises this when the module is misused.""" # 2
pass
class WizCoin:
def __init__(self, galleons, sickles, knuts):
"""Create a new WizCoin object with galleons, sickles, and knuts."""
self.galleons = galleons # 3
self.sickles = sickles
self.knuts = knuts
# NOTE: __init__() methods NEVER have a return statement.
--snip--
@property
def galleons(self): # 4
"""Returns the number of galleon coins in this object."""
return self._galleons
@galleons.setter
def galleons(self, value): # 5
if not isinstance(value, int): # 6
raise WizCoinException('galleons attr must be set to an int, not a ' + value.__class__.__qualname__) # 7
if value < 0: # 8
raise WizCoinException('galleons attr must be a positive int, not ' + value.__class__.__qualname__)
self._galleons = value
--snip--
新的变化增加了一个继承自 Python 内置Exception
类的WizCoinException
类 1 。该类的文档字符串描述了wizcoin
模块 2 如何使用它。这是 Python 模块的最佳实践:WizCoin
类的对象在被误用时会引发这个问题。这样,如果一个WizCoin
对象引发了其他异常类,比如ValueError
或TypeError
,这很可能意味着它是WizCoin
类中的一个 bug。
在__init__()
方法中,我们将self.galleons
、self.sickles
和self.knuts
属性 3 设置为相应的参数。
在文件的底部,在total()
和weight()
方法之后,我们为self._galleons
属性添加了一个获取器 4 和设置器方法 5。获取器简单地返回self._galleons
中的值。设置器检查分配给galleons
属性的值是否是整数 6 和正数 8 。如果任一项检查失败,则WizCoinException
会显示一条错误消息。只要代码总是使用galleons
属性,这种检查就可以防止_galleons
被设置为无效值。
所有 Python 对象都自动拥有一个__class__
属性,该属性引用对象的类对象。换句话说,value.__class__
是type(value)
返回的同一个类对象。这个类对象有一个名为__qualname__
的属性,它是类名的字符串。(具体来说,它是类的限定的名称,包括类对象嵌套在其中的任何类的名称。嵌套类的用途有限,超出了本书的范围。)例如,如果value
存储了由datetime.date(2021, 1, 1)
返回的date
对象,那么value.__class__.__qualname__
将是字符串'date'
。异常消息使用value.__class__.__qualname__
7 来获取值对象名称的字符串。类名使得错误消息对阅读它的程序员更有用,因为它不仅标识了value
参数不是正确的类型,还标识了它是什么类型以及它应该是什么类型。
您需要复制用于_galleons
的获取器和设置器的代码,以便用于_sickles
和_knuts
属性。他们的代码是相同的,除了他们使用_sickles
和_knuts
属性,而不是_galleons
作为支持变量。
只读属性
你的对象可能需要一些不能用赋值操作符=
设置的只读属性。通过省略设置器和获取器方法,可以将属性设置为只读。
例如,WizCoin
类中的total()
方法返回knuts
中对象的值。我们可以将其从常规方法改为只读属性,因为没有合理的方法来设置WizCoin
对象的total
。毕竟,如果你将total
设为整数1000
,这是否意味着 1000 克努特?还是说 1 加隆 493 克努特?还是意味着某种其他的组合?出于这个原因,我们将把粗体代码添加到wizcoin.py
文件中,使total
成为只读属性:
@property
def total(self):
"""Total value (in knuts) of all the coins in this WizCoin object."""
return (self.galleons * 17 * 29) + (self.sickles * 29) + (self.knuts)
# Note that there is no setter or deleter method for `total`.**
在total()
前面添加了@property
函数装饰器后,每当访问total
时,Python 就会调用total()
方法。因为没有设置器或删除器方法,所以如果任何代码试图通过在赋值语句或del
语句中分别使用total
来修改或删除total
,Python 就会引发AttributeError
。注意,total
属性的值取决于galleons
、sickles
和knuts
属性中的值:该属性并不基于名为_total
的支持变量。在交互式 Shell 中输入以下内容:
>>> import wizcoin
>>> purse = wizcoin.WizCoin(2, 5, 10)
>>> purse.total
1141
>>> purse.total = 1000
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: can't set attribute
您可能不喜欢在试图更改只读属性时程序立即崩溃,但这种行为比允许更改只读属性更可取。您的程序能够修改只读属性,这肯定会在程序运行的某个时候导致错误。如果在修改只读属性之后很久才出现这个错误,那么很难找到最初的原因。立即崩溃可以让你更快地发现问题。
不要混淆只读属性和常量变量。常量变量全部用大写字母书写,并且依赖程序员不去修改它们。它们的值应该在程序运行期间保持不变。与任何属性一样,只读属性与对象相关联。不能直接设置或删除只读属性。但是它可能会计算出一个变化的值。我们的WizCoin
类的total
属性随着它的galleons
、sickles
和knuts
属性的改变而改变。
何时使用属性
正如您在前面几节中看到的,属性为我们如何使用类的属性提供了更多的控制,它们是编写代码的 Python 风格方式。名字像getSomeAttribute()
或setSomeAttribute()
的方法表明你应该使用属性来代替。
这并不是说以get
或set
开始的方法的每个实例都应该立即被替换为一个属性。有些情况下你应该使用一个方法,即使它的名字以get
或者set
开头。以下是一些例子:
- 对于耗时超过一两秒的慢速操作,例如下载或上传文件
- 对于有副作用的操作,如对其他属性或对象的更改
- 对于需要将附加参数传递给获取或设置操作的操作,例如,在像
emailObj.getFileAttachment(filename)
这样的方法调用中
程序员通常认为方法是动词(在某种意义上,方法执行某种动作),他们认为属性和特性是名词(在某种意义上,它们表示某种项目或对象)。如果您的代码似乎更多地执行获取或设置的操作,而不是获取或设置项,那么最好使用获取器或设置器方法。最终,这个决定取决于对程序员来说什么是正确的。
使用 Python 的属性的最大好处是,当你第一次创建你的类时,你不必使用它们。您可以使用常规属性,如果以后需要属性,可以将属性转换为属性,而不破坏类外的任何代码。当我们用属性的名称创建一个属性时,我们可以使用前缀下划线来重命名属性,我们的程序仍然会像以前一样工作。
Python 的魔术方法
Python 有几个特殊的方法名,以双下划线开始和结束,缩写为双下划线(Dunder)。这些方法被称为双下划线方法、特殊方法或魔术方法。您已经熟悉了__init__()
魔术方法名,但是 Python 还有几个。我们经常将它们用于操作符重载——也就是说,添加自定义行为,允许我们使用带有 Python 操作符的类的对象,例如+
或>=
。其他的魔术方法让我们类的对象与 Python 的内置函数一起工作,比如len()
或repr()
。
与属性的获取器、设置器和删除器方法一样,您几乎从不直接调用魔术方法。当您使用带有操作符或内置函数的对象时,Python 会在后台调用它们。例如,如果你为你的类创建一个名为__len__()
或__repr__()
的方法,当那个类的一个对象被分别传递给len()
或repr()
函数时,它们将在后台被调用。这些方法在线记录在docs.python.org/3/reference/datamodel.html
的官方 Python 文档中。
当我们探索许多不同类型的魔术方法时,我们将扩展我们的WizCoin
类来利用它们。
字符串表示的魔术方法
您可以使用__repr__()
和__str__()
魔术方法来创建 Python 通常不知道如何处理的对象的字符串表示。通常,Python 以两种方式创建对象的字符串表示。repr
(读作“repper”)字符串是一串 Python 代码,当运行时,它创建对象的副本。str
(读作“stir”)字符串是人类可读的字符串,它提供了关于对象的清晰、有用的信息。repr
和str
字符串分别由repr()
和str()
内置函数返回。例如,在交互式 Shell 中输入以下内容来查看一个datetime.date
对象的repr
和str
字符串:
>>> import datetime
>>> newyears = datetime.date(2021, 1, 1) # 1
>>> repr(newyears)
'datetime.date(2021, 1, 1)' # 2
>>> str(newyears)
'2021-01-01' # 3
>>> newyears # 4
datetime.date(2021, 1, 1)
在这个例子中,datetime.date
对象 2 的'datetime.date(2021, 1, 1)'
repr
字符串实际上是一串 Python 代码,它创建了该对象 1 的副本。这个副本提供了对象的精确表示。另一方面,datetime.date
对象 3 的'2021-01-01'
字符串是一个字符串,以一种人类易于阅读的方式表示对象的值。如果我们简单地将对象输入交互式 shell 4 ,它会显示repr
字符串。对象的str
字符串通常显示给用户,而对象的repr
字符串则用在技术上下文中,例如错误消息和日志文件。
Python 知道如何显示其内置类型的对象,比如整数和字符串。但是它不知道如何显示我们创建的类的对象。如果repr()
不知道如何为一个对象创建一个repr
或str
字符串,按照惯例,该字符串将被包含在尖括号中,并包含该对象的内存地址和类名:'<wizcoin.WizCoin object at 0x00000212B4148EE0>'
。要为WizCoin
对象创建这种字符串,请在交互式 Shell 中输入以下内容:
>>> import wizcoin
>>> purse = wizcoin.WizCoin(2, 5, 10)
>>> str(purse)
'<wizcoin.WizCoin object at 0x00000212B4148EE0>'
>>> repr(purse)
'<wizcoin.WizCoin object at 0x00000212B4148EE0>'
>>> purse
<wizcoin.WizCoin object at 0x00000212B4148EE0>
这些字符串不是很可读或有用,所以我们可以通过实现__repr__()
和__str__()
魔术方法来告诉 Python 使用什么字符串。__repr__()
方法指定对象传递给repr()
内置函数时 Python 应该返回什么字符串,__str__()
方法指定对象传递给str()
内置函数时 Python 应该返回什么字符串。将以下内容添加到wizcoin.py
文件的末尾:
`--snip--`
def __repr__(self):
"""Returns a string of an expression that re-creates this object."""
return f'{self.__class__.__qualname__}({self.galleons}, {self.sickles}, {self.knuts})'
def __str__(self):
"""Returns a human-readable string representation of this object."""
return f'{self.galleons}g, {self.sickles}s, {self.knuts}k'
当我们将purse
传递给repr()
和str()
时,Python 调用了__repr__()
和__str__()
魔术方法。我们在代码中不调用魔术方法。
注意,在括号中包含对象的 F 字符串将隐式调用str()
来获取对象的字符串。例如,在交互式 Shell 中输入以下内容:
>>> import wizcoin
>>> purse = wizcoin.WizCoin(2, 5, 10)
>>> repr(purse) # Calls WizCoin's __repr__() behind the scenes.
'WizCoin(2, 5, 10)'
>>> str(purse) # Calls WizCoin's __str__() behind the scenes.
'2g, 5s, 10k'
>>> print(f'My purse contains {purse}.') # Calls WizCoin's __str__().
My purse contains 2g, 5s, 10k.
当我们将purse
中的WizCoin
对象传递给repr()
和str()
函数时,Python 在幕后调用WizCoin
类的__repr__()
和__str__()
方法。我们对这些方法进行了编程,以返回更可读、更有用的字符串。如果您将'WizCoin(2, 5, 10)'
repr
字符串的文本输入到交互式 Shell 中,它将创建一个与purse
中的对象具有相同属性的WizCoin
对象。str
字符串是对象值的更易于阅读的表示:'2g, 5s, 10k'
。如果在 F 字符串中使用一个WizCoin
对象,Python 将使用该对象的str
字符串。
如果WizCoin
对象非常复杂,以至于不可能通过一次构造器调用来创建它们的副本,我们将把repr
字符串放在尖括号中,以表示它不是 Python 代码。这就是一般表示字符串,如'<wizcoin.WizCoin object at 0x00000212B4148EE0>'
所做的。在交互式 Shell 中键入这个字符串会引发一个SyntaxError
,所以它不可能与创建对象副本的 Python 代码混淆。
在__repr__()
方法内部,我们使用self.__class__.__qualname__
而不是硬编码字符串'WizCoin'
;所以如果我们子类化WizCoin
,继承的__repr__()
方法将使用子类的名字而不是'WizCoin'
。此外,如果我们重命名WizCoin
类,__repr__()
方法将自动使用更新后的名称。
但是WizCoin
对象的str
字符串以简洁明了的形式向我们展示了属性值。我强烈推荐你在所有的类中实现__repr__()
和__str__()
。
##repr
字符串中的敏感信息
如前所述,我们通常向用户显示str
字符串,并且在技术上下文中使用repr
字符串,比如日志文件。但是,如果您创建的对象包含敏感信息,如密码、医疗细节或个人身份信息,repr
字符串可能会导致安全问题。如果是这种情况,确保__repr__()
方法没有在它返回的字符串中包含这些信息。当软件崩溃时,通常会在日志文件中包含变量的内容,以帮助调试。通常,这些日志文件不会被视为敏感信息。在几起安全事故中,公开共享的日志文件无意中包含了密码、信用卡号、家庭地址和其他敏感信息。当您为您的类编写__repr__()
方法时,请记住这一点。
数字魔术方法
数字魔术方法,也称为数学魔术方法,重载了 Python 的数学运算符,如+
、-
、*
、/
等。目前,我们不能用+
操作符来执行类似于添加两个WizCoin
对象的操作。如果我们试图这样做,Python 将引发一个TypeError
异常,因为它不知道如何添加WizCoin
对象。要查看此错误,请在交互式 Shell 中输入以下内容:
>>> import wizcoin
>>> purse = wizcoin.WizCoin(2, 5, 10)
>>> tipJar = wizcoin.WizCoin(0, 0, 37)
>>> purse + tipJar
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unsupported operand type(s) for +: 'WizCoin' and 'WizCoin'
不用为WizCoin
类编写一个addWizCoin()
方法,你可以使用__add__()
魔术方法,这样WizCoin
对象就可以和+
操作符一起工作。将以下内容添加到wizcoin.py
文件的末尾:
`--snip--`
def __add__(self, other): # 1
"""Adds the coin amounts in two WizCoin objects together."""
if not isinstance(other, WizCoin): # 2
return NotImplemented
return WizCoin(other.galleons + self.galleons, other.sickles + self.sickles, other.knuts + self.knuts) # 3
当一个WizCoin
对象在+
操作符的左边时,Python 调用__add__()
方法 1 ,并为other
参数传入+
操作符右边的值。(参数可以取任何名字,但other
是约定俗成的。)
请记住,您可以将任何类型的对象传递给__add__()
方法,因此该方法必须包含类型检查 2 。例如,向WizCoin
对象添加整数或浮点数是没有意义的,因为我们不知道是否应该将它添加到galleons
、sickles
或knuts
金额中。
__add__()
方法创建一个新的WizCoin
对象,其数量等于self
和other
3 的galleons
、sickles
和knuts
属性的总和。因为这三个属性包含整数,所以我们可以对它们使用+
操作符。现在我们已经为WizCoin
类重载了+
操作符,我们可以对WizCoin
对象使用+
操作符。
像这样重载+
操作符允许我们编写更可读的代码。例如,在交互式 Shell 中输入以下内容:
>>> import wizcoin
>>> purse = wizcoin.WizCoin(2, 5, 10) # Create a WizCoin object.
>>> tipJar = wizcoin.WizCoin(0, 0, 37) # Create another WizCoin object.
>>> purse + tipJar # Creates a new WizCoin object with the sum amount.
WizCoin(2, 5, 47)
如果为other
传递了错误类型的对象,魔术方法不应该引发异常,而是返回内置值NotImplemented
。例如,在下面的代码中,other
是一个整数:
>>> import wizcoin
>>> purse = wizcoin.WizCoin(2, 5, 10)
>>> purse + 42 # WizCoin objects and integers can't be added together.
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unsupported operand type(s) for +: 'WizCoin' and 'int'
返回NotImplemented
通知 Python 尝试调用其他方法来执行这个操作。(详见本章后面的“反射数字魔术方法”)。在幕后,Python 用other
参数的42
调用__add__()
方法,该方法也返回NotImplemented
,导致 Python 抛出一个TypeError
。
尽管我们不应该能够在WizCoin
对象上加减整数,但是通过定义一个__mul__()
魔术方法,允许代码将WizCoin
对象乘以正整数值是有意义的。在wizcoin.py
的末尾添加以下内容:
`--snip--`
def __mul__(self, other):
"""Multiplies the coin amounts by a non-negative integer."""
if not isinstance(other, int):
return NotImplemented
if other < 0:
# Multiplying by a negative int results in negative
# amounts of coins, which is invalid.
raise WizCoinException('cannot multiply with negative integers')
return WizCoin(self.galleons * other, self.sickles * other, self.knuts * other)
这个__mul__()
方法让您将WizCoin
对象乘以正整数。如果other
是一个整数,它就是__mul__()
方法期望的数据类型,我们不应该返回NotImplemented
。但是如果这个整数是负的,那么用它乘以WizCoin
对象将会导致我们的WizCoin
对象中的硬币数量为负。因为这违背了我们对这个类的设计,所以我们用一个描述性的错误消息引发了一个WizCoinException
。
注
你不应该在一个数字方法中改变self
对象。相反,该方法应该总是创建并返回一个新的对象。+
和其他数字操作符总是被期望计算一个新的对象,而不是原地修改对象的值。
在交互式 Shell 中输入以下内容,查看运行中的__mul__()
魔术方法:
>>> import wizcoin
>>> purse = wizcoin.WizCoin(2, 5, 10) # Create a WizCoin object.
>>> purse * 10 # Multiply the WizCoin object by an integer.
WizCoin(20, 50, 100)
>>> purse * -2 # Multiplying by a negative integer causes an error.
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "C:\Users\Al\Desktop\wizcoin.py", line 86, in __mul__
raise WizCoinException('cannot multiply with negative integers')
wizcoin.WizCoinException: cannot multiply with negative integers
表 17-1 显示了数字魔术方法的完整列表。你不需要总是为你的类实现所有的方法。由您决定哪些方法是相关的。
表 17-1: 数字魔术方法
魔术方法 | 操作 | 运算符或内置函数 |
---|---|---|
__add__() | 加法 | + |
__sub__() | 减法 | - |
__mul__() | 乘法 | * |
__matmul__() | 矩阵乘法(Python 3.5 中的新功能) | @ |
__truediv__() | 除法 | / |
__floordiv__() | 整数除法 | // |
__mod__() | 模数 | % |
__divmod__() | 除法和模数 | divmod() |
__pow__() | 指数 | ** ,pow() |
__lshift__() | 左移 | >> |
__rshift__() | 右移 | << |
__and__() | 按位与 | & |
__or__() | 按位或 | | |
__xor__() | 按位异或 | ^ |
__neg__() | 取反 | 一元- ,如在-42 中 |
__pos__() | 不变 | 一元+ ,如在+42 中 |
__abs__() | 绝对值 | abs() |
__invert__() | 逐位反转 | ~ |
__complex__() | 复数形式 | complex() |
__int__() | 整数形式 | int() |
__float__() | 浮点数形式 | float() |
__bool__() | 布尔形式 | bool() |
__round__() | 舍入 | round() |
__trunc__() | 截断 | math.trunc() |
__floor__() | 向下取整 | math.floor() |
__ceil__() | 向上取整 | math.ceil() |
其中一些方法与我们的WizCoin
类相关。尝试编写自己的__sub__()
、__pow__()
、__int__()
、__float__()
和__bool__()
方法的实现。你可以在autbor.com/wizcoinfull
看到一个实现的例子。数值魔术方法的完整文档在 Python 文档中,位于docs.python.org/3/reference/datamodel.html#emulating-numeric-types
。
numeric 魔术方法允许类的对象使用 Python 内置的数学运算符。如果您正在编写名称类似于multiplyBy()
、convertToInt()
的方法,或者描述通常由现有操作符或内置函数完成的任务的类似名称,请使用数字魔术方法(以及下两节中描述的反射和原地魔术方法)。
反射数字魔术方法
当对象位于数学运算符的左侧时,Python 调用数值型魔术方法。但是当对象位于数学运算符的右侧时,它调用反射数字魔术方法(也称为反向或右手魔术方法)。
反射数字魔术方法很有用,因为使用你的类的程序员不会总是把对象写在操作符的左边,这可能导致意外的行为。比如让我们考虑一下当purse
包含一个WizCoin
对象,Python 对表达式2 * purse
求值,其中purse
在操作符的右边会发生什么:
- 因为
2
是一个整数,所以调用int
类的__mul__()
方法时会将purse
传递给other
参数。 int
类的__mul__()
方法不知道如何处理WizCoin
对象,所以返回NotImplemented
。- Python 还没有引发一个
TypeError
。因为purse
包含了一个WizCoin
对象,所以调用WizCoin
类的__rmul__()
方法时会将2
传递给other
参数。 - 如果
__rmul__()
返回NotImplemented
,Python 会引发一个TypeError
。
否则,从__rmul__()
返回的对象就是2 * purse
表达式的计算结果。
但是表达式purse * 2
的工作方式不同,其中purse
位于操作符的左侧:
- 因为
purse
包含了一个WizCoin
对象,所以调用WizCoin
类的__mul__()
方法时会将2
传递给other
参数。 __mul__()
方法创建一个新的WizCoin
对象并返回它。- 这个返回的对象就是
purse * 2
表达式的计算结果。
如果数字魔术方法和反射数字魔术方法是可交换的,则它们具有相同的代码。交换运算和加法一样,向后和向前的结果是一样的:3 + 2
和2 + 3
是一样的。但是其他的运算是不可交换的:3 – 2
不同于2 – 3
。每当调用反射的数值魔术方法时,任何交换操作都可以只调用原始的数值魔术方法。例如,将以下内容添加到wizcoin.py
文件的末尾,为乘法运算定义一个反射的数值魔术方法:
`--snip--`
def __rmul__(self, other):
"""Multiplies the coin amounts by a non-negative integer."""
return self.__mul__(other)
一个整数和一个WizCoin
对象相乘是可换的:2 * purse
和purse * 2
一样。我们不需要从__mul__()
复制并粘贴代码,我们只需要调用self.__mul__()
并传递给它other
参数。
更新wizcoin.py
后,通过在交互式 Shell 中输入以下内容,练习使用反射乘法魔术方法:
>>> import wizcoin
>>> purse = wizcoin.WizCoin(2, 5, 10)
>>> purse * 10 # Calls __mul__() with 10 for the `other` parameter.
WizCoin(20, 50, 100)
>>> 10 * purse # Calls __rmul__() with 10 for the `other` parameter.
WizCoin(20, 50, 100)
请记住,在表达式10 * purse
中,Python 首先调用int
类的__mul__()
方法,看看整数能否与WizCoin
对象相乘。当然,Python 的内置int
类对我们创建的类一无所知,所以它返回NotImplemented
。这就通知 Python 下一次调用WizCoin
类的__rmul__()
,如果它存在,就处理这个操作。如果对int
类的__mul__()
和WizCoin
类的__rmul__()
的调用都返回NotImplemented
,Python 会引发一个TypeError
异常。
只有WizCoin
个对象可以互相添加。这保证了第一个WizCoin
对象的__add__()
方法会处理操作,所以我们不需要实现__radd__()
。例如,在表达式purse + tipJar
中,调用purse
对象的__add__()
方法,并为other
参数传递tipJar
。因为这个调用不会返回NotImplemented
,所以 Python 不会尝试调用tipJar
对象的__radd__()
方法,将purse
作为other
参数。
表 17-2 包含了可用反射魔术方法的完整列表。
表 17-2: 反映数值的魔术方法
魔术方法 | 操作 | 运算符或内置函数 |
---|---|---|
__radd__() | 加法 | + |
__rsub__() | 减法 | - |
__rmul__() | 乘法 | * |
__rmatmul__() | 矩阵乘法(Python 3.5 中的新功能) | @ |
__rtruediv__() | 除法 | / |
__rfloordiv__() | 整数除法 | // |
__rmod__() | 模数 | % |
__rdivmod__() | 除法和模数 | divmod() |
__rpow__() | 指数 | ** ,pow() |
__rlshift__() | 左移 | >> |
__rrshift__() | 右移 | << |
__rand__() | 按位与 | & |
__ror__() | 按位或 | | |
__rxor__() | 按位异或 | ^ |
反射的魔术方法的完整文档在 Python 文档中,位于docs.Python.org/3/reference/datamodel.html#emulating-numeric-types
。
原地扩展赋值魔术方法
数字和反射魔术方法总是创建新的对象,而不是原地修改对象。由扩充的赋值操作符(如+=
和*=
)调用的原地魔术方法,原地修改对象,而不是创建新的对象。(有一个例外,我将在本节末尾解释。)这些魔术方法名以一个i
开头,比如__iadd__()
和__imul__()
分别代表+=
和*=
操作符。
例如,当 Python 运行代码purse *= 2
时,预期的行为并不是WizCoin
类的__imul__()
方法创建并返回一个新的具有两倍硬币的WizCoin
对象,然后给它分配purse
变量。相反,__imul__()
方法修改了purse
中现有的WizCoin
对象,因此它有两倍多的硬币。如果您希望您的类重载增加的赋值操作符,这是一个微妙但重要的区别。
我们的WizCoin
对象已经重载了+
和*
操作符,所以让我们定义__iadd__()
和__imul__()
魔术方法,这样它们也重载了+=
和*=
操作符。在表达式purse += tipJar
和purse *= 2
中,我们分别调用__iadd__()
和__imul__()
方法,分别为other
参数传递tipJar
和2
。将以下内容添加到wizcoin.py
文件的末尾:
`--snip--`
def __iadd__(self, other):
"""Add the amounts in another WizCoin object to this object."""
if not isinstance(other, WizCoin):
return NotImplemented
# We modify the `self` object in-place:
self.galleons += other.galleons
self.sickles += other.sickles
self.knuts += other.knuts
return self # In-place dunder methods almost always return self.
def __imul__(self, other):
"""Multiply the amount of galleons, sickles, and knuts in this object
by a non-negative integer amount."""
if not isinstance(other, int):
return NotImplemented
if other < 0:
raise WizCoinException('cannot multiply with negative integers')
# The WizCoin class creates mutable objects, so do NOT create a
# new object like this commented-out code:
#return WizCoin(self.galleons * other, self.sickles * other, self.knuts * other)
# We modify the `self` object in-place:
self.galleons *= other
self.sickles *= other
self.knuts *= other
return self # In-place dunder methods almost always return self.
WizCoin
对象可以对其他WizCoin
对象使用+=
运算符,对正整数使用*=
运算符。注意,在确保另一个参数有效之后,原地方法原地修改了self
对象,而不是创建一个新的WizCoin
对象。将以下内容输入到交互式 Shell 中,查看增强赋值操作符如何原地修改WizCoin
对象:
>>> import wizcoin
>>> purse = wizcoin.WizCoin(2, 5, 10)
>>> tipJar = wizcoin.WizCoin(0, 0, 37)
>>> purse + tipJar # 1
WizCoin(2, 5, 46) # 2
>>> purse
WizCoin(2, 5, 10)
>>> purse += tipJar # 3
>>> purse
WizCoin(2, 5, 47)
>>> purse *= 10 # 4
>>> purse
WizCoin(20, 50, 470)
+
操作符 1 调用__add__()
或__radd__()
魔术方法来创建并返回新对象 2 。由+
操作符操作的原始对象保持不变。只要对象是可变的(也就是说,它是一个值可以改变的对象),原地方法 3 和 4 就应该原地修改对象。不可变对象例外:因为不可变对象不能被修改,所以不可能原地修改它。在这种情况下,原地魔术方法应该创建并返回一个新对象,就像数值和反射数值魔术方法一样。
我们没有将galleons
、sickles
和knuts
属性设为只读,这意味着它们可以改变。所以WizCoin
对象是可变的。您编写的大多数类都会创建可变对象,因此您应该设计您的原地魔术方法来原地修改对象。
如果不实现原地魔术方法,Python 将调用数值魔术方法。例如,如果WizCoin
类没有__imul__()
方法,表达式purse *= 10
将调用__mul__()
并将其返回值赋给purse.
,因为WizCoin
对象是可变的,这是一种意外的行为,可能会导致微妙的错误。
比较魔术方法
Python 的sort()
方法和sorted()
函数包含一个高效的排序算法,您可以通过一个简单的调用来访问它。但是如果你想对你创建的类的对象进行比较和排序,你需要告诉 Python 如何通过实现比较魔术方法来比较其中的两个对象。每当在带有<
、>
、<=
、>=
、==
和!=
比较运算符的表达式中使用对象时,Python 就会在后台调用比较魔术方法。
在我们探索比较魔术方法之前,让我们检查一下operator
模块中的六个函数,它们执行与六个比较操作符相同的操作。我们的比较方法将调用这些函数。在交互式 Shell 中输入以下内容。
>>> import operator
>>> operator.eq(42, 42) # "EQual", same as 42 == 42
True
>>> operator.ne('cat', 'dog') # "Not Equal", same as 'cat' != 'dog'
True
>>> operator.gt(10, 20) # "Greater Than ", same as 10 > 20
False
>>> operator.ge(10, 10) # "Greater than or Equal", same as 10 >= 10
True
>>> operator.lt(10, 20) # "Less Than", same as 10 < 20
True
>>> operator.le(10, 20) # "Less than or Equal", same as 10 <= 20
True
operator
模块为我们提供了比较操作符的函数版本。它们的实现很简单。例如,我们可以用两行代码编写自己的operator.eq()
函数:
def eq(a, b):
return a == b
拥有比较运算符的函数形式很有用,因为与运算符不同,函数可以作为参数传递给函数调用。我们将这样做来为我们的比较魔术方法实现一个辅助方法。
首先,在wizcoin.py
的开头添加以下内容。这些导入让我们可以访问operator
模块中的函数,并允许我们通过与collections.abc.Sequence
进行比较来检查方法中的other
参数是否是一个序列:
import collections.abc
import operator
然后将以下内容添加到wizcoin.py
文件的末尾:
`--snip--`
def _comparisonOperatorHelper(self, operatorFunc, other): # 1
"""A helper method for our comparison dunder methods."""
if isinstance(other, WizCoin): # 2
return operatorFunc(self.total, other.total)
elif isinstance(other, (int, float)): # 3
return operatorFunc(self.total, other)
elif isinstance(other, collections.abc.Sequence): # 4
otherValue = (other[0] * 17 * 29) + (other[1] * 29) + other[2]
return operatorFunc(self.total, otherValue)
elif operatorFunc == operator.eq:
return False
elif operatorFunc == operator.ne:
return True
else:
return NotImplemented
def __eq__(self, other): # eq is "EQual"
return self._comparisonOperatorHelper(operator.eq, other) # 5
def __ne__(self, other): # ne is "Not Equal"
return self._comparisonOperatorHelper(operator.ne, other) # 6
def __lt__(self, other): # lt is "Less Than"
return self._comparisonOperatorHelper(operator.lt, other) # 7
def __le__(self, other): # le is "Less than or Equal"
return self._comparisonOperatorHelper(operator.le, other) # 8
def __gt__(self, other): # gt is "Greater Than"
return self._comparisonOperatorHelper(operator.gt, other) # 9
def __ge__(self, other): # ge is "Greater than or Equal"
return self._comparisonOperatorHelper(operator.ge, other) #a
我们的比较方法调用_comparisonOperatorHelper()
方法 1 并从operator
模块为operatorFunc
参数传递适当的函数。当我们调用operatorFunc()
时,我们调用的是从operator
模块中为operatorFunc
参数传递的函数— eq()
5 、ne()
6 、lt()
7 、le()
8 、gt()
9 或ge()
a 。否则,我们将不得不在我们的六个比较方法的每一个中重复_comparisonOperatorHelper()
中的代码。
注
像_comparisonOperatorHelper()
这样接受其他函数作为参数的函数(或方法)被称为高阶函数。
我们的WizCoin
对象现在可以与其他WizCoin
对象 2 ,整数和浮点数 3 ,以及代表大帆船、镰刀和关节 4 的三个数值的序列值进行比较。在交互式 Shell 中输入以下内容以查看实际效果:
>>> import wizcoin
>>> purse = wizcoin.WizCoin(2, 5, 10) # Create a WizCoin object.
>>> tipJar = wizcoin.WizCoin(0, 0, 37) # Create another WizCoin object.
>>> purse.total, tipJar.total # Examine the values in knuts.
(1141, 37)
>>> purse > tipJar # Compare WizCoin objects with a comparison operator.
True
>>> purse < tipJar
False
>>> purse > 1000 `# Compare with an int.`
True
>>> purse <= 1000
False
>>> purse == 1141
True
>>> purse == 1141.0 `# Compare with a float.`
True
>>> purse == '1141' # The WizCoin is not equal to any string value.
False
>>> bagOfKnuts = wizcoin.WizCoin(0, 0, 1141)
>>> purse == bagOfKnuts
True
>>> purse == (2, 5, 10) # We can compare with a 3-integer tuple.
True
>>> purse >= [2, 5, 10] # We can compare with a 3-integer list.
True
>>> purse >= ['cat', 'dog'] # This should cause an error.
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "C:\Users\Al\Desktop\wizcoin.py", line 265, in __ge__
return self._comparisonOperatorHelper(operator.ge, other)
File "C:\Users\Al\Desktop\wizcoin.py", line 237, in _comparisonOperatorHelper
otherValue = (other[0] * 17 * 29) + (other[1] * 29) + other[2]
IndexError: list index out of range
我们的辅助方法调用isinstance(other, collections.abc.Sequence)
来查看other
是否是序列数据类型,比如元组或列表。通过将WizCoin
对象与序列进行比较,我们可以编写像purse >= [2, 5, 10]
这样的代码来进行快速比较。
序列比较
当比较两个内置序列类型的对象时,比如字符串、列表或元组,Python 更重视序列中较早的项目。也就是说,它不会比较后面的项目,除非前面的项目具有相等的值。例如,在交互式 Shell 中输入以下内容:
>>> 'Azriel' < 'Zelda'
True
>>> (1, 2, 3) > (0, 8888, 9999)
True
字符串'Azriel'
先于(换句话说,小于)'Zelda'
,因为'A'
先于'Z'
。元组(1, 2, 3)
位于(换句话说,大于)(0, 8888, 9999)
之后,因为1
大于0
。另一方面,在交互式外壳中输入以下内容:
>>> 'Azriel' < 'Aaron'
False
>>> (1, 0, 0) > (1, 0, 9999)
False
字符串'Azriel'
不会出现在'Aaron'
之前,因为即使'Azriel'
中的'A'
等于'Aaron'
中的'A'
,后面的'Azriel'
中的'z'
也不会出现在'Aaron'
中的'a'
之前。同样适用于元组(1, 0, 0)
和(1, 0, 9999)
:每个元组中的前两项相等,所以是第三项(分别为0
和9999
)决定了(1, 0, 0)
在(1, 0, 9999)
之前。
这迫使我们对我们的WizCoin
类做出设计决定。WizCoin(0, 0, 9999)
应该在WizCoin(1, 0, 0)
之前还是之后?如果加隆的数量比镰刀或努特的数量多,那么WizCoin(0, 0, 9999)
应该在WizCoin(1, 0, 0)
之前。或者,如果我们根据以克努特为单位的值来比较对象,WizCoin(0, 0, 9999)
(值 9999 克努特)排在WizCoin(1, 0, 0)
(值 493 克努特)之后。在wizcoin.py
中,我决定使用knuts
中的对象值,因为它使行为与WizCoin
对象与整数和浮点数的比较一致。这些是你在设计自己的类时必须做出的决定。
没有反射的比较标准方法,例如__req__()
或__rne__()
,您需要实现它们。相反,__lt__()
和__gt__()
相互辉映,__le__()
和__ge__()
相互辉映,__eq__()
和__ne__()
相互辉映。原因在于,无论运算符左侧或右侧的值是什么,以下关系都成立:
purse > [2, 5, 10]
与[2, 5, 10] < purse
相同purse >= [2, 5, 10]
与[2, 5, 10] <= purse
相同purse == [2, 5, 10]
与[2, 5, 10] == purse
相同purse != [2, 5, 10]
与[2, 5, 10] != purse
相同
一旦实现了比较魔术方法,Python 的sort()
函数将自动使用它们对对象进行排序。在交互式 Shell 中输入以下内容:
>>> import wizcoin
>>> oneGalleon = wizcoin.WizCoin(1, 0, 0) # Worth 493 knuts.
>>> oneSickle = wizcoin.WizCoin(0, 1, 0) # Worth 29 knuts.
>>> oneKnut = wizcoin.WizCoin(0, 0, 1) # Worth 1 knut.
>>> coins = [oneSickle, oneKnut, oneGalleon, 100]
>>> coins.sort() # Sort them from lowest value to highest.
>>> coins
[WizCoin(0, 0, 1), WizCoin(0, 1, 0), 100, WizCoin(1, 0, 0)]
表 17-3 包含了可用的比较魔术方法和操作函数的完整列表。
表 17-3: 比较魔术方法和operator
模块函数
魔术方法 | 操作 | 比较运算符 | operator 模块中的函数 |
---|---|---|---|
__eq__() | 等于 | == | operator.eq() |
__ne__() | 不等于 | != | operator.ne() |
__lt__() | 小于 | < | operator.lt() |
__le__() | 小于等于 | <= | operator.le() |
__gt__() | 大于 | > | operator.gt() |
__ge__() | 大于等于 | >= | operator.ge() |
你可以在autbor.com/wizcoinfull
看到这些方法的实现。比较数据库方法的完整文档在docs.python.org/3/reference/datamodel.html#object.__lt__
的 Python 文档中。
比较魔术方法允许类的对象使用 Python 的比较运算符,而不是强迫您创建自己的方法。如果你正在创建名为equals()
或isGreaterThan()
的方法,它们不是 Python 风格,它们是你应该使用比较魔术方法的标志。
总结
Python 实现面向对象特性的方式不同于其他 OOP 语言,比如 Java 或 C++。Python 没有显式的获取器和设置器方法,而是具有允许您验证属性或使属性为只读的属性。
Python 还允许您通过它的魔术方法重载它的操作符,这些方法以双下划线字符开始和结束。我们使用数值和反射数值魔术方法重载常见的数学运算符。这些方法为 Python 的内置操作符提供了一种处理您创建的类的对象的方式。如果它们不能处理操作符另一端的对象的数据类型,它们将返回内置的NotImplemented
值。这些魔术方法创建并返回新的对象,而原地魔术方法(重载扩展赋值操作符)原地修改对象。比较魔术方法不仅实现了对象的六个 Python 比较操作符,还允许 Python 的sort()
函数对类的对象进行排序。您可能想要使用operator
模块中的eq()
、ne()
、lt()
、le()
、gt()
和ge()
函数来帮助您实现这些魔术方法。
属性和魔术方法允许您编写一致且可读的类。它们让您避免了其他语言(如 Java)要求您编写的大量样板代码。为了学习更多关于编写 Python 代码的知识,Raymond Hettinger 的两个 PyCon 演讲扩展了这些想法:在youtu.be/OSGv2VnC0go
的“将代码转换成漂亮的、惯用的 Python”和在youtu.be/wf-BqAjZb8M
的“超越 PEP8——漂亮的、可理解的代码的最佳实践”涵盖了本章及以后的一些概念。
关于如何有效地使用 Python,还有很多东西需要学习。卢西亚诺·拉马尔霍的《流畅的 Python》(O’Reilly Media,2021 年)和布雷特·斯拉特金的《Python 高效编程》(Addison-Wesley Professional,2019 年)这两本书提供了关于 Python 语法和最佳实践的更深入的信息,是任何想继续了解 Python 的人的必读之作。