Python3-高级编程-二-

95 阅读1小时+

Python3 高级编程(二)

原文:Pro Python 3

协议:CC BY-NC-SA 4.0

四、类

在第三章中,你回顾了函数如何允许你定义可重用的代码。这允许通过不必重新键入代码的“块”来简化一般的代码。然而,将这些相同的功能组合成定义特定类型对象的行为和属性的逻辑组通常更有用。这是标准的面向对象(OO)编程,在 Python 中通过类型来实现。这些就像函数一样,表面上看起来很简单,但是在它们背后有相当大的力量可以利用。

类最基本的想法是它封装了对象的行为,而类实例代表了对象的数据。因此,即使数据可能会从一个实例变化到另一个实例,由底层类决定的行为在这些实例之间将保持不变。定义、扩展和改变该行为是本章的重点。

遗产

使用类的最简单的方法是为单一类型的对象定义一个单一的类。这对于许多简单的应用来说很好,但是您可能会发现需要对对象的行为进行更细粒度的控制。特别是,对于一个大的对象集合,通常只有一组通用的行为,但是您需要修改它们,或者为一个更小的更具体的对象集合添加新的行为。

为了方便起见,Python 允许每个类指定一个或多个提供基本行为的基类。然后,正在定义的新类可以添加新的行为或覆盖任何现有的行为。默认情况下,所有对象都继承自内置的object类型,尽管这本身并没有真正做任何有用的事情。它实际上只是一个支撑整个系统的基础类型,因为其他所有东西都是从它继承的。

像大多数面向对象的语言一样,Python 允许您为给定的类定义尽可能多的子类,并且您也可以对这些子类化,根据需要深入到尽可能多的层次。这种垂直的继承方法适用于大多数应用,因为它最大化了基类的有用性。当一个单独的、通常很大的行为集需要跨各种其他类重用时,垂直继承被证明非常有用。尝试一个带有显式构造函数的非常简单的 Python 类:

img/330715_3_En_4_Figa_HTML.jpg

class Contact:
    def __init__(self, lName, fName): # explicit constructor for class
        self.lastName = lName
        self.firstName = fName

worker1 = Contact("Smith", "James")
print(worker1.lastName, worker1.firstName)

Python 也有一些内置函数来修改你的类..这是对本章“属性”部分的前瞻,但是这些函数是 getattr(obj,name) 来访问对象的属性; setattr(obj,name,value) 设置一个对象的属性; hasattr(obj,name) 检查是否存在;最后, delattr(obj,name) 删除对象中的属性。当然,一旦创建了对象,就可以访问公共属性:

img/330715_3_En_4_Figb_HTML.jpg

class Contact:
    def __init__(self, lName, fName): # explicit constructor for class
        self.lastName = lName
        self.firstName = fName
worker1 = Contact('Smith', 'James')
print(worker1.lastName, worker1.firstName) # object.public_property
newLast=raw_input('Enter new last name: ')
setattr(worker1,'lastName',newLast) # set attribute with new value
print(worker1.lastName, worker1.firstName)
print(getattr(worker1, 'lastName')) # get existing attribute

作为又一示例,考虑涉及联系人管理应用的常见场景。在所有其他东西的基础上,你会有一个Contact类,因为,根据定义,应用中的所有东西都是一个联系人。它将有一组与之相关的字段和行为,根据应用的需要,这些字段和行为仅涵盖与所有联系人相关的内容:

img/330715_3_En_4_Figc_HTML.jpg

class Contact:
    name = TextField()
    email = EmailAddressField()
    phone = PhoneNumberField()

    def send_mail(self, message):
        # Email sending code would go here

现在,不要担心每个字段类来自哪里或它们在应用中如何工作的细节。如果你感兴趣,第十一章展示了一个可能的框架来编写这样的类。现在的关键是,每个字段都代表与手头的类相关的一个数据片段。值可能由用户输入、数据库查询的结果甚至随机值生成器提供;重要的是类的结构以及子类如何使用它。

即使只有一个联系人,您也可以基于这些核心字段和行为创建一个有用的应用。提供附加功能意味着增加对不同类型联系人的支持。例如,真实的人有名字、姓氏,也许还有手机,而公司通常只有一个名字和电话号码。同样,公司会在特定行业开展业务,这对于个人来说没有任何意义:

img/330715_3_En_4_Figd_HTML.jpg

class Person(Contact):
    first_name = TextField()
    last_name = TextField()
    name = ComputedString('%(last_name)s, %(first_name)s')
    cell_phone = PhoneNumberField()

class Company(Contact):
    industry = TextField()

现在我们已经有了一个基本的层次结构。人和公司不一样,每个人都有适合每个案例的不同领域。Python 的继承系统自动从Contact类中提取字段,并使它们在PersonCompany类中可用。您也可以对它们进行子类化,提供诸如EmployeeFriendFamilyMember这样的Person类型:

img/330715_3_En_4_Fige_HTML.jpg

class Employee(Person):
    employer = RelatedContact(Company)
    job_title = TextField()
    office_email = EmailAddressField()
    office_phone = PhoneNumberField()
    extension = ExtensionField()

class Friend(Person):
    relationship = TextField()

class FamilyMember(Person):
    relationship = TextField()
    birthday = DateField()

注意,尽管FriendFamilyMember都有彼此相同的关系字段,FamilyMember并不从Friend继承。家庭成员不一定会成为朋友,所以阶级结构反映了这一点。每个新的子类都被自动认为是它所扩展的类的一个更具体的例子,所以继承方案反映被编码的实际关系是很重要的。

这看起来像是哲学上的细节,但它在代码中也有实际的分支。正如在本章的“自省”一节中将要展示的,Python 代码可以查看类的继承结构,所以任何不匹配都会导致你的代码将一种类型的类与另一种类型的类混淆。避免这些问题的最好方法是考虑你所表示的对象实际上是如何相互关联的,并尝试在代码中重新创建这些关系。

多重继承

Python 也支持类继承的水平方法,允许一个子类一次定义多个基类。这样,一个类可以从许多不同的类中获得行为,而不必深入几个层次。当然,这意味着采用不同的逻辑方法,因为您不再通过增加特异性来定义类。相反,在多重继承的一些应用中,您实际上是将每个类构建成一组组件。

像这样构建类特别适合于这样的应用,在这些应用中,您的类共享一些共同的行为,但在其他方面没有以分层的方式相互关联。为了有意义,这通常需要从相当多的组件中构建大量的类。因为这不是大多数应用的组合方式,所以在野外很少使用这种方式。

相反,多重继承经常被调用来应用支持类,称为 mixins。Mixin 类本身不提供完整的功能;相反,它们只提供了一个很小的附加特性,这个特性在很多不同的类上都很有用。一个例子可能是 mixin,当您试图访问对象上不可用的任何属性时,它返回None,而不是引发AttributeError:

img/330715_3_En_4_Figf_HTML.jpg

class NoneAttributes:
    def __getattr__(self, name):
        return None

__getattr__()方法将在本章后面的“神奇方法”一节中详细介绍,每当请求对象上不可用的属性时,就会调用该方法。因为它作为一个后备,所以它是 mixin 的一个显而易见的选择;real 类提供了自己的功能,mixin 在适当的地方添加了这些功能:

img/330715_3_En_4_Figg_HTML.jpg

class Example(BaseClass, NoneAttributes):
      pass

e = Example()
e.does_not_exist

在典型的应用中,垂直层次将提供大部分功能,mixins 在必要的地方添加一些额外的功能。由于在访问属性时涉及到的类的潜在数量,完全理解 Python 如何决定每个被访问的属性和方法使用哪个类变得更加重要。换句话说,你需要知道 Python 解析使用哪个方法的顺序。

方法解析顺序

给定一个类层次结构,Python 需要确定在试图通过名称访问属性时使用哪个类。为了做到这一点,Python 有一些规则来管理在定义新类时如何对一组基类进行排序。对于类的大多数基本用法,你不需要知道它是如何工作的,但是如果你使用多级或多重继承,本节的细节将帮助你理解真正发生了什么。

在简单的仅垂直场景中,很容易想象如何创建方法分辨率顺序(MRO)。您实际使用的类将是第一个,接下来是它的基类,然后是基类的基类,依此类推,直到您返回到根object类型。

在这个链的每一步,Python 都会检查这个类是否有一个带有所请求名称的属性,如果有,那就是你得到的。如果没有,它会移动到下一个。用一个简单的例子就可以很容易看出这一点。在提示符下键入并尝试:

img/330715_3_En_4_Figh_HTML.jpg

>>> class Book:
...     def __init__(self, title):
...         self.title = title
...         self.page = 1
...     def read(self):
...         return 'There sure are a lot of words on page %s.' % self.page
...     def bookmark(self, page):
...         self.page = page
...
>>> class Novel(Book):
...     pass
...
>>> class Mystery(Novel):
...     def read(self):
...         return "Page %s and I still don't know who did it!" % self.page
...
>>> book1 = Book('Pro Python')
>>> book1.read()
'There sure are a lot of words on page 1.'
>>> book1.bookmark(page=52)
>>> book1.read()
'There sure are a lot of words on page 52.'
>>> book2 = Novel('Pride and Prejudice')
>>> book2.read()
'There sure are a lot of words on page 1.'
>>> book3 = Mystery('Murder on the Orient Express')
>>> book3.read()
"Page 1 and I still don't know who did it!"
>>> book3.bookmark(page=352)
>>> book3.read()
"Page 352 and I still don't know who did it!"

正如你所看到的,当在一个Mystery对象上调用read()时,你得到了直接在那个类上定义的方法,而在同一个类上使用bookmark()使用了来自Book的实现。同样,Novel本身并没有定义任何东西——它只是为了建立一个更有意义的层次结构——所以你可以访问的所有方法实际上都来自Book。说得更直接一点,Mystery的 MRO 就是[Mystery, Novel, Book],而Novel的 MRO 就是简单的[Novel, Book]

那么,当你采用使用多重继承的水平方法时会发生什么呢?为了简单起见,我们将从提供的每个基类的单一继承层开始,这样它就是一个纯粹的水平方法。在这种情况下,Python 从左到右,按照类被定义为基类的顺序。一旦我们添加了一个purchase() method,这将允许用户购买这本书的副本,这就是上一个例子的样子。如果您仍然打开先前的终端会话,请尝试在我们所做的基础上添加下一部分内容:

img/330715_3_En_4_Figi_HTML.jpg

>>> class Product:
...     def purchase(self):
...         return 'Wow, you must really like it!'
...
>>> class BookProduct(Book, Product):
...     pass
...
>>> class MysteryProduct(Mystery, Product):
...     def purchase(self):
...         return 'Whodunnit?'
...
>>> product1 = BookProduct('Pro Python')
>>> product1.purchase()
'Wow, you must really like it!'
>>> product2 = MysteryProduct('Murder on the Orient Express')
>>> product2.purchase()
'Whodunnit?'

到目前为止,每部 MRO 都非常简单易懂,即使你不知道幕后发生了什么。不幸的是,当你开始结合两种形式的继承时,事情变得更加复杂。它甚至没有举一个非常复杂的例子来说明问题;考虑一下,当您从一个拥有自己的基类和独立 mixin 的类继承时会发生什么:

img/330715_3_En_4_Figj_HTML.jpg

class A:
    def test(self):
        return 'A'

class B(A):
    pass

class C:
    def test(self):
        return 'C'

这很简单,但是如果你创建一个新的类,D,它同时继承了BC,如果你调用它的test() method,会发生什么呢?和往常一样,在交互式解释器中测试这一点很容易,你会发现答案取决于你先放哪一个。确保您在同一个会话中,并且键入了上述代码,然后尝试以下操作来查看结果:

img/330715_3_En_4_Figk_HTML.jpg

>>> class D(B, C):
...     pass
...
>>> D().test()
'A'
>>> class D(C, B):
...     pass
...
>>> D().test()
'C'

从表面上看,似乎很容易假设 Python 只是深度优先;它会查看第一个基类,并一直跟踪下去,寻找所请求的属性,只有在找不到所需的属性时,才会移动到下一个基类。这个观察对于这个和许多其他的例子来说当然是正确的,但是它仍然不是故事的全部。真正发生的事情会将整个继承方案考虑在内。

然而,在阐明完整的算法之前,让我们先弄清楚一件事。Python 关注的第一个名称空间总是实例对象。如果在那里找不到该属性,它将转到提供该对象行为的实际类。这两个名称空间总是首先被检查的两个名称空间,不管任何可能正在使用的继承结构。只有在没有找到属性的情况下,Python 才会尝试通过类继承来定位它。

Python 并没有把整个继承结构看作一棵树,而是试图把它展平成一个列表,每个类只出现一次。这是一个重要的区别,因为两个基类在链的更深处继承同一个类是可能的,但是查看那个类两次只会导致以后的混乱。为了解决这个和其他潜在的问题,需要一个单一的、平面的列表。

第一步是确定从一个类到它的最基本类的所有不同路径。因为两个原因,即使没有基类,也总会有至少一个路径。首先,给定类的 MRO 总是将类本身放在第一位。从前面的描述来看,这似乎是显而易见的,但是算法的其余部分将清楚地说明为什么明确地陈述这一点是重要的。此外,每个类都隐式继承自object,所以这是在每个 MRO 的末尾。

所以,对于一个简单的类,A,它不继承任何东西,它的 MRO 只是一个简单的两元素列表:[A, object]。如果你有另一个类,B,它的子类是A,它的 MRO 也变得相当明显,是[B, A, object]。一旦引入了一点多重继承,同一个类就有可能在整个树中出现不止一次,所以我们需要做一些额外的工作来整理 MRO。

考虑一个新的类,C,它继承自BA。现在A出现在两个不同的分支下,离新类C有两个不同的距离。

注意

这样做可能没有意义,因为B已经继承了A。但是,请记住,您可能并不总是预先知道基类在幕后做什么。你可以扩展从其他地方传入你的代码的类或者动态生成的类,就像本章后面将要展示的那样。Python 不知道你的类是如何布局的,所以它必须能够考虑所有的可能性。

img/330715_3_En_4_Figl_HTML.jpg

>>> class A:
...     pass
...
>>> class B(A):
...     pass
...
>>> class C(B, A):
...     pass
...

对象的 MRO 显然只是[object],正如你所料,A 已经显示为[A, object]B明明是[B, A, object],那C呢?首先看它的深度,一旦删除了重复的 A,你可能会猜到[C, B, A, object]。采用广度优先(先水平后垂直)的方法,您会想到[C, A, B, object]

那么 Python 到底走哪条路呢?事实是,这两种说法都不准确;Python 使用一种叫做 C3 的算法。这个算法考虑了所有的继承,一次减少一层,直到只剩下一个列表。在每个级别,C3 处理为该级别的所有父类别创建的类别列表。因此,它从最普通的类object开始,并从那里继续向外扩展。

有了C,我们终于可以详细了解算法是如何工作的了。到 Python 遇到C的时候,AB都已经被处理了,所以它们的 mro 是已知的。为了将它们结合起来,C3 查看每个父 MRO 中的第一个类,看它是否能找到一个候选项,以包含在C的 MRO 中。当然,这回避了一个问题,即什么才是有效的候选人。

用于识别候选类别的唯一标准是它是否仅存在于所考虑的任何 MRO 列表的第一位。不一定要全部都有,但是如果有,一定是列表中的第一个。如果它在任何列表中的任何其他位置,C3 将跳过它,直到它的下一次传递。一旦找到一个有效的条目,它就把它拉入新的 MRO,并使用相同的过程寻找下一个。

例如:C3 算法

因为算法实际上只是代码,所以让我们构建一个简单的 C3 函数来执行必要的线性化——将继承树简化为一个列表。然而,在深入完整的实现之前,让我们先看看函数调用是什么样子的,这样我们就知道它将处理什么数据。对于C,它看起来像这样:

C3(C, [B, A, object], [A, object], [B, A])

第一个参数是类本身,后面是其父类的已知 MRO 列表,按照它们在类中定义的顺序。然而,最后一个参数只是父类本身的列表,没有它们的完整 mro。正如稍后对C稍加修改后所显示的,这个额外的参数对于解决一些歧义是必要的。

与任何功能一样,在完成真正的繁重工作之前,有一些令人厌烦的细节需要落实到位。在 C3 的例子中,会有一些对 MRO 列表的修改,我们不希望这些修改影响到调用 C3 函数的代码,所以我们必须复制它们来使用。此外,我们需要建立一个新的列表来包含算法生成的最终 MRO:

img/330715_3_En_4_Figm_HTML.jpg

def C3(cls, *mro_lists):
    # Make a copy so we don't change existing content
    mro_lists = [list(mro_list[:]) for mro_list in mro_lists]

    # Set up the new MRO with the class itself
    mro = [cls]

    # The real algorithm goes here.

    return mro

我们不能在这里使用mro_list[:],因为它只是复制了外部列表。该列表中包含的所有其他列表都将保留,因此对它们的任何修改在函数外部都是可见的。通过使用列表理解并复制每个内部列表,我们得到了所有相关列表的副本,因此它们可以被安全地修改。

稳健性原则

如果你已经知道 Python 的复制模块——或者你已经跳到第六章了——你可能想知道为什么我们不用copy. d eepcopy(mro_list)来代替。至少,你可能想知道这个额外的列表(mro_list[:])是干什么用的,因为我们已经在传入列表了。通过将每个内部序列显式地转换为列表,并将其全部包装在一个列表理解中,我们可以允许函数接受任何有效的序列类型,包括元组,这些元组在创建后不能被修改(就像常量一样)。这使得 C3 函数在接受的内容上更加自由。

内务处理结束后,我们可以继续讨论主算法。因为我们事先不知道每个 MRO 中有多少个类,所以最好将主工作负载包装在一个简单的while True循环中,该循环将无限期执行,这样我们就可以使用breakcontinue来控制它的流程。当然,这意味着您不应该尝试执行这段代码,直到稍后,直到我们有了必要的控制代码。

该循环中的第一个任务是遍历每个 MRO 列表,获取它的第一个类,并查看它是否在任何其他列表中除了第一个之外的任何位置。如果是,那么这个类还不是一个有效的候选类,我们需要移动到下一个列表中的第一个类。下面是执行这些最初步骤所必需的循环:

img/330715_3_En_4_Fign_HTML.jpg

import itertools

def C3(cls, *mro_lists):
    # Make a copy so we don't change existing content
    mro_lists = [list(mro_list[:]) for mro_list in mro_lists]

    # Set up the new MRO with the class itself
    mro = [cls]

    while True:
        for mro_list in mro_lists:
            # Get the first item as a potential candidate for the MRO.
            candidate = mro_list[0]

            if candidate in itertools.chain(*(x[1:] for x in mro_lists)) :
                # The candidate was found in an invalid position, so we
                # move on to the next MRO list to get a new candidate.
                continue

    return mro

这里使用的链将所有 MRO 列表中的所有非第一类减少到一个列表中,因此更容易测试当前候选项是否有效。当然,当前代码只在候选无效时才响应。如果在那个链中没有找到,它就是一个有效的候选,可以马上提升到最终的 MRO。

此外,我们需要从发现该候选人的 MRO 名单中删除该候选人,以及可能发现该候选人的任何其他名单。由于我们知道它只能是任何列表中的第一项,并且不会出现在这一轮已经处理过的任何列表中,所以这变得稍微容易了一点。因此,我们可以只查看每个剩余的候选项,并删除被提升的类。无论如何,这一次不应该为新的候选人处理其他 MRO 列表,所以我们还需要添加一个continue:

img/330715_3_En_4_Figo_HTML.jpg

    while True:
    # Reset for the next round of tests
        candidate_found = False

        for mro_list in mro_lists:
            if not len(mro_list):
                # Any empty lists are of no use to the algorithm.
                continue

            # Get the first item as a potential candidate for the MRO.
            candidate = mro_list[0]

            if candidate_found:
                # Candidates promoted to the MRO are no longer of use.
                if candidate in mro:
                    mro_list.pop(0)
                # Don't bother checking any more candidates if one was found.
                continue

            if candidate in itertools.chain(*(x[1:] for x in mro_lists)) :
                # The candidate was found in an invalid position, so we
                # move on to the next MRO list to get a new candidate.
                continue

            else:
                # The candidate is valid and should be promoted to the MRO.
                mro.append(candidate)
                mro_list.pop(0)
                candidate_found = True

注意

既然我们要从 MRO 列表中删除项目,我们还必须添加额外的代码来处理其中一个列表被完全清空的情况。因为空列表中没有任何有价值的东西,所以循环会继续到下一个。

现在候选选择已经完成,剩下的事情就是告诉算法它的工作什么时候完成,它应该退出循环。按照现在的情况,它会完全清空列表,但是会一直循环下去,永远不会返回新的 MRO。识别这种情况的关键是它确实会清空所有的列表。因此,我们可以检查剩余的 MRO 列表,看看是否还有任何类。如果没有,就完成了,可以结束循环:

img/330715_3_En_4_Figp_HTML.jpg

    while True:
        # Reset for the next round of tests
        candidate_found = False

        for mro_list in mro_lists:
            if not len(mro_list):
                # Any empty lists are of no use to the algorithm.
                continue

            # Get the first item as a potential candidate for the MRO.
            candidate = mro_list[0]

            if candidate_found:
                # Candidates promoted to the MRO are no longer of use.
                if candidate in mro:
                    mro_list.pop(0)
                # Don't bother checking any more candidates if one was found.
                continue

            if candidate in itertools.chain(*(x[1:] for x in mro_lists)) :
                # The candidate was found in an invalid position, so we
                # move on to the next MRO list to get a new candidate.
                continue

            else:
                # The candidate is valid and should be promoted to the MRO.
                mro.append(candidate)
                mro_list.pop(0)
                candidate_found = True

        if not sum(len(mro_list) for mro_list in mro_lists):
            # There are no MROs to cycle through, so we're all done.
            # note any() returns false if no items so it could replace sum(len)
            break

这个循环,在已经提到的 C3 函数中,可以成功地为任何有效的 Python 继承模式创建一个 MRO。回到前面提到的对C类的函数调用,我们会得到下面的结果。请注意,我们在这里使用的是字符串而不是实际的类,以便于说明。无论如何,C3 算法实际上与类无关;这只是为了消除可能包含重复项的层次结构:

>>> C3('C', ['B', 'A', 'object'], ['A', 'object'], ['B', 'A'])
['C', 'B', 'A', 'object']

这很好,但是还有另一个相关的情况需要注意:当CB之前继承A时会发生什么?人们会从逻辑上假设在A上找到的任何属性都会在B上的属性之前使用,即使B的 MRO 将B放在A之前。这将违反类继承中的一个重要的一致性:MRO 中项目的顺序应该在它未来的所有子类中保持。

这些子类可以在它们的 MRO 中添加新的物件,甚至可以将它们插入到基类 MRO 中的物件之间,但是所有相关的 mro 仍然应该保持原来的顺序。所以当做类似于C(A, B)的事情时,正确的结果实际上会与用户的期望不一致。

这就是为什么 C3 算法要求将基类本身添加到传入的 mro 列表中。如果没有它们,我们可以用这个新的结构调用 C3 算法,得到与原始排序相同的结果:

>>> C3('C', ['B', 'A', 'object'], ['A', 'object'])
['C', 'B', 'A', 'object']
>>> C3('C', ['A', 'object'], ['B', 'A', 'object'])
['C', 'B', 'A', 'object']

尽管看起来这两个人应该做不同的事情,但实际上他们最终会做同样的事情。然而,通过在末尾添加额外的类列表,C3 的行为发生了一点变化。第一个候选人是A,在B的 MRO 的第二个位置,所以本轮跳过A。下一个候选项是B,它在最后一个参数中添加的列表中,所以也被跳过。当检查最终列表时,再次跳过 A。

这意味着 C3 完成了一个完整的循环,而没有找到任何有效的候选,这就是它如何检测像C(A, B)这样的不适当的构造。如果没有有效的候选项,则不会从任何列表中删除任何项目,并且主循环将使用完全相同的数据再次运行。没有对无效情况的任何额外处理,我们当前的 Python 实现将简单地无限期继续下去。最好提出一个例外。然而,首先让我们通过用C(A, B)检查 Python 自身的行为来验证这个假设。假设您键入了前面的示例,请尝试以下操作:

img/330715_3_En_4_Figq_HTML.jpg

>>> class A:
...     pass
...
>>> class B(A):
...     pass
...
>>> class C(A, B):
...     pass
...
Traceback (most recent call last):
  ...
TypeError:  Cannot create a consistent method resolution
order (MRO)  for bases B, A

果然,Python 的类系统不允许这种构造,以迫使开发人员只创建有意义的类。在我们自己的 C3 类中复制这个功能是相当容易的,因为我们知道如何识别无效的情况。我们所要做的就是在循环结束时进行检查,看看是否找到了有效的候选对象。如果没有,我们可以提出一个TypeError:

img/330715_3_En_4_Figr_HTML.jpg

import itertools

def C3(cls, *mro_lists):
    # Make a copy so we don't change existing content
    mro_lists = [list(mro_list[:]) for mro_list in mro_lists]

    # Set up the new MRO with the class itself
    mro = [cls]

    while True:
        # Reset for the next round of tests
        candidate_found = False

        for mro_list in mro_lists:
            if not len(mro_list):
                # Any empty lists are of no use to the algorithm.
                continue

            # Get the first item as a potential candidate for the MRO.
            candidate = mro_list[0]

            if candidate_found:
                # Candidates promoted to the MRO are no longer of use.
                if candidate in mro:
                    mro_list.pop(0)
                # Don't bother checking any more candidates if one was found.
                continue

            if candidate in itertools.chain(*(x[1:] for x in mro_lists)) :
                # The candidate was found in an invalid position, so we
                # move on to the next MRO list to get a new candidate.
                continue

            else:
                # The candidate is valid and should be promoted to the MRO.
                mro.append(candidate)
                mro_list.pop(0)
                candidate_found = True

        if not sum(len(mro_list) for mro_list in mro_lists):
            # There are no MROs to cycle through, so we're all done.
            break

        if not candidate_found:
            # No valid candidate was available, so we have to bail out.
            break
            raise TypeError("Inconsistent MRO")

    return mro

有了这最后一部分,我们的 C3 实现与 Python 自身的行为相匹配,涵盖了所有的基础。大多数任意的类继承结构都可以简化为一个有效的 MRO,所以你通常不需要太担心算法是如何工作的。然而,类有一个特性——super() function—广泛依赖于 MRO。

使用 super()将控制传递给其他类

创建子类的一个最常见的原因是覆盖一些现有方法的行为。这可能像每次调用方法时记录日志一样简单,也可能像用不同的实现完全替换其行为一样复杂。在前一种情况下,您只是简单地调整现有的行为,能够直接使用原始的实现是非常有用的,这样您就不必为了做一些小的更改而重新发明轮子。

为了实现这一点,Python 提供了内置的super() function,这一点经常被误解。对super()的常见解释是,它允许您在子类的被覆盖方法中调用基类的方法。这种描述在一定程度上是可行的,但在更全面地解释它之前,让我们先看看它在简单情况下的表现,看看这到底意味着什么:

img/330715_3_En_4_Figs_HTML.jpg

class A(object):
    def afunction(self):
        print('afunction from Class A')
class B(A):
    def __init__(self):
        print('B is constructed!!!') # constructor for B
    def afunction(self):
        return super(B, self).afunction()
sample1=B()
print(sample1.afunction())

在这个简单的例子中,super()返回方法的基类。基于我们刚刚读到的内容,super()查看 MRO 中的下一个类,在本例中是类 a。注意,我们说“被覆盖”,因为我们有两个名为 afunction 的函数。

接下来,考虑一个应用,该应用需要创建一个字典,为任何还没有关联值的键自动返回None。这与defaultdict相当类似,但它不必每次都创建一个新值;它刚刚返回None:

img/330715_3_En_4_Figt_HTML.jpg

>>> class NoneDictionary(dict):
...     def __getitem__(self, name):
...         try:
...             return super(NoneDictionary, self). __getitem__(name)
...         except KeyError:
...             return None
...
>>> d = NoneDictionary()
>>> d['example']
>>> d['example'] = True
>>> d['example']
True

在进一步深入之前,重要的是要认识到super()在这里真正在做什么。在一些语言中,super()仅仅是一种语言特性,被编译成一些特殊的代码来访问其他类的方法。然而,在 Python 中,super()返回一个实际的对象,该对象具有一组基于其使用位置的属性和方法。

从这个简单的例子来看,super()似乎只是提供了对基类上的一个方法的访问,但是请记住,可以包含任意数量的基类,在每个类上指定不止一个。鉴于某些继承结构的复杂性,现在应该很清楚 Python 将使用 MRO 来决定使用哪种方法。然而,可能不明显的是在查找方法时使用哪个 MRO。

仅仅看一下,你可能会认为 Python 使用了使用了super()的类的 MRO,在这里给出的例子中是NoneDictionary。因为大多数情况看起来非常像那个例子,所以这个假设足够准确,可以解释大多数情况。然而,更复杂的类层次结构提出了一个问题,当子类中的 MRO 发生变化时会发生什么。考虑下面的一组类;但是,开始一个新的 Python 会话,因为这些类定义与我们的第一个示例略有不同:

img/330715_3_En_4_Figu_HTML.jpg

>>> class A:
...     def test(self):
...         return 'A'
...
>>> class B(A):
...     def test(self):
...         return 'B->' + super(B, self). test()
...
>>> B().test()
'B->A'

在这个例子中,在B中使用super()指的是它的基类A,正如所料。它的test()方法包含一个对自身的引用,所以如果事情发生变化,我们将能够一路看到。除了B,我们可以定义另一个类C,它也是subclasses A。为了更好地说明未来的事情,C将实现它自己的test()方法,而不使用super():

img/330715_3_En_4_Figv_HTML.jpg

>>> class C(A):
...     def test(self):
...         return 'C'
...
>>> C().test()
'C'

当然,到目前为止,这没有什么不寻常或有问题的,因为它没有以任何方式与AB交互。当我们创建一个新类D时,事情变得有趣起来,这个新类又包含了BC两个子类。它不需要一个test() method,所以我们只是把它的主体留白,让它尽可能的简单。现在让我们看看test()会发生什么:

img/330715_3_En_4_Figw_HTML.jpg

>>> class D(B, C):
...     pass
...
>>> D().test()
'B->C'

现在我们终于可以看到发生了什么。我们可以看到test()B上被调用,导致其在输出中引用,但是当它调用super().test()时,引用的是C的方法,而不是A上的方法。如果 Python 只是使用了定义方法的类的 MRO,它将引用A,而不是C。相反,因为它使用了C,我们可以深入了解super()的实际工作原理。

在最常见的情况下,包括这里显示的用法,super()接受两个参数:一个类和该类的一个实例。正如我们的示例所示,实例对象决定了哪个 MRO 将用于解析结果对象的任何属性。所提供的类确定了 MRO 的子集,因为super()只使用 MRO 中出现在所提供的类之后的那些条目。

推荐的用法是提供将super()用作第一个参数的类,将标准 self 用作第二个参数。产生的对象将保留self的实例namespace字典,但是它只检索在 MRO 中比提供的类更晚的类上定义的属性。然而,从技术上讲,您可以传入不同的类并得到不同的结果:

img/330715_3_En_4_Figx_HTML.jpg

>>> class B(A):
...     def test(self):
...         return 'B->' + super(C, self). test()
...
>>> class D(B, C):
...     pass
...
>>> D().test()
'B->A'

在这个例子中,当B在对super()的调用中实际引用了C时,产生的 MRO 跳过C,直接移动到A,这通过再次调用test()来显示。然而,在通常的实践中,这是一件危险的事情,正如试图单独使用B时所示:

img/330715_3_En_4_Figy_HTML.jpg

>>> B().test()
Traceback (most recent call last):
  ...
TypeError:  super(type, obj):  obj must be an instance or subtype of type

因为在这种情况下 self 不是C的子类,C不在 MRO 的任何地方,所以super()不能确定它应该从哪里开始寻找属性。与其创建一个无用的对象,只为每件事抛出一个AttributeErrorsuper()在第一次被调用时会失败,提供一个更好的错误消息。

警告:小心你的论点

使用super()时一个常见的错误是在一个方法上使用它,这个方法在所有不同的类中不会总是有相同的签名。在我们这里的例子中,test()方法不接受任何参数,所以很容易确保它是一致的。许多其他情况,比如前面提到的__getitem__(),都是标准协议,它们的函数签名不应该被任何子类显著改变。第五章更详细地展示了这些案例。

不幸的是,你不能总是知道另一个类会做什么,所以使用 super()有时会给给定的类提供错误的参数,从而导致问题。当然,这与传入一个与另一个函数所期望的协议不同的对象并没有什么不同。

使用 super()值得注意的原因是,很容易假设您知道实际调用的是什么函数。如果没有对 mro 如何工作以及super()如何决定使用哪些属性的深刻理解,问题似乎会突然出现。然而,即使对这些主题有透彻的了解,对这类问题唯一真正的防御是所有涉及的类之间达成不改变方法签名的协议。

反省

考虑到所有可用的不同继承选项,Python 提供了许多工具来识别一个类使用什么结构是合适的。与类一起使用的最明显的自省任务是确定一个对象是否是给定类的实例。这个行为是使用内置的isinstance() function提供的,它将任意对象作为第一个参数,将一个 Python 类作为第二个参数。只有当给定的类在对象类的继承链中的任何位置时,才会isinstance() return True:

img/330715_3_En_4_Figz_HTML.jpg

>>> isinstance(10, int)
True
>>> isinstance('test', tuple)
False

isinstance()的一个自然补充是确定一个类在其继承链中是否有另一个类的能力。这个特性是由内置的subclass() function提供的,工作方式和isinstance()一样,除了它操作的是一个类而不是它的一个实例。如果第一个类在其继承链中包含第二个类,issubclass()返回True:

img/330715_3_En_4_Figaa_HTML.jpg

>>> issubclass(int, object)
True
>>> class A:
...     pass
...
>>> class B(A):
...     pass
...
>>> issubclass(B, A)
True
>>> issubclass(B, B)
True

最后一个例子可能看起来很奇怪,因为B显然不能成为自身的子类,但是这种行为是为了与isinstance()保持一致,如果所提供的对象的类型与所提供的类完全相同,那么它将返回True。简而言之,这两者之间的关系可以用一个简单的表达式来描述,这个表达式总是正确的:

isinstance(obj, cls) == issubclass(type(obj), cls)

如果您想了解特定类的继承结构的更多信息,有一些不同的工具可供您使用。如果您想知道为一个特定的类定义了哪些基类,只需访问它的__bases__属性,该属性将在一个元组中包含这些基类。但是,它只提供了直接基类,而没有任何比它扩展得更深的类:

img/330715_3_En_4_Figab_HTML.jpg

>>> B.__bases__
(<class '__main__.A'>,)

另一方面,每个类也有一个__subclasses__()方法,它返回你正在处理的类的所有子类的列表。像__bases__一样,这与你正在处理的类只差一级。任何进一步的子类都需要使用一些其他机制来跟踪子类,其中一些将在本书的后面讨论:

img/330715_3_En_4_Figac_HTML.jpg

>>> A.__subclasses__()
[<class '__main__.B'>]

如果您想要更多的信息和控制,每个类还有一个__mro__属性,它在一个元组中包含该类的完整 MRO。如前所述,这还包括您传入的实际类及其任何父类。您甚至可以在第一个例子中尝试使用前面使用的super():

img/330715_3_En_4_Figad_HTML.jpg

>>> B.__mro__
(<class '__main__.B'>, <class '__main__.A'>, <class 'object'>)

如何创建类

在 Python 中定义一个类的工作方式与在许多其他语言中不同,尽管差异并不总是显而易见的。这看起来很简单:你提供一个名字,可能是一个继承的基类,一些属性和一些方法。但是当 Python 遇到这种声明时,所发生的过程实际上与函数有着比您可能意识到的更多的共同点。

首先,类声明的主体是一个代码块。就像iffor,while一样,一个类块的主体可以包含任何有效的 Python 代码,这些代码将自顶向下执行。它将跟随函数调用,执行错误处理,读取文件,或者你要求它做的任何事情。事实上,if块在类声明中非常有用:

img/330715_3_En_4_Figae_HTML.jpg

>>> try:
...     import custom_library
... except ImportError:
...     custom_library = None
...
>>> class Custom:
...     if custom_library is not None:
...         has_library = True
...     else:
...         has_library = False
...
>>> Custom.has_library
False

小费

此示例仅用于演示目的。如果您希望获得这里显示的确切效果,那么简单地将表达式custom_library is not None直接分配给has_library属性要实用得多。不管怎样,它都会返回一个布尔值,所以最终结果是相同的,但这是一种更常见的处理手头任务的方法。

Python 执行完内部代码后,您会注意到has_library变成了类对象的一个属性,可供代码的其余部分使用。这是可能的,因为 Python 的类声明有点像函数。当发现一个新类时,Python 首先为其中的代码块创建一个新的名称空间。执行代码块时,任何赋值都是在新的命名空间中进行的。然后,创建的名称空间用于填充一个新的对象,该对象实现了新的类。

在运行时创建类

前一节提到了 Python 在执行代码、编译和解释时创建类型objects的事实。与运行时发生的几乎所有其他事情一样,您可以自己挂钩到该流程中,并利用它来获得优势。这样做利用了 Python 在遇到类时在幕后所做的事情。

真正重要的事情发生在类的内容被处理之后。此时,Python 获取类名称空间,并将其与其他一些信息一起传递给内置的type(),后者创建或“实例化”新的类对象。这意味着所有的类实际上都是type()的子类,它是所有类的基础。具体来说,type()使用三条信息来实例化一个类:

  • 声明的类的名称

  • 定义的类应该继承的基类

  • 执行类主体时填充的命名空间字典

这些信息是表示整个类所必需的,即使 Python 通过检查类声明自动获得这些信息,您也可以通过直接传入这些值来创建类型。

名字是最简单的,因为它只是一个带有类名的字符串。基类稍微复杂一些,但是它们仍然相当简单:只需提供一个包含新类应该继承的现有类对象的序列。名称空间字典就是这样:一个字典,恰好包含了应该通过名称附加到新类的所有内容。下面的例子说明了如何用两种不同的方法创建同一个类:

img/330715_3_En_4_Figaf_HTML.jpg

>>> class Example(int):
...     spam = 'eggs'
...
>>> Example
<class '__main__.Example'>

>>> Example = type('Example', (int,), {'spam': 'eggs'})

>>> Example
<class '__main__.Example'>

不要重复你自己

你会注意到这个例子最后不得不写名字Example两次,这似乎违反了 DRY 原则。但是,请记住,这里实际上有两件事情在进行,而且这两件事情并不相互关联。首先,正在创建类,这需要我们提供一个名称。其次,新类被绑定到名称空间中的一个名称。

这个示例对两个操作使用了相同的名称,部分是为了方便,部分是为了与上面的本机类声明兼容。然而,命名空间的分配与类的创建是完全分开的,因此可以使用任何名称。事实上,大多数时候你甚至不知道这个类的名字,所以在实践中你总是会使用不同的名字。

像大多数时候一样,您对一个通用特性type()的访问权限很低,这给了您很多制造问题的机会。type()的三个参数之一是要创建的类的名称,因此可以用相同的名称创建多个类。

此外,通过传递属性名称空间,您可以提供一个新的__module__属性来模拟它在不同模块中的存在。它实际上不会把类放在指定的模块中,但是它会欺骗任何稍后自省该模块的代码。拥有两个具有相同名称和模块的类可能会导致自省模块以确定其结构和层次的工具出现问题。

当然,即使不直接使用type()也有可能遇到这些问题。如果您创建一个类,给它指定一个不同的名称,然后创建一个与原始类同名的新类,您可能会遇到完全相同的命名问题。此外,Python 允许您在标准的类声明中提供一个__module__属性,因此您甚至可以在不受您控制的代码中创建冲突。

尽管不直接求助于type()也有可能遇到这些问题,但这里的警告是type()使得意外遇到问题变得更加容易。如果没有它,您将不得不编写专门利用上述要点的代码,以便产生命名冲突。然而,使用type(),提供的值可能来自用户输入、定制设置或任何其他地方,代码看起来不会有任何这种性质的问题。

不幸的是,对于这类问题没有真正的保障措施,但是你可以做一些事情来帮助降低风险。一种方法是将所有自定义类的创建封装在一个函数中,该函数跟踪哪些名称已被赋值,并在创建副本时做出适当的反应。一个更实用的选择是简单地确保任何自省代码能够处理遇到重复的情况。使用哪种方法将取决于代码的需求。

元类

到目前为止,类被定义为由内置类型处理,内置类型接受类名、基类和名称空间字典。但是type就像其他任何东西一样只是一个类;它的特殊之处在于它是一个用于创建类的类——一个metaclass。但是,像任何其他类一样,它可以被子类化,以便为我们的应用提供定制的行为。因为 Python 一遇到元类就接收完整的类声明,所以您可以解锁一些非常强大的特性。

通过子类化type,你可以创建自己的元类,它可以定制新类的创建,以更好地满足你的应用的需求。像任何基于类的定制一样,这是通过创建一个类型的子类并覆盖任何对手边的任务有意义的方法来完成的。在大多数情况下,这不是__new__()就是__init__()。本章后面的“魔术方法”一节将解释这两者之间的区别,但是对于这次讨论,我们将只使用__init__(),因为它更容易使用。

如前所述,type()有三个参数,所有这些参数都必须包含在子类中。从简单开始,考虑下面的元类,它打印出它遇到的每个类的名称:

img/330715_3_En_4_Figag_HTML.jpg

>>> class SimpleMetaclass(type):
...     def __init__(cls, name, bases, attrs):
...         print(name)
...         super(SimpleMetaclass, cls).__init__(name, bases, attrs)
...

仅此一点就足以捕获一个类声明。在这里使用super()确保任何其他必要的初始化也发生。即使type在它自己的__init__()中不做任何事情,记住在本章的前面,这个类可能是一个更大的继承结构的一部分。使用super()确保类被正确初始化,而不管在给定的上下文中“正确”的真正含义是什么。

为了将这个元类应用于一个新类并打印出它的名称,Python 允许类定义在父类旁边指定一个元类。它看起来像一个关键字参数,但这不是一个函数调用,所以它实际上是类声明语法的一部分。下面是我们的SimpleMetaclass如何工作的一个例子:

img/330715_3_En_4_Figah_HTML.jpg

>>> class Example(metaclass=SimpleMetaclass):
...     pass
...
>>> Example

这里所需要的只是在类定义中提供元类,Python 自动将该定义发送给元类进行处理。这与标准类定义的唯一区别是它使用了SimpleMetaclass而不是standard类型。

注意

元类上的__init__()方法的第一个参数通常称为 cls,尽管您可能认为它应该是 self,因为__init__()操作的是实例对象,而不是类。一般都是这样,这个案例其实也不例外。这里唯一的区别是实例本身是一个类对象,它是type的一个实例,所以使用 self 仍然是准确的。然而,由于类和对象之间的差异,我们仍然称类对象为cls,而不是self,所以它们保持很好的分离。

如果没有真实世界的例子来说明它们的用处,元类可能很难理解。让我们看看如何使用一个简单的元类来为注册和使用插件提供一个强大的框架。

示例:插件框架

随着应用的增长,灵活性变得越来越重要,所以注意力经常转向插件以及应用是否能适应这种模块化水平。实现插件系统和单个插件的方法有很多,但它们都有三个共同的核心特性。

首先,你需要一种方法来定义插件可以使用的地方。为了插上东西,需要有一个插座供插头插入。另外,一路上如何实现单个插件应该是非常明显的。最后,框架需要提供一个简单的方法来访问所有找到的插件,这样它们都可以被使用。其他功能可能会添加到上面,但这些是插件框架。

有几种方法可以满足这些需求,但是因为插件实际上是一种扩展形式,所以让它们扩展基类是有意义的。这使得第一个需求定义起来相当简单:插件可以连接自己的点是一个类。作为一个类,它利用了 Python 自身的扩展特性,不仅通过内置的子类语法,还通过允许基类提供一些方法来构成默认功能或为常见的插件需求提供帮助。下面是这样一个插件挂载点如何寻找一个验证用户输入的应用:

img/330715_3_En_4_Figai_HTML.jpg

class InputValidator:
    """
    A plugin mount for input validation.

    Supported plugins must provide a validate(self, input) method, which receives
    input as a string and raises a ValueError if the input was invalid. If the
    input was properly valid, it should just return without error. Any return
    value will be ignored.
    """

    def validate(self, input):
        # The default implementation raises a NotImplementedError
        # to ensure that any subclasses must override this method.
        raise NotImplementedError

即使没有任何使插件工作的框架级代码,这个例子也展示了可扩展系统的一个最重要的方面:文档。只有正确地记录插件安装,你才能期望插件作者正确地遵守它的期望。插件框架本身并不假设你的应用会有什么需求,所以由你来记录它们。

有了挂载点,只需编写已经存在的挂载点的子类,就可以很容易地创建单独的插件。通过提供新的或重写的方法来满足文档化的需求,他们可以将自己的一小部分功能添加到整个应用中。下面是一个示例验证器,它确保提供的输入只包含 ASCII 字符:

img/330715_3_En_4_Figaj_HTML.jpg

class ASCIIValidator(InputValidator):
    """
    Validate that the input only consists of valid ASCII characters.

    >>> v = ASCIIValidator()
    >>> v.validate('sombrero')
    >>> v.validate('jalapeño')
    Traceback (most recent call last):
      ...
    UnicodeDecodeError: 'ascii' codec can't decode character '\xf1' in position
    6: ordinal not in range(128)
    """

    def validate(self, input):
        # If the encoding operation fails, str.enc  ode() raises a
        # UnicodeDecodeError, which is a subclass of ValueError.
        input.encode('ascii')

小费

请注意,它还提供了自己的文档。因为插件本身也是类,它们可以被更专业的插件子类化。这使得即使在这一级也包含完整的文档非常重要,以帮助确保以后的正确使用。

既然我们已经解决了三个组件中的两个,那么在把它们结合在一起之前,剩下的唯一一件事就是演示如何访问任何已定义的插件。因为我们的代码已经知道了插件的挂载点,这就为访问它们提供了一个显而易见的位置,而且由于插件的数量可能从零到数百个不等,所以最好是对它们进行迭代,而不用关心有多少个插件。下面是一个示例函数,它使用任何和所有可用的插件来确定用户提供的某些输入是否有效:

img/330715_3_En_4_Figak_HTML.jpg

def is_valid(input):
    for plugin in InputValidator.plugins:
        try:
            plugin().validate(input)
        except ValueError:
            # A ValueError means invalidate input
            return False
    # All validators succeeded
    return True

拥有插件意味着你可以扩展这样一个简单函数的功能,而不需要再修改它的代码。简单地添加一个新插件,确保它被导入,剩下的工作由框架来完成。至此,我们终于开始解释这个框架,以及它是如何将所有这些部分联系在一起的。因为我们使用的类的定义不仅仅指定了它们的行为,所以元类将是一种理想的技术。

元类真正需要做的是识别插件挂载类和插件子类之间的区别,并在插件挂载列表中注册任何插件,以后可以在那里访问它们。如果这听起来太简单,那真的不是。事实上,整个框架可以用几行代码来表达,并且只需要插件挂载上的一行额外代码就可以激活整个框架:

img/330715_3_En_4_Figal_HTML.jpg

class PluginMount(type):
    """
    Place this metaclass on any standard Python class to turn it into a plugin
    mount point. All subclasses will be automatically registered as plugins.
    """
    def __init__(cls, name, bases, attrs):
        if not hasattr(cls, 'plugins'):
            # The class has no plugins list, so it must be a mount point,
            # so we add one for plugins to be registered in later.
            cls.plugins = []
        else:
            # Since the plugins attribute already exists, this is an
            # individual plugin, and it needs to be registered.
            cls.plugins.append(cls)

这就是提供整个插件框架所需的全部内容。当元类在插件挂载上被激活时,__init__()方法识别出 plugins 属性还不存在,所以它创建一个并返回,不做任何其他事情。当遇到插件子类时,plugins属性由于其父类而可用,因此元类将新类添加到现有列表中,从而注册它以备后用。

将这个功能添加到前面描述的inputValidator挂载点就像将元类添加到它的类定义中一样简单。

class InputValidator(metaclass=PluginMount):
    ...

单个插件仍然被定义为标准插件,不需要额外的努力。因为元类由所有子类继承,所以插件行为是自动添加的。

控制命名空间

元类也可以用来帮助控制 Python 如何处理类声明。另一种策略是在 Python 处理类的原始组件时处理它们,而不是等待类被创建后再处理。这是通过一个叫做__prepare__()的特殊元类实现的。

通过在元类上提供一个__prepare__()方法,您可以提前访问类声明。事实上,这发生得太早了,以至于类定义的主体还没有被处理。__prepare__()方法只接收类名及其基类的元组。__prepare__()负责返回字典本身,而不是获取名称空间字典作为参数。

当 Python 执行类定义的主体时,__prepare__()返回的字典被用作名称空间。这允许您在将每个属性分配给类时立即截取它,这样就可以立即处理它。通常,这用于返回一个有序字典,这样属性可以按照它们在类中声明的顺序存储。作为参考,看看元类如何在不使用__prepare__()的情况下工作:

img/330715_3_En_4_Figam_HTML.jpg

>>> from collections import OrderedDict
>>> class OrderedMeta(type):
...     def __init__(cls, name, bases, attrs):
...         print(attrs)
...
>>> class Example(metaclass=OrderedMeta):
...     b = 1
...     a = 2
...     c = 3
...
{'a': 2, '__module__': '__main__', 'b': 1, 'c': 3}

默认行为返回一个标准字典,它不跟踪键是如何添加的。添加一个简单的__prepare__()方法提供了在类被处理后保持顺序不变所需的一切:

img/330715_3_En_4_Figan_HTML.jpg

>>> class OrderedMeta(type):
...     @classmethod
...     def __prepare__(cls, name, bases):
...         return OrderedDict()
...     def __init__(cls, name, bases, attrs):
...         print(attrs)
...
>>> class Example(metaclass=OrderedMeta):
...     b = 1
...     a = 2
...     c = 3
...
OrderedDict([('__module__', '__main__'), ('B', 1), ('A', 2), ('c', 3)])

注意

属性位于属性列表的开头,因为它是在调用了__prepare__()之后,Python 开始处理类的主体之前添加的。

权力越大,责任越大

通过控制用于名称空间字典的对象,您可以对整个类声明的行为进行大量的控制。每当类中的一行引用一个变量或分配一个属性时,定制的名称空间可以调解并改变标准行为。一种可能性是提供在类中定义方法时可以使用的 decorators,而不需要单独的导入来使它们对类定义可用。同样,您可以通过更改属性的名称、将属性包装在辅助对象中或将其从名称空间中完全删除来控制属性的分配方式。

这种力量和灵活性很容易被滥用来提供一种在别处看不到的魔力。对于一个仅仅使用你的代码而没有完全理解它是如何实现的开发人员来说,这看起来就像 Python 本身是非常不一致的。更糟糕的是,您对类声明的行为所做的任何重大更改都可能会影响您的用户可能尝试与您的用户结合使用的其他工具的行为。第五章展示了如何通过扩展字典来启用这些特性,但是在这么做的时候要非常小心。

属性

一旦实例化了一个对象,与之相关的任何数据都保存在一个新的特定于该实例的名称空间字典中。对这个字典的访问由属性处理,这比使用字典键更容易访问。就像字典键一样,可以根据需要检索、设置和删除属性值。

通常,访问属性需要您事先知道属性的名称。在提供变量而不是文字方面,属性的语法不像字典键那样灵活,所以如果您需要用来自其他地方的名称来获取或设置属性,它看起来可能会受到限制。Python 没有提供以这种方式处理属性的特殊语法,而是提供了三个函数。

第一个是getattr(),在给定包含属性名称的变量的情况下,检索属性引用的值。下一个是setattr(),它接受属性的名称和它的值,并将该值附加到具有给定名称的属性上。最后,delattr()允许你删除一个给定名字作为参数的属性值。使用这些函数,您可以在编写代码时处理任何对象的任何属性,而无需知道属性名。

性能

属性不仅仅充当标准名称空间字典的代理,它还允许属性由能够访问 Python 全部功能的方法提供动力。通常,属性是使用内置的@property装饰函数定义的。应用于方法时,它会强制在将函数名作为属性名访问时调用该方法:

img/330715_3_En_4_Figao_HTML.jpg

>>> class Person:
...     def __init__(self, first_name, last_name):
...         self.first_name = first_name
...         self.last_name = last_name
...     @property
...     def name(self):
...         return '%s, %s' % (self.last_name, self.first_name)
...
>>> p = Person('Marty', 'Alchin')
>>> p.name
'Alchin, Marty'
>>> p.name = 'Alchin, Martin'  # Update it to be properly legal
Traceback (most recent call last):
  ...
AttributeError: can't set attribute

最后一个错误不是很好描述,但是基本上以这种方式定义的属性只检索属性值,而不是设置它们。函数调用只是一种方式,所以要设置值,我们需要添加另一种方法来处理这方面的事情。这个新方法将接受另一个变量:应该在属性上设置的值。

为了将新方法标记为属性的 setter,它被装饰得很像 getter 属性。不过,getter 没有使用内置的装饰器,而是获得了一个可以用来装饰新方法的setter属性。这符合 decorators 典型的基于名词的命名约定,同时也描述了哪个属性将被管理:

img/330715_3_En_4_Figap_HTML.jpg

>>> class Person:
...     def __init__(self, first_name, last_name):
...         self.first_name = first_name
...         self.last_name = last_name
...     @property
...     def name(self):
...         return '%s, %s' % (self.last_name, self.first_name)

...     @name.setter

...     def name(self, value):

...         return '%s, %s' % (self.last_name, self.first_name)

...
>>> p = Person('Marty', 'Alchin')
>>> p.name
'Alchin, Marty'
>>> p.name = 'Alchin, Martin'  # Update it to be properly legal

>>> p.name

'Alchin, Martin'

只要确保 setter 方法的名称与原始 getter 方法的名称相同,否则它将无法正常工作。原因是name.setter实际上并没有用 setter 方法更新原始属性。相反,它将 getter 复制到新的属性上,并将它们赋给 setter 方法的名称。这在幕后的确切含义将在下一节描述符中更好地解释。

除了获取和设置值,属性还可以使用类似于 setter 的装饰器删除当前值。通过将name.deleter应用到一个只接受通常自我的方法,您可以使用该方法从属性中删除值。对于这里显示的Person类,这意味着同时清除first_namelast_name:

img/330715_3_En_4_Figaq_HTML.jpg

>>> class Person:
...     def __init__(self, first_name, last_name):
...         self.first_name = first_name
...         self.last_name = last_name
...     @property
...     def name(self):
...         return '%s, %s' % (self.last_name, self.first_name)
...     @name.setter
...     def name(self, value):
...         return '%s, %s' % (self.last_name, self.first_name)

...     @name.deleter

...     def name(self):

...         del self.first_name

...         del self.last_name

...
>>> p = Person('Marty', 'Alchin')
>>> p.name
'Alchin, Marty'
>>> p.name = 'Alchin, Martin' # Update it to be properly legal
>>> p.name
'Alchin, Martin'

>>> del p.name

>>> p.name

Traceback (most recent call last):
  ...
AttributeError: 'Person' object has no attribute 'last_name'

描述符

属性的一个潜在问题是,它们要求所有方法都被定义为类定义的一部分。如果您自己能够控制类,那么为类添加功能是非常好的,但是当构建一个包含在其他代码中的框架时,我们将需要另一种方法。描述符允许你定义一个对象,这个对象的行为方式可以和它所赋给的任何类上的属性一样。

事实上,属性是作为描述符在幕后实现的,方法也是如此,这将在下一节中解释。这使得描述符可能是高级类行为的最基本的方面之一。它们通过实现三种可能的方法来处理获取、设置和删除值。

第一个是__get__(),管理属性值的检索,但是与属性不同,描述符可以管理对类及其实例的属性访问。为了识别差异,__get__()接收对象实例和它的所有者类作为参数。所有者类将总是被提供,但是如果描述符是直接在类上而不是在实例上被访问的,那么实例参数将是None

仅使用__get__()方法的简单描述符可用于在请求时始终提供最新值。一个明显的例子是返回当前日期和时间的对象,而不需要单独的方法调用:

img/330715_3_En_4_Figar_HTML.jpg

>>> import datetime
>>> class CurrentTime:
...     def __get__(self, instance, owner):
...         return datetime.datetime.now()
...
>>> class Example:
...     time = CurrentTime()
...
>>> Example().time
datetime.datetime(2009, 10, 31, 21, 27, 5, 236000)
>>> import time
>>> time.sleep(5 * 60) # Wait five minutes
>>> Example().time
datetime.datetime(2009, 10, 31, 21, 32, 15, 375000)

相关的__set__()方法管理在描述符管理的属性上设置一个值。与__get__()不同,该操作只能在实例对象上执行。如果您为类的给定名称赋值,实际上会用新值覆盖描述符,从类中删除它的所有功能。这是有意的,因为没有它,一旦描述符被分配给一个类,就没有办法修改或删除它。

因为不需要接受 owner 类,__set__()只接收实例对象和被赋值的值。不过,仍然可以通过访问所提供的实例对象上的__class__属性来确定该类,因此不会丢失任何信息。在描述符上定义了__get__()__set__()之后,我们可以做一些更有用的事情。例如,下面是一个基本描述符,它的行为就像一个属性,只是它会在每次值发生变化时进行记录:

img/330715_3_En_4_Figas_HTML.jpg

>>> import datetime
>>> class LoggedAttribute:
...     def __init__(self):
...         self.log = []
...         self.value_map = {}
...     def __set__(self, instance, value):
...         self.value_map[instance] = value
...         log_value = (datetime.datetime.now(), instance, value)
...         self.log.append(log_value)
...     def __get__(self, instance, owner):
...         if not instance:
...             return self # This way, the log is accessible
...         return self.value_map[instance]
...
>>> class Example:
...     value = LoggedAttribute()
...
>>> e = Example()
>>> e.value = 'testing'
>>> e.value
'testing'
>>> Example.value.log
[(datetime.datetime(2009, 10, 31, 21, 49, 59, 933000), <__main__.Example object a
t 0x...>, 'testing')]

在继续之前,这里有一些重要的事情需要注意。首先,当在描述符上设置一个值时,__set__()使用实例作为键,将它添加到自身的字典中。这样做的原因是描述符对象在它所附加的类的所有实例之间共享。如果您将该值设置为描述符自身的值,那么该值也将在所有这些实例之间共享。

注意

使用字典只是确保实例得到处理的一种方式,但这不是最好的方式。这里使用它是因为首选的方法是直接分配给实例的名称空间字典,这只是在您知道属性名称后的一个选项。描述符本身不能访问这个名称,所以这里使用字典。第十一章展示了一种基于元类解决这个问题的方法。

另外,请注意,如果没有传入实例,__get__()将返回 self。因为描述符基于设置值工作,所以当在类上调用它时,它没有额外的贡献值。大多数时候,当描述符处于这种情况时,更有意义的做法是抛出一个AttributeError来防止用户尝试一些没有意义的东西。在这里这样做意味着值日志永远不可用,所以描述符返回它自己。

除了获取和设置值,描述符还可以从属性或属性本身删除值。__delete__()方法管理这个行为,因为它只对实例起作用,不关心值,所以它接收实例对象作为它唯一的参数。

除了管理属性,描述符还用于实现面向对象编程的一个最重要的方面:方法。

方法

当一个函数被定义在一个类中时,它被认为是一个方法。尽管它总体上仍然像函数一样工作,但它有可用的类信息,因为函数实际上也是描述符。然而,在方法类别中,有两种截然不同的类型:绑定方法和未绑定方法。

未绑定方法

因为可以从类及其实例中访问描述符,所以也可以从这两者中访问方法。当访问一个类的函数时,它成为一个未绑定的方法。描述符接收类,但是方法通常需要实例,所以在没有实例的情况下访问时,它们被称为未绑定的。

将其称为非绑定方法实际上更像是一种命名约定,而不是任何正式声明。当访问类的方法时,您得到的只是函数对象本身:

img/330715_3_En_4_Figat_HTML.jpg

>>> class Example:
...     def method(self):
...         return 'done!'
...
>>> type(Example.method)
<class 'function'>
>>> Example.method
<function method at 0x...>

# self isn't passed automatically

>>> Example.method()
Traceback (most recent call last):
  ...
TypeError: method() takes exactly 1 position argument (0 given)

它仍然是可调用的,就像任何其他标准函数一样,但是它也携带关于它附加到什么类的信息。注意,未绑定方法中的self参数不是自动传递的,因为没有实例对象可以绑定到它。

绑定方法

一旦类被实例化,每个方法描述符返回一个绑定到该实例的函数。它仍然由相同的函数支持,并且原始的未绑定方法仍然在该类上可用,但是绑定方法现在自动接收实例对象作为它的第一个参数:

img/330715_3_En_4_Figau_HTML.jpg

>>> ex = Example()
>>> type(ex.method)
<class 'method'>
>>> ex.method
<bound method Example.method of <__main__.Example object at 0x...>>

# self gets passed automatically now

>>> ex.method()
'done!'

# And the underlying function is still the same

>>> Example.method is ex.method.__func__
True

# is and == have related yet different functionality and == could have replaced is in this instance, yet since is checks to see if two arguments refer to the same object versus == checks to see if two object have same value, is works better for our needs.

如您所见,绑定方法仍然由与未绑定方法相同的函数支持。唯一真正的区别是绑定方法有一个实例作为第一个参数接收。同样重要的是要认识到实例对象是作为位置参数传递的,所以参数名不需要为self才能正常工作,但是这是一个已经建立的标准,您应该尽可能地遵循。

小费

因为绑定方法接受实例作为第一个参数,所以可以通过显式提供实例作为未绑定方法的第一个参数来伪造方法绑定。对于该方法来说,这一切看起来都是一样的,并且在将函数作为回调传递时,这可能是一种有用的方法。

然而,有时方法不需要访问实例对象,不管类是否已经被实例化。这些方法分为两种不同的类型。

类方法

当一个方法只需要访问它所附加的类时,它被认为是一个类方法,Python 通过使用内置的@classmethod decorator 来支持它。这确保了该方法将总是接收类对象作为它的第一个位置参数,而不管它是作为类的属性还是它的实例之一被调用:

img/330715_3_En_4_Figav_HTML.jpg

>>> class Example:
...     @classmethod
...     def method(cls):
...         return cls
...
>>> Example.method()
<class __main__.Example at 0x...>
>>> Example().method()
<class __main__.Example at 0x...>

一旦应用了@classmethoddecorator——参见本章后面关于 decorator 的信息——method() method将永远不会收到一个Example的实例作为它的第一个参数,而将永远是类本身或它的一个子类。cls参数总是用来调用方法的类,而不仅仅是定义方法的类。

尽管从前面的例子中可能看不清楚,但类方法实际上是绑定的实例方法,就像前面几节中描述的那些方法一样。因为所有类实际上都是内置类型的实例,所以类方法被绑定到类本身:

img/330715_3_En_4_Figaw_HTML.jpg

>>> Example.method
<bound method type.method of <class '__main__.Example'>>

类方法也可以用另一种稍微间接的方式创建。因为所有的类实际上都只是元类的实例,所以可以在元类上定义一个方法。然后,所有实例类都可以将该方法作为标准绑定方法进行访问。不需要使用@classmethod装饰器,因为该方法已经使用前面描述的标准行为绑定到了类。它是这样工作的:

img/330715_3_En_4_Figax_HTML.jpg

>>> class ExampleMeta(type):
...     def method(cls):
...         return cls
...
>>> class Example(metaclass=ExampleMeta):
...     pass
...
>>> Example.method
<bound method ExampleMeta.method of <class '__main__.Example'>>
>>> Example.method()
<class __main__.Example at 0x...>

以这种方式构造的方法的实际行为在大多数方面与常规的类方法相同,因为它们在内部是以相同的方式构建的。它们可以从类本身调用,而不需要实例,并且它们总是接收类对象作为隐式的第一个参数。然而,不同之处在于类方法仍然可以从实例中调用,而绑定类方法只能从类本身调用。

这种行为的原因是该方法是在元类名称空间中定义的,这只能将其放在该元类实例的 MRO 中。所有引用该元类的类都可以访问该方法,但实际上并不在它们的定义中。用@classmethod修饰的方法被直接放在定义它们的类的名称空间中,这使得它们对于该类的实例也是可用的。

尽管这种可见性上的差异看起来像是基于元类的类方法只是标准修饰类方法的一个低级版本,但是有两个原因可以解释为什么它们对应用有益。首先,类方法通常被期望作为类的属性来调用,很少从实例对象中调用。这不是一个通用的规则,当然也不足以证明元类本身的使用,但这是值得注意的。

也许更重要的是,许多已经使用元类的应用还需要向使用该元类的任何类添加类方法。在这种情况下,只在现有元类上定义方法是有意义的,而不是使用单独的类来保存类方法。当额外的类本身没有任何有价值的东西可添加时,这尤其有用;如果元类是重要的部分,最好把所有东西都放在那里。

静态方法

有时,即使是类也比方法完成工作所需的信息多。静态方法就是这种情况,实现静态方法通常是为了给本来可以在模块级实现的函数建立一个名称空间。使用staticmethod装饰器,该方法在任何时候都不会接收任何隐式参数:

img/330715_3_En_4_Figay_HTML.jpg

>>> class Example:
...     @staticmethod
...     def method():
...         print('static!')
...
>>> Example.method
<function method at 0x...>
>>> Example.method()
static!

如您所见,静态方法看起来一点也不像方法。它们只是碰巧出现在一个类中的标准函数。下一节将展示如何利用 Python 的动态特性在实例上实现类似的效果。

将函数分配给类和实例

Python 允许简单地通过分配一个新值来覆盖大多数属性,这为方法提供了一个有趣的机会:

img/330715_3_En_4_Figaz_HTML.jpg

>>> def dynamic(obj):
...     return obj
...
>>> Example.method = dynamic
>>> Example.method()
Traceback (most recent call last):
  ...
TypeError: dynamic() takes exactly 1 positional argument (0 given)
>>> ex = Example()
>>> ex.method()
<__main__.Example object at 0x...>

请注意,分配给该类的函数仍然需要编写成接受一个实例作为它的第一个参数。一旦赋值,它就像一个常规的实例方法一样工作,所以参数需求根本不会改变。赋值给实例在语法上类似,但是因为函数永远不会被赋值给类,所以根本不涉及绑定。直接分配给实例属性的函数就像附加到类的静态方法一样工作:

img/330715_3_En_4_Figba_HTML.jpg

>>> def dynamic():
...     print('dynamic!')
...
>>> ex.method = dynamic
>>> ex.method()
dynamic!
>>> ex.method
<function dynamic at 0x...>

神奇的方法

Python 中的对象可以用许多不同的方式来创建、操作和销毁,并且大多数可用的行为可以通过在您自己的定制类上实现一些额外的方法来修改。一些更特殊的定制可以在第五章中找到,但是有一些特殊的方法对于所有类型的类都是通用的。这些方法可以根据它们处理的类的方面来分类,因此下面的章节将分别介绍一些不同的方法。

创建实例

从类到对象的转换称为实例化。实例只不过是对提供行为和名称空间字典的类的引用,该字典对于正在创建的实例是唯一的。当创建一个新对象而不覆盖任何特殊方法时,实例名称空间只是一个空字典,等待数据。

因此,大多数类实现的第一个方法是__init__(),目的是用一些有用的值初始化名称空间。有时这些只是占位符,直到更多有趣的数据到来,而在其他时候,有趣的数据以参数的形式直接进入方法。发生这种情况是因为传递给类实例化的任何参数都会一直传递给__init__():

img/330715_3_En_4_Figbb_HTML.jpg

>>> class Example:
...     def __init__(self):
...         self.initialized = True
...
>>> e = Example()
>>> e.initialized = True
>>> class Example2:
...     def __init__(self, name, value=“):
...         self.name = name
...         self.value = value
...
>>> e = Example2()
Traceback (most recent call last):
  ...
TypeError: __init__() takes at least 2 positional arguments (1 given)
>>> e = Example2('testing')
>>> e.name
'testing'
>>> e.value
"

像任何 Python 函数一样,您可以在__init__()中自由地做任何您想做的事情,但是请记住,这只是为了初始化对象,仅此而已。一旦__init__()完成执行,对象应该准备好用于更实际的目的,但是任何超出基本设置的事情应该被推迟到其他更明确的方法。

当然,初始化的真正定义可能对不同的对象有不同的含义。对于大多数对象,您只需要将一些属性设置为一些默认值或传递给__init__()的值,如前面的例子所示。其他时候,这些初始值可能需要计算,比如将不同的时间单位转换成秒,所以一切都是正常的。

在一些不太常见的情况下,初始化可能包括更复杂的任务,如数据验证、文件检索,甚至网络流量。例如,一个使用 web 服务的类可能将一个 API 令牌作为它对__init__()的唯一参数。然后,它可能会调用 web 服务,将该令牌转换为经过身份验证的会话,这将允许进行其他操作。所有其他操作都需要单独的方法调用,但是所有这些操作背后的认证可能发生在__init__()中。

__init__()中做得太多的主要问题是,除了文档之外,没有任何迹象表明发生了任何事情。不幸的是,无论你如何努力,有些用户就是不愿意阅读你的文档;他们可能仍然期望初始化是一个简单的操作,例如,如果他们没有有效的网络连接,他们可能会惊讶地看到错误。关于解决这个问题的一种方法,请参见下一节中的示例。

尽管__init__()可能是所有方法中最广为人知的魔法方法,但它并不是创建新对象时第一个被执行的方法。毕竟,记住__init__()是关于初始化一个对象,而不是创建它。对于后者,Python 提供了__new__()方法,该方法获得大部分相同的参数,但负责在初始化之前实际创建新对象。

与典型的实例对象self不同,__new__()的第一个参数实际上是被创建对象的类。这使它看起来很像一个类方法,但是你不需要使用任何装饰器来使它这样工作——这是 Python 中的一个特例。然而,从技术上讲,它是一个静态方法,所以如果你试图直接调用它,你总是需要提供类;它永远不会被隐式发送,就像它是一个真正的类方法一样。

在类参数之后——通常命名为 cls,就像常规的类方法一样——__new__()方法接收所有与__init__()相同的参数。当试图创建对象时,你传递给类的任何东西都将被传递给__new__()来帮助定义它。这些参数在根据当前需求定制新对象时通常很有用。

这通常不同于初始化,因为__new__()通常用于改变被创建对象的本质,而不仅仅是设置一些初始值。举例来说,考虑一个例子,其中对象的类可以根据创建它时传入的值而改变。

示例:自动子类

有些库由大量不同的类组成,其中大多数共享一组公共数据,但可能具有不同的行为或其他数据定制。这通常需要库的用户跟踪所有不同的类,并确定他们的数据的哪些特征对应于适当的类。

相反,提供一个用户可以实例化的类会更有帮助,该类实际上返回一个对象,该对象可以是不同的类,这取决于参数。使用__new__()定制新对象的创建,这可以相当简单地实现。确切的行为将取决于手边的应用,但是基本的技术很容易用一个通用的例子来说明。

考虑一个类,每当它被实例化为一个对象时,它随机选择一个子类。当然,这不是最实际的用途,但是它说明了这个过程是如何工作的。使用random.choice()从使用__subclasses__()得到的值中选择,然后实例化它找到的子类,而不是定义的子类:

img/330715_3_En_4_Figbc_HTML.jpg

>>> import random
>>> class Example:
...     def __new__(cls, *args, **kwargs):
...         cls = random.choice(cls.__subclasses__())
...         return super(Example, cls).__new__(cls, *args, **kwargs)
...
>>> class Spam(Example):
...     pass
...
>>> class Eggs(Example):
...     pass
...
>>> Example()
<__main__.Eggs object at 0x...>
>>> Example()
<__main__.Eggs object at 0x...>
>>> Example()
<__main__.Spam object at 0x...>
>>> Example()
<__main__.Eggs object at 0x...>
>>> Example()
<__main__.Spam object at 0x...>
>>> Example()
<__main__.Spam object at 0x...>

在另一个真实的例子中,您可以将文件的内容传递给一个单独的File类,并让它自动实例化一个子类,该子类的属性和方法是为所提供的文件格式构建的。这对于大类文件(如音乐或图像)尤其有用,这些文件表面上在大多数方面表现相似,但却有可以抽象掉的潜在差异。

处理属性

使用一个对象时,最常见的需求之一是与其属性进行交互。一般来说,这很简单,只要给定属性的名称,比如instance.attribute,就可以直接分配和访问属性。在少数情况下,这种类型的访问本身是不够的,因此您需要更多的控制。

如果您在编写应用时不知道属性的名称,那么如果您使用内置的getattr()函数,您可以为名称提供一个变量。例如,instance.attribute将变成getattr(instanceattribute_name),其中attribute_name的值可以从任何地方提供,只要它是一个字符串。

这种方法只处理给定一个字符串形式的名称,并且需要查找该名称引用的实例属性的情况。另一方面,你也可以告诉一个类如何处理它没有明确管理的属性。这种行为由__getattr__()方法控制。

如果您定义了这个方法,Python 将在您请求尚未定义的属性时调用它。它接收所请求的属性的名称,因此您的类可以决定应该如何处理它。一个常见的例子是字典,它允许您按属性检索值,而不只是使用标准的字典语法:

img/330715_3_En_4_Figbd_HTML.jpg

>>> class AttributeDict(dict):
...     def __getattr__(self, name):
...         return self[name]
...
>>> d = AttributeDict(spam='eggs')
>>> d['spam']
'eggs'
>>> d.spam
'eggs'

注意

__getattr__()的一个不太明显的特性是,它只被实际上不存在的属性调用。如果您直接设置属性,引用该属性将检索它而不调用__getattr__()。如果你需要捕捉每个属性,使用__getattribute__()代替。它采用与__getattr__()相同的参数和函数,除了即使属性已经在实例上,它也会被调用。

当然,如果属性是只读的,那么允许属性访问的字典就没什么用了。为了使画面完整,我们还应该支持在属性中存储值。除了这个简单的字典示例之外,当您为一个属性设置值时,还需要定制发生的事情。正如所料,Python 以__setattr__()方法的形式提供了一个并行。

这个新方法需要一个额外的参数,因为还有一个值需要管理。通过定义__setattr__(),您可以截取这些赋值,并根据您的应用的需要来处理它们。将这个应用到AttributeDict就像前面的例子一样简单:

img/330715_3_En_4_Figbe_HTML.jpg

>>> class AttributeDict(dict):
...     def __getattr__(self, name):
...         return self[name]

...     def __setattr__(self, name, value):

...         self[name] = value

...
>>> d = AttributeDict(spam='eggs')
>>> d['spam']
'eggs'
>>> d.spam
'eggs'
>>> d.spam = 'ham'

>>> d.spam

'ham'

小费

就像getattr()提供用变量代替硬编码名称来访问属性一样,Python 提供了setattr()来设置属性。它的参数与__setattr__()的参数相匹配,因为它接受对象、属性名和值。

尽管这看起来像是属性访问的完整画面,但仍然缺少一个组件。当您不再需要某个属性,并希望将其从对象中完全移除时,Python 提供了del语句。然而,当您使用由这些特殊方法管理的假属性时,del本身不起作用。

为了处理这种情况,Python 会挂钩到__delattr__()方法(如果有的话)。因为这个值不再相关,所以这个方法只接受属性名和标准的self。将它添加到现有的AttributeDict很容易:

img/330715_3_En_4_Figbf_HTML.jpg

>>> class AttributeDict(dict):
...     def __getattr__(self, name):
...         return self[name]
...     def __setattr__(self, name, value):
...         self[name] = value

...     def __delattr__(self, name):

...         del self[name]

...
>>> d = AttributeDict(spam='eggs')
>>> d['spam']
'eggs'
>>> d.spam
'eggs'
>>> d.spam = 'ham'
>>> d.spam
'ham'

>>> del d.spam

>>> d.spam

Traceback (most recent call last):

  ...

KeyError: 'spam'

警告:引发正确的异常

该错误消息提出了使用这些类型的被覆盖属性的重要一点。很容易忽略异常在函数内部是如何处理的,所以你最终可能会抛出一个没有任何意义的异常;如果一个属性不存在,你会合理地期望看到一个AttributeError,而不是一个KeyError

这似乎是一个随意的细节,但是请记住,大多数代码都显式地捕捉特定类型的异常,因此,如果您引发了错误的类型,您可能会导致其他代码采取错误的路径。因此,当遇到相当于丢失属性的东西时,一定要确保显式地抛出AttributeError。根据假属性的作用,它可能是一个KeyErrorIOError,或者甚至是一个UnicodeDecodeError

这将在本书和现实世界中的不同地方出现。第五章涵盖了各种协议,在这些协议中,获得正确的异常和参数一样重要。

字符串表示

在 Python 中所有可能的不同对象类型中,最常见的无疑是字符串。从读写文件到与 web 服务交互和打印文档,字符串支配着软件执行的许多方面。尽管我们的大部分数据以其他形式存在,但迟早大部分数据会被转换成字符串。

为了使这个过程尽可能简单,Python 提供了一个额外的钩子来将对象转换成它的字符串表示。当在一个类上实现时,__str__()方法允许使用内置的str() function将它的实例转换成一个字符串,当使用print()或字符串格式化时也会用到这个方法。关于这些特性和更多的细节可以在第七章中找到,但是现在,看看__str__()在一个简单的类中是如何工作的:

img/330715_3_En_4_Figbg_HTML.jpg

# First, without __str__()

>>> class Book:
...     def __init__(self, title):
...         self.title = title
...
>>> Book('Pro Python')
<__main__.Book object at 0x...>
>>> str(Book('Pro Python'))
'<__main__.Book object at 0x...>'

# And again, this time with __str__()

>>> class Book:
...     def __init__(self, title):
...         self.title = title

...     def __str__(self):

...         return self.title

...
>>> Book('Pro Python')
<__main__.Book object at 0x...>
>>> str(Book('Pro Python'))

'Pro Python'

添加的__str__()允许该类指定在将对象表示为字符串时应该显示对象的哪些方面。在这个例子中,它是一本书的标题,但是它也可以是一个人的名字、地理位置的纬度和经度,或者在一组同类对象中简洁地标识该对象的任何其他东西。它不必包含关于对象的所有内容,但需要足够的内容来区分不同的对象。

还要注意,当交互式解释器中的表达式不包含对str()的调用时,它不使用由__str__()返回的值。相反,解释器使用对象的不同表示,这是为了更准确地表示对象的代码性质。对于自定义类来说,这种表示是相当无用的,只显示了对象的类的名称和模块以及它在内存中的地址。

然而,对于其他类型,您会注意到这些表示在确定对象是什么时非常有用。事实上,这种表示的理想目标是呈现一个字符串,如果将该字符串键入控制台,将会重新创建该对象。这对于了解交互式控制台中的对象非常有用:

img/330715_3_En_4_Figbh_HTML.jpg

>>> dict(spam='eggs')
{'spam': 'eggs'}
>>> list(range(5))
[0, 1, 2, 3, 4]
>>> set(range(5))
{0, 1, 2, 3, 4}

>>> import datetime
>>> datetime.date.today()
datetime.date(2009, 10, 31)
>>> datetime.time(12 + 6, 30)
datetime.time(18, 30)

这种替代表示由__repr__()方法控制,主要用于这种情况,描述交互控制台中的对象。当在解释器中单独引用一个对象时,它被自动触发,有时在日志应用中使用,在那里__str__()通常不能提供足够的细节。

对于列表和字典这样的内置对象,表示是一个文字表达式,可以很容易地再现对象。对于其他不包含太多数据的简单对象,日期和时间示例表明,只需提供一个实例化调用就可以了。当然,datetime必须首先被导入,但是它完成了工作。

如果对象表示的数据太多,无法压缩成这样一个简单的表示,那么下一个最好的方法是提供一个用尖括号括起来的字符串,它以更合理的细节描述对象。这通常是显示类名和一些标识类名的数据。对于Book示例,它在现实世界中有更多的属性,看起来可能是这样的:

img/330715_3_En_4_Figbi_HTML.jpg

>>> class Book:
...     def __init__(self, title, author=None):
...         self.title = title
...         self.author = author
...     def __str__(self):
...         return self.title
...     def __repr__(self):
...         return '<%s by %s>' % (self.title, self.author or '<Unknown Author>')
...
>>> Book('Pro Python', author='Marty Alchin')
<Book: Pro Python by Marty Alchin>
>>> str(Book('Pro Python', author='Marty Alchin'))
'Pro Python'

令人兴奋的 Python 扩展:迭代器

迭代器是可以被迭代的对象;换句话说,你可以说它是一个“可迭代”或“可循环”的项目。列表、元组和字符串是可迭代的;它们容纳不止一个项目,因此是可迭代的容器。Python 中有两个迭代器对象。第一个是序列迭代器,适用于任意序列。第二个对象遍历可调用对象项,并带有结束该过程的 sentinel 值。让我们看看他们的行动,以便更好地理解这一点。

一个非常简单的例子是增强的 for 循环 ,它遍历容器中的所有条目(你必须有不止一个)。请考虑以下几点:

my_string=('Hello Python!')
for item in my_string:
            print(item)
my_list=[1,2,3,4]
for item in my_list:
            print (item, end=' ')
#Note newline after printing is replaced with space
print()
my_tuple='Fred','Wilma', 1, 3
for item in my_tuple:
            print (item)

现在,如果在 Python 脚本所在的同一文件夹中有一个文本文件,例如一个包含数据的 CSV 文件,您可以执行如下操作:

for the_line in open("file.csv”):
                   print (the_line)

使用 Python 迭代器,您还可以组合结构来增强功能。一定要保持可读性。请注意,我们正在遍历一个字符串,并对字母“b”的实例进行计数

#Combine control structures
my_string=('ababaaaabbbbaaaabb')
counter=0
for character in [char for char in my_string if char == 'b']:
            counter +=1
print('There were ', counter, ' letter b')

另一个例子可能是凯撒密码加密:

#Secret message Ceasar cipher!
my_string = input('Type secret message:  ')
print (my_string)
new_string = ' '
z=input('How much to Ceasar shift by?  ')
for letter in my_string:
            x=ord(letter)
            t=x+int(z)
            print (chr(t),)

现在我们来看看迭代协议下一个 函数从第一项开始迭代,一直到最后一项,但是当它试图打印列表中没有的第四项时,返回一个 StopIteration 错误:

# Simple iteration over a list
simple_list = [1, 3, 2]
simple_iter = iter(simple_list)
counter = 1
while counter <=4:
          print(next(simple_iter))
          counter +=1

现在,您可以添加 try 和 except 来保持运行,但这显示了一般情况下的工作方式。花时间使用迭代器会有很好的回报。

带着它

对类的透彻理解为您的应用打开了一个可能性的世界,无论它们是简单的个人项目还是为大规模分发而构建的大型框架。除了所有这些自由之外,还有一组已建立的协议,允许您的类像 Python 中一些最著名的类型一样工作。

五、通用协议

大多数情况下,您会希望根据应用的需求来定义高度定制的对象。这通常意味着提出自己的接口和 API,这些接口和 API 是您自己的代码所独有的。做到这一点的灵活性对于任何系统的扩展能力都是必不可少的,但这是有代价的。你发明的任何新东西都必须被记录下来,并被需要使用它的人所理解。

理解如何使用一个框架提供的各种类对于该框架的用户来说可能是一件相当麻烦的事情,即使有适当的文档。减轻用户负担的一个好方法是模仿他们已经熟悉的界面。有许多现有的类型是 Python 编程中的标准问题,其中大多数都有可以在自定义类中实现的接口。

方法是实现现有接口的最显而易见的方式,但是对于许多内置类型,大多数操作都是使用本机 Python 语法而不是显式方法调用来执行的。自然,这些语法特性是由幕后的实际方法支持的,所以它们可以被覆盖以提供定制的行为。

以下部分展示了如何在定制代码中模拟 Python 中使用的一些最常见类型的接口。这绝不是 Python 附带的所有类型的详尽列表,也不是每个方法都被表示出来。相反,这一章是对那些不太明显的方法的参考,因为它们被语法糖掩盖了。

基本操作

尽管 Python 中有各种各样的对象类型,但大多数都有一组共同的操作。这些被认为是某种核心特性集,代表了对象操作的一些最常见的高级方面,其中许多都适用于简单的数字,就像它们适用于许多其他对象一样。

包括 Python 在内的所有编程中最简单也是最常见的需求之一是将一个表达式计算为布尔值,这样就可以用它来做出简单的决策。通常这用于if模块,但这些决定也在使用while和布尔运算如andor时发挥作用。当 Python 遇到其中一种情况时,它依赖于__bool__()方法的行为来确定对象的布尔等价物。

如果实现了__bool__()方法,它只接受通常的self,并且必须返回TrueFalse。这允许任何对象使用任何适当的方法或属性来确定它在给定的表达式中应该被认为是真还是假:

img/330715_3_En_5_Figa_HTML.jpg

>>> bool(0)
False
>>> bool(1)
True
>>> bool(5)
True

作为另一个例子,考虑表示矩形的类可能使用它的面积来确定该矩形被认为是真还是假。因此,__bool__()只需检查是否存在非零宽度和非零高度,因为bool 0为假,而任何其他正值,通常为1,为真。这里我们使用内置的bool(),它使用__bool__()将值转换为布尔值:

img/330715_3_En_5_Figb_HTML.jpg

>>> class Rectangle:
...     def __init__(self, width, height):
...         self.width = width
...         self.height = height
...     def __bool__(self):
...         if self.width and self.height:
...             return True
...         return False
...
>>> bool(Rectangle(10, 15))
True
>>> bool(Rectangle(0, 0))
False
>>> bool(Rectangle(0, 15))
False

小费

__bool__()方法并不是定制 Python 布尔行为的唯一方法。相反,如果一个对象提供了一个__len__()方法,这将在本章后面的序列一节中描述,Python 将退回到这个方法,并认为任何非零长度为真,而零长度为假。

考虑到对象的真实性,你会自动控制andornot等操作者的行为。因此,为了自定义这些运算符,不需要重写单独的方法。

除了能够确定对象的真实性,Python 还在其他操作中提供了很大的灵活性。特别是,标准的数学运算可以被覆盖,因为它们中的许多可以应用于除数字之外的各种对象。

数学运算

一些最早的数学形式源于对我们周围世界的观察。因此,我们在小学学到的大部分数学知识很容易应用到其他类型的物体上,就像应用到数字上一样。例如,加法可以被看作是简单地将两个东西放在一起(连接),例如将两个字符串连接在一起形成一个更长的字符串。

如果你只从数学上来看,你可以说你实际上只是把两个长度加在一起,得到一个更长的长度。但是当你看看刚刚发生了什么,你现在有了一个全新的弦,它不同于最初进入它的两根弦。

这种类比也很容易扩展到 Python 字符串,可以使用标准加法连接,而不需要单独的命名方法。类似地,如果你需要多次写出同一个字符串,你可以简单地把它乘以一个普通的数。这些类型的操作在 Python 中非常常见,因为它们是实现常见任务的简单方法:

img/330715_3_En_5_Figc_HTML.jpg

>>> 2 + 2
4
>>> 'two' + 'two'
'twotwo'
>>> 2 * 2
4
>>> 'two' * 2
'twotwo'

__bool__()一样,这些行为都是由自己特殊的方法控制的。他们中的大多数都相当直接,接受通常的self以及other论点。这些方法被绑定到操作符左侧的对象,附加的other是右侧的对象。

Python 中使用标准运算符+-*/来表示四种基本算术运算——加、减、乘、除。在幕后,前三个由__add__()__sub__()__mul__()方法的实现驱动。除法有点复杂,我们稍后会讲到,但是现在,让我们看看这个操作符重载是如何工作的。

考虑一个作为值的简单代理的类。这种东西在现实世界中没有多大用处,但它是解释一些事情的好起点:

img/330715_3_En_5_Figd_HTML.jpg

>>> class Example:
...     def __init__(self, value):
...         self.value = value
...     def __add__(self, other):
...         return self.value + other
...
>>> Example(10) + 20
30

这只是您的代码可以自定义的一些基本算术运算的一个例子。在本章的剩余部分,你会发现更高级的操作细节;表 5-1 列出了这些基本算术运算符。

表 5-1

基本算术运算符

|

操作

|

操作员

|

自定义方法

| | --- | --- | --- | | 添加 | + | __add__() | | 减法 | - | __sub__() | | 增加 | * | __mul__() | | 分开 | / | __truediv__() |

这就是事情变得有趣的地方,因为你会注意到除法的方法并不像你想象的那样是__div__()。这是因为分裂有两种不同的味道。当你使用计算器时得到的那种除法在 Python 中被称为真除法,它使用__truediv__()方法,正如你所期望的那样工作。

然而,真除法是唯一可以接受两个整数并返回一个非整数的算术运算。在某些应用中,总是返回一个整数是有用的。例如,如果你用百分比显示一个应用的进度,你不需要显示完整的浮点数。

取而代之的是另一种操作,叫做楼层分区;你可能也听说过它被称为整数除法。如果真除法的结果落在两个整数之间,则底数除法将只返回两个整数中较小的一个,因此它总是返回一个整数。如您所料,Floor division 是通过一个单独的__floordiv__()实现的,并使用//操作符进行访问:

img/330715_3_En_5_Fige_HTML.jpg

>>> 5 / 4
1.25
>>> 5 // 4
1

还有一个模运算,和除法有关。在除法运算会产生余数的情况下,使用模运算会返回余数,所以模运算只返回除法运算的余数。这使用了使用__mod__()实现的%操作符。字符串使用它来执行标准变量解释,即使这与除法无关:

img/330715_3_En_5_Figf_HTML.jpg

>>> 20 // 6
3
>>> 20 % 6
2
>>> 'test%s' % 'ing'
'testing'

实际上,您可以使用取整除法和模运算来获得除法运算的整数结果及其余数,余数保留了关于结果的所有信息。这有时比真除法更可取,真除法只会产生一个浮点数。例如,考虑一个需要几分钟的函数,它必须返回包含小时数和分钟数的字符串:

img/330715_3_En_5_Figg_HTML.jpg

>>> def hours_and_minutes(minutes):
...     return minutes // 60, minutes % 60
...
>>> hours_and_minutes(60)
(1, 0)
>>> hours_and_minutes(137)
(2, 17)
>>> hours_and_minutes(42)
(0, 42)

事实上,这个基本任务非常常见,Python 为它提供了自己的函数:divmod()。通过传入一个基值和一个被它除的值,可以同时得到地板除法和模运算的结果。然而,Python 将尝试调用一个__divmod__()方法,而不是简单地分别委托给这两个方法,这使得定制实现更加高效。

作为更有效实现的替代,可以使用与hours_and_minutes()函数相同的技术来说明__divmod__()方法。我们所要做的就是接受第二个参数,以便将硬编码的 60 从方法中去掉:

img/330715_3_En_5_Figh_HTML.jpg

>>> class Example:
...     def __init__(self, value):
...         self.value = value
...     def __divmod__(self, divisor):
...         return self.value // divisor, self.value % divisor
...
>>> divmod(Example(20), 6)
(3, 2)

乘法还有一个扩展,叫做指数运算,一个值要乘以它自身的次数。鉴于它与乘法的关系,Python 使用双星号**符号来执行运算。它是使用__pow__()方法实现的,因为现实世界的数学通常称之为将一个值提升到某个其他值的幂:

img/330715_3_En_5_Figi_HTML.jpg

>>> class Example:
...     def __init__(self, value):
...         self.value = value
...     def __pow__(self, power):
...         val = 1
...         for x in range(power):
...             val *= self.value
...         return val
...
>>> Example(5) ** 3
125

与其他操作不同,取幂也可以通过另一种方式执行,即通过内置的pow()函数。之所以有不同的操作符,是因为它允许传递额外的参数。这个额外的参数是一个值,应该用于在执行取幂运算之后执行模运算。这种额外的行为允许以更有效的方式来执行诸如查找素数之类的任务,这在密码学中很常用:

img/330715_3_En_5_Figj_HTML.jpg

>>> 5 ** 3
125
>>> 125 % 50
25
>>> 5 ** 3 % 50
25
>>> pow(5, 3, 50)
25

为了用__pow__()方法支持这种行为,您可以选择接受一个额外的参数,它将用于执行模运算。为了支持普通的**操作符,这个新参数必须是可选的。没有合理的缺省值可以盲目使用而不会导致标准幂运算的问题,所以它应该缺省为None来决定是否应该执行模运算:

img/330715_3_En_5_Figk_HTML.jpg

>>> class Example:
...     def __init__(self, value):
...         self.value = value
...     def __pow__(self, power, modulo=None):
...         val = 1
...         for x in range(power):
...             val *= self.value
...         if modulo is not None:
...             val %= modulo
...         return val
...
>>> Example(5) ** 3
125
>>> Example(5) ** 3 % 50
25
>>> pow(Example(5), 3, 50)
25

警告

和前面显示的__divmod__()实现一样,这个例子并不是解决问题的非常有效的方法。它确实会产生正确的值,但它仅用于说明。

位运算

位运算用于处理二进制文件、加密、编码、硬件驱动程序和网络协议的情况。因此,它们通常与低级编程相关联;然而,它们肯定不是专门为该领域保留的。通过位操作,一组独立的操作不直接作用于数值,而是作用于一系列单独的位。在这个层次上,有几种不同的操作值的方法,它们不仅适用于数字,也适用于其他类型的序列。最简单的按位操作是移位,将一个值中的位向右或向左移动,产生一个新值。

在二进制算术中,将位左移一位会使值乘以二。这就像在十进制数学中一样:如果你将一个数字中的所有数字都向左移动,并用一个零填充右边的空白,你实际上是将这个值乘以 10。这种行为存在于任何基数上,但是计算机是以二进制工作的,所以移位操作也是如此。

分别使用<<>>运算符向左和向右移动。运算符的右边表示这些位应该移位多少位。在内部,这些操作由__lshift__()__rshift__()方法支持,每个方法都接受要移动的位置数作为其唯一的附加参数:

img/330715_3_En_5_Figl_HTML.jpg

>>> 10 << 1
20
>>> 10 >> 1
5

除了打乱位的顺序之外,还有一些操作将每个值中的位相互比较,从而产生一个新值,该值表示两个单独值的某种组合。这四种按位比较操作是&|^~,分别指与、或、异或(异或)和求反。

只有当被比较的两个位都为 1 时,AND 比较才返回 1。如果是其他任何组合,结果都是 0。此行为通常用于创建位掩码,您可以通过应用 AND 将所有不相关的值重置为 0,其中每个有用的位为 1,其余位为 0。这将清除掉您不感兴趣的任何位,便于与二进制标志集进行比较。在代码中支持这种行为需要有一个__and__()方法。

如果被比较的任一位为 1,OR 比较返回 1。如果两个都是 1 也没关系;只要其中至少有一个是 1,结果就会是 1。这通常用于将多组二进制标志连接在一起,以便运算符两边的所有标志都设置在结果中。支持此功能所需的方法是__or__()

标准 or 运算符有时被称为包含 OR,与其表亲异或形成对比,后者通常缩写为 XOR。在 XOR 运算中,只有当其中一位为 1 而另一位不为 1 时,结果才为 1。如果两位都是 1 或都是 0,结果将是 0。__xor__()方法支持异或运算。

最后,Python 还提供了逐位反转,每一位都翻转为与当前值相反的值;1 变成 0,反之亦然。从数字上看,这在负值和正值之间交换,但它不仅仅改变符号。下面是一个使用~运算符反转数字时的反应示例:

img/330715_3_En_5_Figm_HTML.jpg

>>> ~42
-43
>>> ~-256
255

这种行为基于计算机处理有符号值的方式。最高有效位用于确定值是正还是负,因此翻转该位会改变符号。反转后绝对值的变化是由于缺少–0。当 0 反转时,它变成–1 而不是–0,因此所有其他值在此之后也跟着反转。

在自定义代码中,当您拥有一组已知的所有可能值以及这些值的各个子集时,反转通常是最有用的。反转这些子集将删除任何现有的值,并用主集中不在子集中的任何值替换它们。

这种行为可以通过在对象上提供一个__invert__()方法来实现。然而,与其他按位方法不同,__invert__()是一元的,所以它不接受标准self之外的任何额外参数。

注意

此处描述的反转行为对使用二进制补码方法编码的数字有效,该方法用于处理有符号数字。如果自定义数字类提供了__invert__()方法,还有其他选项 1 可用,它们的行为可能与这里显示的不同。默认情况下,Python 只使用二进制补码编码方法。

变化

除了正常的操作行为之外,还有几种不同的方法可以访问它们。最明显的问题是,这些方法通常绑定到运算符左侧的值。如果你的自定义对象被放在右边,那么左边的值很有可能不知道如何使用它,所以你会得到一个TypeError而不是一个可用的值。

这种行为是可以理解的,但是很不幸,因为如果自定义对象知道如何与另一个值交互,那么不管它们的位置如何,它都应该能够这样做。考虑到这一点,Python 给运算符右侧的值一个返回有效值的机会。

当表达式的左边没有产生值时,Python 会检查右边的值是否属于同一类型。如果是的话,就没有理由期望它能比第一次做得更好,所以 Python 简单地抛出了TypeError。但是,如果是不同的类型,Python 将调用右边值的方法,将左边的值作为参数传入。

这个过程交换参数,将方法绑定到右边的值。对于一些运算,比如减法和除法,值的顺序很重要,所以 Python 使用不同的方法来表示顺序的变化。这些独立方法的名称大多与左边的方法相同,但在前两个下划线后添加了一个r:

img/330715_3_En_5_Fign_HTML.jpg

>>> class Example:
...     def __init__(self, value):
...         self.value = value
...     def __add__(self, other):
...         return self.value + other
...
>>> Example(20) + 10
30
>>> 10 + Example(20)
Traceback (most recent call last):
  ...
TypeError: unsupported operand type(s) for +: 'int' and 'Example'
>>> class Example:
...     def __init__(self, value):
...         self.value = value
...     def __add__(self, other):
...         return self.value + other

...     def __radd__(self, other):

...         return self.value + other

...
>>> Example(20) + 10
30
>>> 10 + Example(20)

30

小费

在这种情况下,值的顺序不影响结果,实际上你可以把左边的方法赋给右边的方法。只是要记住,并不是所有的操作都是这样的,所以你不能在没有确保它有意义的情况下,盲目地将方法复制到两边。

使用这些运算符的另一种常见方式是修改现有值,并将结果赋回初始值。正如在本章前面没有解释的情况下已经证明的那样,一种替代的转让形式适合于这些修改。只需将=附加到所需的运算符上,就可以将运算结果赋给左边的值:

img/330715_3_En_5_Figo_HTML.jpg

>>> value = 5
>>> value *= 3
>>> value
15

默认情况下,这种形式的扩充赋值使用标准运算符方法,其方式与本章前面所述的方式相同。但是,这需要在操作后创建一个新值,然后用它来重新绑定一个现有的值。相反,只要您能够识别这种赋值何时发生,就地修改值有时会更有利。

像右边的方法一样,就地操作符使用与标准操作符基本相同的方法名,但是这次在下划线后面加上了一个i。然而,这个操作没有右边的等价操作,因为赋值总是在左边的变量上完成。考虑到一切,表 5-2 列出了可用的操作符,以及定制它们的行为所需的方法。

表 5-2

可用运算符

|

操作

|

操作员

|

左侧

|

右侧

|

直列式

| | --- | --- | --- | --- | --- | | 添加 | + | __add__() | __radd__() | __iadd__() | | 减法 | - | __sub__() | __rsub__() | __isub__() | | 增加 | * | __mul__() | __rmul__() | __imul__() | | 真正的分裂 | / | __truediv__() | __rtruediv__() | __itruediv__() | | 楼层划分 | // | __floordiv__() | __rfloordiv__() | __ifloordiv__() | | 以…为模 | % | __mod__() | __rmod__() | __imod__() | | 除法和模数 | divmod() | __divmod__() | __rdivmod__() | 不适用的 | | 指数运算 | ** | __pow__() | __rpow__() | __ipow__() | | 左二进制移位 | << | __lshift__() | __rlshift__() | __ilshift__() | | 右二进制移位 | >> | __rshift__() | __rrshift__() | __irshift__() | | 按位 AND | & | __and__() | __rand__() | __iand__() | | 按位或 | &#124; | __or__() | __ror__() | __ior__() | | 按位异或 | ^ | __xor__() | __rxor__() | __ixor__() | | 逐位反转 | ~ | invert() | 不适用的 | 不适用的 |

注意

没有除法和模运算的内嵌方法,因为它不能作为支持赋值的运算符。它只被称为divmod()方法,没有内联功能。此外,按位反转是一元运算,因此没有右侧或内嵌方法可用。

尽管这些操作主要集中在数字上,但它们中的许多也适用于其他类型的对象。然而,还有另一组行为,它们只对数字和可以像数字一样运行的对象有意义。

民数记

从本质上讲,所有的计算机都与数字有关,所以它们在大多数应用中扮演重要角色是很自然的。除了上一节概述的操作之外,数字还表现出许多不太明显的行为。

自定义数字最基本的行为是让 Python 相信它实际上是一个数字。当试图使用一个对象作为序列中的索引时,这是必要的。Python 要求所有索引都是整数,因此需要一种方法将对象强制转换为整数,以便用作索引。对于这个 Python 使用了一个__index__()方法,如果它不存在或者它返回一个非整数的值,就抛出一个TypeError:

img/330715_3_En_5_Figp_HTML.jpg

>>> sequence = [1, 2, 3, 4, 5]
>>> sequence[3.14]
Traceback (most recent call last):
  ...
TypeError: list indices must be integers, not float
>>> class FloatIndex(float):
...     def __index__(self):
...         # For the sake of example, return just the integer portion
...         return int(self)
...
>>> sequence[FloatIndex(3.14)]
4
>>> sequence[3]
4

除了简单的索引访问之外,__index__()还用于为了切片而强制输入一个整数,并使用内置的bin()hex()oct()函数为转换生成一个起始值。当希望在其他情况下显式强制一个整数时,可以使用内置的int()函数使用的__int__()方法。使用__float__()支持float()__complex__()进行complex()的其他类型转换。

将一个数字转换为另一个数字时,最常见的操作之一是舍入。不像int()那样盲目地截断非整数的值的任何部分,舍入对最终得到的值的类型和保留的精度提供了更多的控制。

当您将一个十进制数或浮点数传递给int()时,效果本质上只是一个地板运算。与前面提到的除法一样,floor 运算接受两个整数之间的一个数,并返回两个数中较小的一个。math模块包含一个执行该操作的floor()函数。

如您所料,这依赖于定制对象上的__floor__()方法来执行 floor 操作。除了通常的self之外,它不需要任何参数,应该总是返回一个整数。然而,Python 实际上并没有对返回值强制任何要求,所以如果你正在处理整数的某个子类,你可以返回其中的一个。

相比之下,你可能需要两者中较高的一个,这将是一个上限操作。这是使用math.ceil()完成的,并通过__ceil__()方法实现。像__floor__()一样,它不需要任何额外的参数,返回一个整数。

更有可能的是,您需要将一个值四舍五入到特定的位数。这是使用round()函数实现的,它是一个内置函数,而不是位于math模块中。它最多接受两个参数,并使用定制对象上的__round__()方法来实现。

round()的第一个参数是__round__()将要绑定的对象,所以它作为标准的self出现。然而,第二个论点有点微妙。小数点右边的位数应该被认为是有效的,因此保留在结果中。如果没有提供,round()应该假设这些数字都不重要,并返回一个整数:

img/330715_3_En_5_Figq_HTML.jpg

>>> round(3.14, 1)
3.1
>>> round(3.14)
3
>>> round(3.14, 0)
3.0
>>> import decimal
>>> round(decimal.Decimal('3.14'), 1)
Decimal('3.1')
>>> round(decimal.Decimal('3.14'))
3

如你所见,传递第二个参数 0 和根本不传递参数是有区别的。返回值本质上是一样的,但是当不传入时,应该总是得到一个整数。当传入一个 0 时,您将得到您传入的任何类型,但是只包括有效数字。

除了将小数点右边的数字四舍五入,round()还可以作用于另一边。通过传入一个负数,您可以指定小数点左边应该四舍五入的位数,而保留其他位数:

img/330715_3_En_5_Figr_HTML.jpg

>>> round(256, -1)
260
>>> round(512, -2)
500

签名操作

还有一个一元运算选项,可用于调整值的符号。第一个是-,对符号求反,在正值和负值之间交换。通过提供一个__neg__()方法可以定制这种行为,该方法不接受超过self的额外参数。

为了补充负号,Python 还支持正号,使用+。因为数字通常被假定为正数,所以这个操作符实际上并不自己做任何事情;它只是返回不变的数字。然而,如果一个定制对象需要一个附加的实际行为,一个__pos__()方法可以提供它。

最后,一个数也可以有绝对值,一般定义为它离零的距离。符号无关紧要,所有值都变成正值。因此,将abs()应用于数字会移除负号(如果存在的话),但保持正值不变。这个行为由一个__abs__()方法修改。

比较操作

至此所示的操作涉及至少部分基于一个或多个现有值返回修改后的值。相比之下,比较运算符根据两个值之间的关系返回TrueFalse

最基本的比较操作符isis not,直接作用于每个对象的内部标识。因为标识通常是作为对象在内存中的地址实现的,Python 代码不能更改它,所以没有办法覆盖这种行为。它的用途通常保留给与已知常数的比较,如None

可用的运算符代表标准的数值比较,它检测一个值是高于、低于还是完全等于另一个值。最通用的是使用==测试相等性。它的通用性来自于这样一个事实,即它不局限于数值,因为许多其他类型可以有被认为彼此相等的对象。这个行为由一个__eq__()方法控制。

不平等在 Python 中由!=操作符表示,其行为正如您所料。然而,您可能没有想到的是,这个功能与==没有任何联系。Python 不是简单地调用__eq__()并反转其结果,而是依靠一个单独的__ne__()方法来处理不等式测试。因此,如果你实现了__eq__(),也要记得提供__ne__(),以确保一切按预期运行。

此外,您可以使用<>比较一个值是小于还是大于另一个值,这两个值分别使用__lt__()__gt__()实现。等式也可以与这些相结合,例如,一个值可以大于或等于另一个值。这些操作使用<=>=,并由__lte__()__gte__()支持。

这些比较通常用于主要由数字表示的对象,即使对象本身远不止这些。日期和时间是容易比较的对象的显著例子,因为它们本质上都是一系列数字,如果需要,可以单独进行比较:

img/330715_3_En_5_Figs_HTML.jpg

>>> import datetime
>>> a = datetime.date(2019, 10, 31)
>>> b = datetime.date(2017, 1, 1)
>>> a == b
False
>>> a < b
True

关于比较,字符串是一个有趣的例子。尽管从明显的意义上来说,字符串不是数字,但字符串中的每个字符都是数字的另一种表示,所以字符串比较也是有效的。这些比较驱动了字符串的排序特性。

可重复的

序列似乎是显而易见的下一个选择,但是首先要考虑更通用的形式。如果一个对象可以一次产生一个对象,那么它被认为是可迭代的,通常是在一个for循环中。这个定义是有意简单的,因为在高层次上,iterables 并没有超出这个范围。然而,Python 对 iterables 有更具体的定义。

特别是,如果将一个对象传递给内置的iter()函数返回一个迭代器,那么这个对象就是可迭代的。在内部,iter()检查传入的对象,首先寻找一个__iter__()方法。如果找到了这样的方法,它将被不带任何参数地调用,并返回一个迭代器。如果__iter__()不可用,还会发生另一个步骤,但是现在,让我们关注迭代器。

尽管这个对象被认为是可迭代的,但是迭代器做了所有真正的工作,但是实际上没有那么多。对于__init__()方法应该是什么样子没有要求,因为它在它的主对象的__iter__()方法中被实例化。所需的接口只包含两个方法。

第一种方法,也许令人惊讶,是__iter__()。迭代器本身也应该总是可迭代的,所以它们必须提供一个__iter__()方法。不过,通常没有理由在这个方法中做任何特殊的事情,所以它通常被实现为只返回self。如果不在迭代器上提供__iter__(),主对象在大多数情况下仍然是可迭代的,但是一些代码希望迭代器也可以独立使用。

更重要的是,迭代器必须始终提供一个__next__()方法,所有真正的工作都在这里进行。Python 将调用__next__()从迭代器中检索下一个值,该值将在任何称为迭代器的代码体中使用。当代码需要一个新值时,通常是为了循环中的下一遍,它会再次调用__next__()来获得一个新值。这一过程一直持续到几件事情之一发生。

如果 Python 遇到了导致循环完成的任何事情,而迭代器仍然有它可以产生的项,那么迭代器只是等待其他代码请求另一项。如果这种情况永远不会发生,最终将不会有更多的代码知道迭代器,所以 Python 将从内存中删除它。第六章更详细地介绍了这个垃圾收集过程。

在一些不同的情况下,迭代器可能没有机会完成。最明显的是一个break语句,它将停止循环,然后继续。此外,returnraise语句将隐式地跳出它所属的任何循环,因此迭代器保持与break发生时相同的状态。

然而,更常见的情况是,循环会让迭代器一直运行,直到它不再产生任何项。使用生成器时,当函数返回而没有产生新值时,会自动处理这种情况。对于迭代器,这种行为必须显式提供。

因为None是一个完全有效的对象,可以合理地从迭代器中产生,Python 不能只对__next__()没有返回值做出反应。相反,StopIteration异常为__next__()提供了一种方式来表明不再有条目。当引发此问题时,循环被视为完成,并且在循环结束后的下一行继续执行。

为了说明所有这些是如何结合在一起的,让我们看一下内置的range()函数的行为。它不是一个生成器,因为你可以多次迭代它。为了提供类似的功能,我们需要返回一个 iterable 对象,然后可以根据需要迭代多次:

img/330715_3_En_5_Figt_HTML.jpg

class Range:
    def __init__(self, count):
        self.count = count

    def __iter__(self):
        return RangeIter(self.count)

class RangeIter:
    def __init__(self, count):
        self.count = count
        self.current = 0

    def __iter__(self):
        return self

    def __next__(self):
        value = self.current
        self.current += 1
        if self.current > self.count:
            raise StopIteration
        return value

>>> def range_gen(count):
...     for x in range(count):
...         yield x
...
>>> r = range_gen(5)
>>> list(r)
[0, 1, 2, 3, 4]
>>> list(r)
[]
>>> r = Range(5)
>>> list(r)
[0, 1, 2, 3, 4]
>>> list(r)
[0, 1, 2, 3, 4]

迭代器是实现 iterable 的最强大、最灵活的方式,所以它们通常是首选,但是还有另一种方法可以达到类似的效果。对象可迭代的原因是iter()返回一个迭代器,所以值得注意的是iter()支持某种特殊情况。

如果一个对象没有一个__iter__()方法,但是包含一个__getitem__()方法,Python 可以在一个特殊的迭代器中使用这个方法来处理这种情况。我们将在下一节讨论序列的更多细节,但基本思想是__getitem__()接受一个索引,并被期望在那个位置返回项目。

如果 Python 找到的是__getitem__()而不是__iter__(),它会自动创建一个迭代器来使用它。这个新的迭代器多次调用__getitem__(),每次调用一系列数字中的一个值,从零开始,直到__getitem__()产生一个IndexError。因此,我们的自定义Range iterable 可以非常简单地重写:

img/330715_3_En_5_Figu_HTML.jpg

class Range:
    def __init__(self, count):
        self.count = count

    def __getitem__(self, index):
        if index < self.count:
            return index
        raise IndexError

>>> r = Range(5)
>>> list(r)
[0, 1, 2, 3, 4]
>>> list(r)
[0, 1, 2, 3, 4]

注意

只有当__iter__()不存在时,Python 才会使用这个__getitem__()行为。如果两者都在一个类中提供,那么__iter__()方法将被用来控制迭代行为。

示例:可重复生成器

在显式可迭代的对象类型中,多次迭代一个对象的能力非常普遍,但是使用生成器通常更方便。如果您需要一个生成器,它可以在每次迭代器被访问时自动重启,看起来您要么错过了这个功能,要么添加了一堆不必要的代码,这些代码的存在只是为了允许正确的迭代。

相反,像许多其他行为一样,我们可以依靠 Python 的标准方法来扩充函数,并将其分解到装饰器中。当应用于生成器函数时,这个新的装饰器可以处理创建 iterable 所需的一切,每次请求新的迭代器时,iterable 从头触发生成器:

img/330715_3_En_5_Figv_HTML.jpg

def repeatable(generator):
    """
    A decorator to turn a generator into an object that can be
    iterated multiple times, restarting the generator each time.
    """
    class RepeatableGenerator:
        def __init__(self, *args, **kwargs):
            self.args = args
            self.kwargs = kwargs

        def __iter__(self):
            return iter(generator(*self.args, **self.kwargs))

    return RepeatableGenerator

>>> @repeatable
... def generator(max):
...     for x in range(max):
...         yield x
...
>>> g = generator(5)
>>> list(g)
[0, 1, 2, 3, 4]
>>> list(g)
[0, 1, 2, 3, 4]

通过创建一个可以在调用生成器函数时实例化的新类,它的__iter__()方法将代替生成器的方法被调用。这样,每次新的循环开始时,都可以从头开始调用生成器,产生新的序列,而不是试图从它停止的地方开始,这通常意味着返回一个空序列。

警告

尽管大多数生成器每次都返回相似的序列,并且可以放心地重新启动,但并不是所有的生成器都这样。如果一个生成器根据它被调用的时间改变它的输出,在随后的调用中从它停止的地方继续,或者产生副作用,这个装饰器是不推荐的。通过改变行为,每次都显式地重新启动装饰器,新的生成器可能会产生不可预知的结果。

然而,目前的代码有一个问题。@repeatable decorator 接收一个函数,但返回一个类,这在提供的例子中工作得很好,但有一些非常麻烦的含义。首先,记住第三章中的包装函数有新的属性,这个问题可以用@functools.wraps装饰器来解决。

然而,在我们考虑使用另一个装饰器之前,我们必须解决一个更大的问题:我们正在返回一个与原始函数完全不同的类型。通过返回一个类而不是一个函数,我们会给任何期望它是一个函数的代码带来问题,包括其他装饰器。更糟糕的是,返回的类不能作为方法使用,因为它没有一个__get__()方法将它绑定到它的所有者类或它的一个实例。

为了解决这些问题,我们必须在类周围引入一个包装函数,它将实例化对象并返回它。这样,我们可以使用@functools.wraps尽可能多地保留原始装饰器。更好的是,我们还可以返回一个函数,它可以毫无困难地绑定到类和实例:

img/330715_3_En_5_Figw_HTML.jpg

import functools

def repeatable(generator):
    """
    A decorator to turn a generator into an object that can be
    iterated multiple times, restarting the generator each time.
    """
    class RepeatableGenerator:
        def __init__(self, *args, **kwargs):
            self.args = args
            self.kwargs = kwargs

        def __iter__(self):
            return iter(generator(*self.args, **self.kwargs))

    @functools.wraps(generator)
    def wrapper(*args, **kwargs):
        return RepeatableGenerator(*args, **kwargs)
    return wrapper

顺序

除了数字,序列可能是所有编程中最常用的数据结构,包括 Python。列表、元组甚至字符串都是共享一组公共特性的序列,它们实际上是迭代器的一种特殊类型。除了能够单独产生一系列项目之外,序列还具有额外的属性和行为,支持它们一次性了解整个项目集的事实。

这些额外的行为并不一定要求所有的条目同时被加载到内存中。通过迭代获得的效率收益对于序列和其他任何可迭代对象一样有效,所以行为不会改变。相反,添加的选项只是将集合作为一个整体,包括其长度和获取子集的能力,以及在不获取整个序列的情况下访问单个项目。

序列最明显的特征是能够确定它的长度。对于可以包含任意项目的对象,这需要知道——或者计数——所有这些项目。对于其他人来说,对象可以使用一些其他信息来达到相同的结果。这个行为的定制是通过提供一个__len__()方法来实现的,当对象被传递到内置的len()函数中时,这个方法在内部被调用。

继续前面的例子,下面是一个简单的替换Range类如何使用它的配置信息来返回长度,而不必产生一个值:

img/330715_3_En_5_Figx_HTML.jpg

class Range:
    def __init__(self, max):
        self.max = max

    def __iter__(self):
        for x in range(self.max):
            yield x

    def __len__(self):
        return self.max

因为序列包含固定的项目集合,所以它们不仅可以从头到尾迭代,还可以反向迭代。Python 提供了reversed()函数,该函数将一个序列作为其唯一的参数,并返回一个 iterable,该 iterable 从序列中反向生成项目。可能会有特别的效率提升,所以一个定制的序列对象可以提供一个__reversed__()方法来定制reversed()的内部行为。

将这个概念再次带到Range类,可以使用内置range()的替代形式来提供一个反向范围:

img/330715_3_En_5_Figy_HTML.jpg

class Range:
    def __init__(self, max):
        self.max = max

    def __iter__(self):
        for x in range(self.max):
            yield x

    def __reversed__(self):
        for x in range(self.max - 1, -1, -1):
            yield x

既然我们已经能够向前和向后遍历序列并报告其长度,下一步就是提供对单个项目的访问。在普通 iterable 中,只能通过在循环中一次检索一项来访问项。通过预先知道序列中的所有值,自定义类可以随时提供对任何项目的访问。

最明显的任务是在给定一个预先已知的索引的情况下检索一个项目。例如,如果自定义对象包含在命令行中传递的参数,应用将知道每个参数的具体含义,并且通常通过索引来访问它们,而不是简单地遍历整个序列。这使用标准的sequence[index]语法,其行为由__getitem__()方法控制。

使用__getitem__(),如果需要,可以从序列中挑选出单个项目,或者从其他数据结构中检索。继续讨论Range主题,__getitem__()可以计算出合适的值,而不需要在序列中循环。事实上,它甚至可以支持内置range()可用的所有参数:

img/330715_3_En_5_Figz_HTML.jpg

class Range:
    def __init__(self, a, b=None, step=1):
        """
        Define a range according to a starting value, an end value and a step.

        If only one argument is provided, it's taken to be the end value. If
        two arguments are passed in, the first becomes a start value, while the
        second is the end value. An optional step can be provided to control
        how far apart each value is from the next.
        """
        if b is not None:
            self.start = a
            self.end = b
        else:
            self.start = 0
            self.end = a
        self.step = step

    def __getitem__(self, key):
        value = self.step * key + self.start
        if value < self.end:
            return value
        else:
            raise IndexError("key outside of the given range")

>>> r = Range(5)
>>> list(r)
[0, 1, 2, 3, 4]
>>> r[3]
3
>>> r = Range(3, 17, step=4)
>>> list(r)
[3, 7, 11, 15]
>>> r[2]
11
>>> r[4]
Traceback (most recent call last):
  ...
IndexError: indexed value outside of the given range

如果传入的索引超出了可用项目的范围,__getitem__()应该引发一个IndexError。高度专业化的应用可以定义一个更具体的子类并提升它,但是大多数用例会简单地自己捕捉IndexError

除了符合大多数 Python 程序员的期望之外,适当提高IndexError对于允许一个序列在不实现__iter__()的情况下被用作 iterable 是至关重要的。Python 将简单地传入整数索引,直到__getitem__()方法引发一个IndexError,此时它将停止对序列的迭代。

除了一次只能访问一个项目,序列还可以通过切片的方式提供对其内容子集的访问。当使用切片语法时,__getitem__()接收一个特殊的slice对象,而不是一个整数索引。一个slice对象具有切片的开始、停止和步进部分的专用属性,这些属性可用于确定返回哪些项目。这是如何影响我们一直在检查的Range对象的:

img/330715_3_En_5_Figaa_HTML.jpg

class Range:
    def __init__(self, a, b=None, step=1):
        """
        Define a range according to a starting value, an end value and a step.

        If only one argument is provided, it's taken to be the end value. If
        two arguments are passed in, the first becomes a start value, while the
        second is the end value. An optional step can be provided to control
        how far apart each value is from the next.
        """
        if b is not None:
            self.start = a
            self.end = b
        else:
            self.start = 0
            self.end = a
        self.step = step

    def __getitem__(self, key):
        if isinstance(key, slice):
            r = range(key.start or 0, key.stop, key.step or 1)
            return [self.step * val + self.start for val in r]
        value = self.step * key + self.start
        if value < self.end:
            return value
        else:
            raise IndexError("key outside of the given range")

下一个逻辑步骤是允许序列中的单个项目根据其索引进行设置。这种就地赋值使用基本相同的sequence[index]语法,但作为赋值操作的目标。它由一个自定义对象在其__setitem__()方法中支持,该方法接受要访问的索引和要存储在该索引中的值。

然而,和__getitem__()一样,__setitem__()也可以接受一个slice对象作为它的索引,而不是一个整数。但是,因为切片定义了序列的子集,所以传递的值应该是另一个序列。然后,这个新序列中的值将取代切片引用的子集中的值。

然而,事情并不完全像它们看起来那样,因为分配给切片的序列实际上不需要与切片本身具有相同的项目数。事实上,它可以是任何大小,无论是大于还是小于它被分配到的片。__setitem__()的预期行为只是删除切片引用的项目,然后将新项目放入该间隙,根据需要扩展或收缩总列表的大小以容纳新值。

注意

__setitem__()方法仅用于替换序列中的现有值,而非严格意义上的添加新项目。为此,你还需要实现append()insert(),使用与标准列表相同的接口。

从列表中删除一个条目可以通过两种不同的方式来实现。对此的显式方法是remove()(例如 my_list(range(10,20))。remove(5)),它获取应该移除的项的索引。然后,位于已移除项目之后的剩余项目会向左移动,以填充间隙。使用del sequence[index]语句也可以实现同样的行为。

考虑到它是一个显式的方法调用,实现remove()非常简单。del的简单情况就像remove()一样,但是使用了一个__delitem__()方法。事实上,如果删除一个条目是最重要的,你可以简单地将一个现有的remove()方法分配给__delitem__属性,它会像预期的那样工作。不幸的是,切片使事情稍微复杂了一些。

从切片中删除项目就像__setitem__()的切片行为的第一部分一样。然而,不是用一个新的序列替换切片中的项目,而是该序列应该简单地移动它的项目以缩小差距。

在对序列内容进行更改的所有不同方法中,最后一个(但不是最不重要的)重要特性是测试一个项目是否是给定序列的一部分。默认情况下,Python 将简单地遍历序列——使用前面关于 iterables 一节中列出的技术——直到它找到被测试的项或者遍历完迭代器提供的所有值。这允许在任何类型的可迭代对象上执行成员测试,而不局限于全序列。

为了提高效率,序列也可以通过提供一个__contains__()方法来覆盖这种行为。它的签名看起来像__getitem__(),但是它不是接受一个索引,而是接受一个对象,如果给定的对象出现在序列中,则返回True,否则返回False。在之前检查的Range示例中,__contains__()的结果可以根据对象的配置动态计算:

img/330715_3_En_5_Figab_HTML.jpg

class Range:
    def __init__(self, a, b=None, step=1):
        """
        Define a range according to a starting value, an end value and a step.

        If only one argument is provided, it's taken to be the end value. If
        two arguments are passed in, the first becomes a start value, while the
        second is the end value. An optional step can be provided to control
        how far apart each value is from the next.
        """
        if b is not None:
            self.start = a
            self.end = b
        else:
            self.start = 0
            self.end = a
        self.step = step

    def __contains__(self, num):
        return self.start <= num < self.end and \
               not (num – self.start) % self.step

>>> list(range(5, 30, 7))
[5, 12, 19, 26]
>>> 5 in Range(5, 30, 7)
True
>>> 10 in Range(5, 30, 7)
False
>>> 33 in Range(5, 30, 7)
False

这里为序列介绍的许多方法也适用于下一个容器类型,它将一组键映射到相关的值。

绘图

序列是对象的连续集合,而映射的工作方式略有不同。在映射中,单个项目实际上是一对,由一个键和一个值组成。键不必排序,因为迭代它们通常不是重点。相反,目标是提供对给定键引用的值的快速访问。密钥通常是预先知道的,最常见的用法是期望它。

通过键访问值与在序列中使用索引使用相同的语法。事实上,Python 不知道也不关心你实现的是序列、映射还是完全不同的东西。不管使用哪种类型的对象,相同的方法__getitem__()__setitem__()__delitem__()都被重用来支持obj[key]语法。然而,这并不意味着这些方法的实现可以完全相同。

对于映射,键被用作索引。尽管两者在语法上没有区别,但键支持更广泛的允许对象。除了普通整数,关键字可以是任何可哈希的 Python 对象,比如日期、时间或字符串;其中,字符串是最常见的。然而,由您的应用来决定是否应该对接受什么键有任何限制。

Python 支持如此多的灵活性,事实上,您甚至可以使用标准切片语法,而不用考虑切片中涉及哪些值。Python 只是传递切片中引用的任何对象,因此由映射决定如何处理它们。默认情况下,列表通过显式查找整数来处理切片,如果有必要,使用__index__()将对象强制转换为整数。相比之下,对于字典来说,slice对象是不可散列的,所以字典不允许它们被用作键。

小费

在大多数情况下,您可以接受自定义词典中的任何内容,即使您打算只使用特定的类型,如字符串,作为您的键。只要它只在您自己的代码中使用,它就不会有任何不同,因为您控制着它的所有使用。如果您所做的修改被证明在您的应用之外是有用的,其他开发人员将利用它来满足他们自己的需求。因此,只有在真正需要时,才应该限制可用的键和值;否则,最好留有选择余地,即使是为你自己。

尽管这一章并没有涵盖任何作为公共接口的一部分被直接调用的方法,但是映射有三个方法提供了对内部组件的特别有用的访问,这三个方法应该总是被实现。这些方法是必要的,因为映射本质上包含两个独立的集合——键和值——然后通过关联连接在一起,而序列只包含一个集合。

这些额外方法中的第一个keys(),迭代映射中的所有键,而不考虑它们的值。默认情况下,键可以以任何顺序返回,但一些更专业的类可以选择为这些键提供显式顺序。这个相同的行为是由映射对象本身的迭代提供的,所以一定要提供一个与keys()做同样事情的__iter__()方法。

下一个方法values()是补充性的,它迭代映射的值端。像键一样,这些值通常不被假定为任何顺序。实际上,Python 的 C 实现使用了与键相同的顺序,但是顺序从来没有保证过,即使是在同一个对象的键和值之间。

为了可靠地获得所有相关对中的键和值,映射提供了一个items()方法。这将遍历整个集合,将每一对作为一个元组以(key, value)的形式产生。因为这通常比遍历键并使用mapping[key]来获取相关值更有效,所以所有映射都应该提供一个items()方法,并使其尽可能高效。

可召回商品

在 Python 中,函数和类都可以在任何时候被调用来执行代码,但是这些并不是唯一可以这样做的对象。事实上,任何 Python 类都可以通过简单地将一个额外的方法附加到类定义上而变得可调用。这个方法被恰当地命名为__call__(),它接受通常的self以及应该在方法调用中传递的任何参数。

对于什么参数__call__()可以接受没有特殊的要求,因为它在被调用时像任何其他方法一样工作。唯一的区别是,它还接收它所附加的对象作为第一个参数:

img/330715_3_En_5_Figac_HTML.jpg

>>> class CallCounter:
...     def __init__(self):
...         self.count = 0
...     def __call__(self, *args, **kwargs):
...         self.count += 1
...         return 'Number of calls so far: %s' % self.count
...     def reset(self):
...         self.count = 0
...
>>> counter = CallCounter()
>>> counter()
'Number of calls so far: 1'
>>> counter()
'Number of calls so far: 2'
>>> counter()
'Number of calls so far: 3'
>>> counter.reset()
>>> counter()
'Number of calls so far: 1'

警告

作为一个方法本身,__call__()也可以被修饰任意次,但是记住它仍然是一个方法,即使它是通过直接调用对象来调用的。作为一个方法,任何应用于它的 decorators 必须能够处理作为对象实例的第一个参数。

至于__call__()能做什么,天空才是极限。它的目的仅仅是允许一个对象是可调用的;通话过程中会发生什么完全取决于手头的需求。这个例子表明,它也可以像任何其他方法或函数一样,接受您可能需要的任何附加参数。然而,它最大的优点是,它允许您本质上提供一个可以自己定制的功能,而不需要任何装饰者。

上下文管理器

正如在第二章中简要提到的,对象也可以在with语句中用作上下文管理器。这允许一个对象定义在该对象的上下文中工作意味着什么,在执行包含的代码之前进行设置,在执行完成之后进行清理。

一个常见的例子是文件处理,因为在使用文件之前,必须为特定类型的访问打开文件。然后,当它不再被使用时也需要被关闭,以将任何挂起的更改刷新到磁盘。这确保了其他代码稍后可以打开同一文件,而不会与任何打开的引用发生冲突。这两个操作之间发生的事情被认为是在打开文件的上下文中执行的。

如上所述,上下文管理器要执行两个不同的步骤。首先,需要初始化上下文,以便在with块中执行的代码可以利用上下文提供的特性。就在执行内部代码块之前,Python 将调用对象上的__enter__()方法。这个方法不接收任何额外的参数,只接收实例对象本身。然后,它的职责是为代码块提供必要的初始化,无论这意味着修改对象本身还是进行全局更改。

如果with语句包含一个as子句,那么__enter__()方法的返回值将用于填充该子句中引用的变量。重要的是要认识到对象本身不一定是那个值,即使看起来是那样的with语句的语法。使用__enter__()的返回值允许上下文对象更加灵活,尽管该行为可以通过简单地返回self来实现。

一旦with块中的代码执行完毕,Python 将调用对象上的__exit__()方法。然后,这个方法负责清除在__enter__()期间所做的任何更改,将上下文返回到处理with语句之前的状态。在文件的情况下,这意味着关闭文件,但它实际上可以是任何东西。

当然,with块中的执行有几种方式可以完成。最明显的是如果代码简单地自己完成,没有任何问题或其他流控制。像returnyieldcontinuebreak这样的语句也可以停止代码块的执行,在这种情况下__exit__()仍然会被调用,因为清理仍然是必要的。事实上,即使出现异常,__exit__()仍然有机会撤销在__enter__()期间应用的任何更改。

为了识别代码是正常完成还是通过异常提前停止,__exit__()方法将被赋予三个额外的参数。第一个是引发的异常的类对象,接下来是该类的实例,这是代码中实际引发的。最后,__exit__()还将接收一个 traceback 对象,表示异常发生时的执行状态。

这三个参数总是被传入,所以任何__exit__()的实现都必须接受它们。如果执行完成而没有引发任何异常,参数仍然会被提供,但是它们的值只是None。访问异常和回溯允许您的__exit__()实现智能地对任何出错和导致问题的原因做出反应。

小费

__exit__()方法本身并不抑制任何异常。如果__exit__()在没有返回值的情况下完成,最初的异常(如果有的话)将被自动重新引发。如果您需要显式地捕捉发生在with块中的任何错误,只需从__exit__()返回True,而不是让它从末尾脱落,这将返回一个隐式的None

为了展示一个简单的例子,考虑一个使用上下文管理协议来抑制任何在with块中引发的异常的类。在这种情况下,__enter__()不需要做任何事情,因为异常处理将在__exit__()中完成:

img/330715_3_En_5_Figad_HTML.jpg

>>> class SuppressErrors:
...     def __init__(self, *exceptions):
...         if not exceptions:
...             exceptions = (Exception,)
...         self.exceptions = exceptions
...     def __enter__(self):
...         pass
...     def __exit__(self, exc_class, exc_instance, traceback):
...         if isinstance(exc_instance, self.exceptions):
...          return True
...         return False
...
>>> with SuppressErrors():
...     1 / 0  # Raises a ZeroDivisionError
...
>>> with SuppressErrors(IndexError):
...     a = [1, 2, 3]
...     print(a[4])
...
>>> with SuppressErrors(KeyError):
...     a = [1, 2, 3]
...     print(a[4])
...
Traceback (most recent call last):
  ...
IndexError: list index out of range

令人兴奋的 Python 扩展:Scrapy

如果你需要从互联网上提取数据,特别是理解网站上的数据,那么网络抓取工具将大有裨益。Scrapy 是一款开源的全功能网络抓取工具。如果你听说过“蜘蛛”或“网络爬行”,那么你已经熟悉网络抓取的其他术语,但它们都是一样的。从大的方面来说,网络抓取工具是处理大数据的一部分。Web scraping 允许您从互联网上挖掘信息,而其他工具允许您清理信息,其他工具允许您对获得的原始数据和清理后的数据进行分类。Python 使得构建 scaper 变得很容易。请继续阅读,了解如何使用 Scrapy 获取原始数据。

装置

首先你需要安装网页抓取工具 Scrapy 的库。为此,进入升级的命令提示符(Windows)并键入:

pip install scrapy (Enter)

MacOS 和 Linux 也会差不多;只是检查一下刺儿头。org 网站了解详情。

奔跑的斗志

您可以通过 run spider 命令直接运行 spider,也可以创建一个包含一个或多个 spider 的项目目录。对于快速工作,比如只运行一个蜘蛛,这只是一个简单的命令:scrapy run spider my _ spider . py。但是,有时您可能需要一个项目目录,以便有序地存储配置信息和多个蜘蛛。对于我们的目的,一只蜘蛛就足够了。

项目设置

最初的过程是查找和下载网页,然后根据给定的标准从网页中提取信息。要做到这一点,你会希望你的蜘蛛在你选择的文件夹中把所有的东西组织到一个区域。对于本例,在系统上创建一个可以从命令提示符轻松导航到的文件夹。例如,如果您的 C:驱动器的根目录位于 MS Windows 上:

md firstspider (Enter)

你把文件夹放在哪里并不重要,但是一定要确保你能够找到它。接下来,使用您的 Python IDLE IDE,编写以下非常基本的 spider 代码,并将该文件作为 scraper.py 保存到您刚刚创建的文件夹中:

import scrapy
# filename scraper.py
class QuotesSpider(scrapy.Spider):
        name = "quotes"

        def start_requests(self):
            urls = [ 'http://quotes.toscrape.com/page/1/' ]
            for url in urls:
                     yield scrapy.Request(url=url, callback=self.parse)

        def parse(self, response):
            print('\nURL we went to: ', response, '\n')

现在,运行前面提到的代码并不令人兴奋。出于我们的目的,Scrapy 将通过命令行使用 Scrapy 命令界面运行得更好。这与从命令行运行 Python 脚本的方式非常相似。对于 Python,它将是Pythonname _ of _ file . py而对于 Scrapy,它将是类似的,从您刚刚创建的文件夹以及您保存文件的位置:Scrapy run spiderscraper . py(Enter)。如果一切运行正常,您应该会看到类似下面的内容:

img/330715_3_En_5_Fig1_HTML.jpg

图 5-1

通过终端运行样品刮刀的屏幕截图

如果你收到任何错误,这可能是你的路径或搜索驱动器找到 Scrapy 没有设置。如果在 Windows 上,您收到 win32api 错误,您很可能需要安装 pypiwin32。如果需要,通过在升级的命令提示符下键入以下命令来完成此操作:

pip install pypiwin32api (Enter)

就其本身而言,这是令人兴奋的,因为(希望)没有错误,我们显示了我们访问的 URL。也就是说,现在让我们做一些更有成效的工作。

用 Scrapy 检索 Web 数据

Scrapy 有一个非常方便的命令行界面。当然你会用 Python 来写你的蜘蛛,但是 Scrapy shell 可以帮助你在你的蜘蛛代码中写些什么。考虑如何用 Scrapy 查看网页。

通过 Scrapy 查看网页

在升级后的命令提示符下, scrapy 视图 http://quotes.toscrape.com/page/1/ 将使 scrapy 在浏览器中加载您指定的 URL。这很方便,因为你可能想在 Scrapy 从一个站点提取数据之前检查它。记下页面的标题;我们将只提取下一个。

外壳选项

当然,你会知道什么 Scrapy 外壳选项可用。要查看它们,使用交互式 shell 并从命令行进入**http://quotes.toscrape.com/page/1/。您现在可以看到选项。试着从命令行提示:response . CSS(' title ')。请注意,您仍然在 Scrapy 交互式 shell 中,并注意标题(来自 HTML 标记)被返回。使用 CTRL+Z 退出 shell。**

**要使用 Python 以编程方式执行相同的操作,请考虑以下情况:

import scrapy
class QuotesSpider(scrapy.Spider):
    name = "quotes"
    def start_requests(self):
        urls = [
            'http://quotes.toscrape.com/page/1/'  ]
        for url in urls:
            yield scrapy.Request(url=url, callback=self.parse)

    def parse(self, response):
        print()
        print("Title will follow: \n")
        print(response.css("title"))
        print()

这将使用从页面中提取的带有标记标签的标题。

img/330715_3_En_5_Fig2_HTML.png

图 5-2

标题的 CLI 输出

现在,为了稍微清理一下,请更改该行:

print(response.css("title"))

致:

print(response.css("title").extract_first(),)

然后保存并重新运行蜘蛛程序,您会注意到 HTML 标签和标题的输出更加干净和有用。extract_first()方法返回第一个出现的字符串。

当然,这只是让你开始使用 Scrapy 的一点点。你可以用它做更多的事情;使用你所学的知识来扩展你的网络抓取技能。找到更多信息的最佳途径是查看文档。scrapy。更多关于 Scrapy 的方法和特性的信息。事实上,这个例子中使用的引用 URL 与 Scrapy 站点教程中使用的相同。

带着它

对于本章列出的所有协议,有一点也许是理解起来最重要的:它们并不相互排斥。在单个对象上实现多个协议是可能的,有时也非常有利。例如,一个序列也可以作为一个可调用的和一个上下文管理器,如果这两个行为对一个给定的类有意义的话。

本章主要讨论了对象的行为,由它们的类提供;下一章将介绍如何在工作代码中实例化这些对象及其数据。

Footnotes 1

http://en.wikipedia.org/wiki/Signed_number_representations

 

**