Python 进阶指南(编程轻松进阶):十七、Python 风格 OOP:属性和魔术方法

2023-04-09 09:28:46 浏览数 (1)

原文: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:

代码语言:javascript复制
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'。当您运行该程序时,输出如下所示:

代码语言:javascript复制
some initial value
changed value

该输出表明代码可以轻松地将someAttribute更改为任何值。使用常规属性的缺点是您的代码可能会将someAttribute属性设置为无效值。这种灵活性简单方便,但也意味着someAttribute可能会被设置为一些无效值,从而导致错误。

让我们使用属性重写这个类,按照以下步骤为名为someAttribute的属性重写这个类:

  1. 使用下划线前缀重命名属性:_someAttribute
  2. @property装饰器创建一个名为someAttribute的方法。这个获取器方法有所有方法都有的self参数。
  3. @someAttribute.setter装饰器创建另一个名为someAttribute的方法。这个设置器方法有名为selfvalue的参数。
  4. @someAttribute.deleter装饰器创建另一个名为someAttribute的方法。这个删除方法有所有方法都有的self参数。

打开一个新的文件编辑器窗口,输入以下代码,保存为propertiesExample.py :

代码语言:javascript复制
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 :

代码语言:javascript复制
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异常:

代码语言:javascript复制
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文件,把galleonssicklesknuts属性变成属性。我们将更改这些属性的设置器,以便只有正整数有效。我们的WizCoin对象代表一个硬币的数量,你不能有半个硬币或者硬币数量小于零。如果类外的代码试图将galleonssicklesknuts属性设置为无效值,我们将引发WizCoinException异常。

打开您在第 15 章中保存的wizcoin.py文件,并将其修改为如下所示:

代码语言:javascript复制
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对象引发了其他异常类,比如ValueErrorTypeError,这很可能意味着它是WizCoin类中的一个 bug。

__init__()方法中,我们将self.galleonsself.sicklesself.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成为只读属性:

代码语言:javascript复制
    @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属性的值取决于galleonssicklesknuts属性中的值:该属性并不基于名为_total的支持变量。在交互式 Shell 中输入以下内容:

代码语言:javascript复制
>>> 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属性随着它的galleonssicklesknuts属性的改变而改变。

何时使用属性

正如您在前面几节中看到的,属性为我们如何使用类的属性提供了更多的控制,它们是编写代码的 Python 风格方式。名字像getSomeAttribute()setSomeAttribute()的方法表明你应该使用属性来代替。

这并不是说以getset开始的方法的每个实例都应该立即被替换为一个属性。有些情况下你应该使用一个方法,即使它的名字以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”)字符串是人类可读的字符串,它提供了关于对象的清晰、有用的信息。reprstr字符串分别由repr()str()内置函数返回。例如,在交互式 Shell 中输入以下内容来查看一个datetime.date对象的reprstr字符串:

代码语言:javascript复制
>>> 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()不知道如何为一个对象创建一个reprstr字符串,按照惯例,该字符串将被包含在尖括号中,并包含该对象的内存地址和类名:'<wizcoin.WizCoin object at 0x00000212B4148EE0>'。要为WizCoin对象创建这种字符串,请在交互式 Shell 中输入以下内容:

代码语言:javascript复制
>>> 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文件的末尾:

代码语言:javascript复制
`--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 中输入以下内容:

代码语言:javascript复制
>>> 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 中输入以下内容:

代码语言:javascript复制
>>> 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文件的末尾:

代码语言:javascript复制
`--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对象添加整数或浮点数是没有意义的,因为我们不知道是否应该将它添加到galleonssicklesknuts金额中。

__add__()方法创建一个新的WizCoin对象,其数量等于selfother 3 的galleonssicklesknuts属性的总和。因为这三个属性包含整数,所以我们可以对它们使用 操作符。现在我们已经为WizCoin类重载了 操作符,我们可以对WizCoin对象使用 操作符。

像这样重载 操作符允许我们编写更可读的代码。例如,在交互式 Shell 中输入以下内容:

代码语言:javascript复制
>>> 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是一个整数:

代码语言:javascript复制
>>> 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的末尾添加以下内容:

代码语言:javascript复制
`--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__()魔术方法:

代码语言:javascript复制
>>> 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:UsersAlDesktopwizcoin.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在操作符的右边会发生什么:

  1. 因为2是一个整数,所以调用int类的__mul__()方法时会将purse传递给other参数。
  2. int类的__mul__()方法不知道如何处理WizCoin对象,所以返回NotImplemented
  3. Python 还没有引发一个TypeError。因为purse包含了一个WizCoin对象,所以调用WizCoin类的__rmul__()方法时会将2传递给other参数。
  4. 如果__rmul__()返回NotImplemented,Python 会引发一个TypeError

否则,从__rmul__()返回的对象就是2 * purse表达式的计算结果。

但是表达式purse * 2的工作方式不同,其中purse位于操作符的左侧:

  1. 因为purse包含了一个WizCoin对象,所以调用WizCoin类的__mul__()方法时会将2传递给other参数。
  2. __mul__()方法创建一个新的WizCoin对象并返回它。
  3. 这个返回的对象就是purse * 2表达式的计算结果。

如果数字魔术方法和反射数字魔术方法是可交换的,则它们具有相同的代码。交换运算和加法一样,向后和向前的结果是一样的:3 22 3是一样的。但是其他的运算是不可交换的:3 – 2不同于2 – 3。每当调用反射的数值魔术方法时,任何交换操作都可以只调用原始的数值魔术方法。例如,将以下内容添加到wizcoin.py文件的末尾,为乘法运算定义一个反射的数值魔术方法:

代码语言:javascript复制
`--snip--`
    def __rmul__(self, other):
        """Multiplies the coin amounts by a non-negative integer."""
        return self.__mul__(other)

一个整数和一个WizCoin对象相乘是可换的:2 * pursepurse * 2一样。我们不需要从__mul__()复制并粘贴代码,我们只需要调用self.__mul__()并传递给它other参数。

更新wizcoin.py后,通过在交互式 Shell 中输入以下内容,练习使用反射乘法魔术方法:

代码语言:javascript复制
>>> 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__()

按位或

&#124;

__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 = tipJarpurse *= 2中,我们分别调用__iadd__()__imul__()方法,分别为other参数传递tipJar2。将以下内容添加到wizcoin.py文件的末尾:

代码语言:javascript复制
`--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对象:

代码语言:javascript复制
>>> 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 就应该原地修改对象。不可变对象例外:因为不可变对象不能被修改,所以不可能原地修改它。在这种情况下,原地魔术方法应该创建并返回一个新对象,就像数值和反射数值魔术方法一样。

我们没有将galleonssicklesknuts属性设为只读,这意味着它们可以改变。所以WizCoin对象是可变的。您编写的大多数类都会创建可变对象,因此您应该设计您的原地魔术方法来原地修改对象。

如果不实现原地魔术方法,Python 将调用数值魔术方法。例如,如果WizCoin类没有__imul__()方法,表达式purse *= 10将调用__mul__()并将其返回值赋给purse.,因为WizCoin对象是可变的,这是一种意外的行为,可能会导致微妙的错误。

比较魔术方法

Python 的sort()方法和sorted()函数包含一个高效的排序算法,您可以通过一个简单的调用来访问它。但是如果你想对你创建的类的对象进行比较和排序,你需要告诉 Python 如何通过实现比较魔术方法来比较其中的两个对象。每当在带有<><=>===!=比较运算符的表达式中使用对象时,Python 就会在后台调用比较魔术方法。

在我们探索比较魔术方法之前,让我们检查一下operator模块中的六个函数,它们执行与六个比较操作符相同的操作。我们的比较方法将调用这些函数。在交互式 Shell 中输入以下内容。

代码语言:javascript复制
>>> 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()函数:

代码语言:javascript复制
def eq(a, b):
    return a == b

拥有比较运算符的函数形式很有用,因为与运算符不同,函数可以作为参数传递给函数调用。我们将这样做来为我们的比较魔术方法实现一个辅助方法。

首先,在wizcoin.py的开头添加以下内容。这些导入让我们可以访问operator模块中的函数,并允许我们通过与collections.abc.Sequence进行比较来检查方法中的other参数是否是一个序列:

代码语言:javascript复制
import collections.abc
import operator

然后将以下内容添加到wizcoin.py文件的末尾:

代码语言:javascript复制
`--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 中输入以下内容以查看实际效果:

代码语言:javascript复制
>>> 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:UsersAlDesktopwizcoin.py", line 265, in __ge__
    return self._comparisonOperatorHelper(operator.ge, other)
  File "C:UsersAlDesktopwizcoin.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 中输入以下内容:

代码语言:javascript复制
>>> 'Azriel' < 'Zelda'
True
>>> (1, 2, 3) > (0, 8888, 9999)
True

字符串'Azriel'先于(换句话说,小于)'Zelda',因为'A'先于'Z'。元组(1, 2, 3)位于(换句话说,大于)(0, 8888, 9999)之后,因为1大于0。另一方面,在交互式外壳中输入以下内容:

代码语言:javascript复制
>>> '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):每个元组中的前两项相等,所以是第三项(分别为09999)决定了(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 中输入以下内容:

代码语言:javascript复制
>>> 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 的人的必读之作。

0 人点赞