Python-真实世界的数据科学-二-

44 阅读21分钟

Python 真实世界的数据科学(二)

原文:Python: Real-World Data Science

协议:CC BY-NC-SA 4.0

五、异常处理

程序非常脆弱。 如果代码始终返回有效结果,但是有时无法计算出有效结果,那将是理想的选择。 例如,不可能用零除或访问五项列表中的第八项。

在过去,解决此问题的唯一方法是严格检查每个功能的输入以确保它们有意义。 通常,函数具有特殊的返回值以指示错误情况。 例如,他们可以返回负数以表示无法计算正值。 不同的数字可能意味着发生了不同的错误。 任何调用此函数的代码都必须明确检查错误情况并采取相应措施。 很多代码都没有去做,程序只是崩溃了。 但是,在面向对象的世界中,情况并非如此。

在本章中,我们将研究异常,这些特殊错误对象仅在有意义的情况下才需要处理。 特别是,我们将介绍:

  • 如何导致异常发生
  • 发生异常时如何恢复
  • 如何以不同方式处理不同的异常类型
  • 发生异常时进行清理
  • 创建新的异常类型
  • 使用异常语法进行流控制

引发异常

原则上,异常只是一个对象。 有许多不同的异常类可用,我们可以轻松定义更多自己的异常类。 它们共有的一件事是它们从称为BaseException的内置类继承。 这些异常对象在程序的控制流中处理时会变得特殊。 当发生异常时,原本应该发生的一切都不会发生,除非在发生异常时原本应该发生。 有道理? 不用担心,它将!

导致异常发生的最简单方法是做一些愚蠢的事情! 您可能已经完成了此操作,并看到了异常输出。 例如,任何时候 Python 在程序中遇到它无法理解的行时,它都会以SyntaxError保释,这是一种异常。 这是一个常见的例子:

>>> print "hello world"
 File "<stdin>", line 1
 print "hello world"
 ^
SyntaxError: invalid syntax

print语句在 Python 2 和早期版本中是有效的命令,但是在 Python 3 中,因为print现在是一个函数,所以必须将参数括在括号中。 因此,如果将前面的命令键入 Python 3 解释器,则会得到SyntaxError

除了SyntaxError以外,以下示例还显示了我们可以处理的其他一些常见异常:

>>> x = 5 / 0
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
ZeroDivisionError: int division or modulo by zero

>>> lst = [1,2,3]
>>> print(lst[3])
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
IndexError: list index out of range

>>> lst + 2
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
TypeError: can only concatenate list (not "int") to list

>>> lst.add
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
AttributeError: 'list' object has no attribute 'add'

>>> d = {'a': 'hello'}
>>> d['b']
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
KeyError: 'b'

>>> print(this_is_not_a_var)
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
NameError: name 'this_is_not_a_var' is not defined

有时,这些异常表明我们的程序有问题(在这种情况下,我们将转到指定的行号并进行修复),但在合法情况下也会发生。 ZeroDivisionError并不总是表示我们收到了无效的输入。 这也可能意味着我们收到了不同的意见。 用户可能错误或故意输入了零,也可能代表合法值,例如空银行帐户或新生婴儿的年龄。

您可能已经注意到前面所有内置的异常都以名称Error结尾。 在 Python 中,errorexception这两个词几乎可以互换使用。 有时错误被认为比异常更为可怕,但是它们的处理方式完全相同。 实际上,前面示例中的所有错误类都将Exception(扩展了BaseException)作为其超类。

引发异常

我们将在一分钟内处理异常,但首先,让我们发现如果我们正在编写一个需要通知用户或调用函数某些输入无效的程序,该怎么办。 如果我们可以使用 Python 使用的相同机制,那不是很好吗? 好吧,我们可以! 这是一个简单的类,仅当项目为偶数整数时才将其添加到列表中:

class EvenOnly(list):
    def append(self, integer):
        if not isinstance(integer, int):
            raise TypeError("Only integers can be added")
        if integer % 2:
            raise ValueError("Only even numbers can be added")
        super().append(integer)

此类扩展了内置的list,正如我们在第 2 章,“Python 中的对象”中所讨论的那样,并重写了append方法以检查两个条件来确保 该项目是一个偶数整数。 我们首先检查输入是否为int类型的实例,然后使用模运算符确保将其除以 2。 如果两个条件中的任何一个都不满足,则raise关键字将导致发生异常。 raise关键字后面紧跟着引发异常的对象。 在前面的示例中,从内置类TypeErrorValueError重新构造了两个对象。 引发的对象可以很容易地成为我们自己创建的新异常类的实例(很快就会看到),在其他地方定义的异常,甚至是先前已经引发和处理的异常对象。 如果我们在 Python 解释器中测试该类,我们可以看到发生异常时,它正在输出有用的错误信息,就像以前一样:

>>> e = EvenOnly()
>>> e.append("a string")
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
 File "even_integers.py", line 7, in add
 raise TypeError("Only integers can be added")
TypeError: Only integers can be added

>>> e.append(3)
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
 File "even_integers.py", line 9, in add
 raise ValueError("Only even numbers can be added")
ValueError: Only even numbers can be added
>>> e.append(2)

注意

虽然此类对于演示实际发生的异常非常有效,但它的工作并不是很好。 仍然可以使用索引符号或切片符号将其他值添加到列表中。 可以通过重写其他适当的方法来避免这些问题,其中一些方法是双下划线方法。

异常的影响

引发异常时,它似乎立即停止程序执行。 引发异常后应该运行的任何行都不会执行,并且除非处理了异常,否则程序将退出并显示一条错误消息。 看一下这个简单的函数:

def no_return():
    print("I am about to raise an exception")
    raise Exception("This is always raised")
    print("This line will never execute")
    return "I won't be returned"

如果执行此函数,则会看到第一个print调用已执行,然后引发了异常。 第二条print语句永远不会执行,return语句也永远不会执行:

>>> no_return()
I am about to raise an exception
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
 File "exception_quits.py", line 3, in no_return
 raise Exception("This is always raised")
Exception: This is always raised

此外,如果我们有一个函数调用另一个引发异常的函数,则在调用第二个函数的位置之后,第一个函数将不执行任何操作。 引发异常将停止所有通过函数调用堆栈执行的操作,直到该异常被处理或迫使解释器退出为止。 为了演示,让我们添加第二个函数来调用较早的函数:

def call_exceptor():
    print("call_exceptor starts here...")
    no_return()
    print("an exception was raised...")
    print("...so these lines don't run")

当我们调用此函数时,我们看到第一个print语句以及no_return函数的第一行都被执行。 但是,一旦引发异常,便不会执行其他任何操作:

>>> call_exceptor()
call_exceptor starts here...
I am about to raise an exception
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
 File "method_calls_excepting.py", line 9, in call_exceptor
 no_return()
 File "method_calls_excepting.py", line 3, in no_return
 raise Exception("This is always raised")
Exception: This is always raised

我们很快就会看到,当解释器实际上并没有采取快捷方式并立即退出时,我们可以对这两种方法中的异常进行反应和处理。 确实,异常在最初引发之后可以在任何级别上进行处理。

从底部到顶部查看异常的输出(称为回溯),并注意如何列出这两种方法。 在no_return内部,最初引发了异常。 然后,在其上方,我们看到在call_exceptor内部,调用了令人讨厌的no_return函数,并且该异常冒泡到调用方法中。 从那里,它又上升到主解释器的另一层,主解释器不知道该怎么做,就放弃并打印了回溯。

处理异常

现在让我们来看看在异常硬币的尾部。 如果遇到异常情况,我们的代码应如何应对或从中恢复? 我们通过将可能抛出一个代码的任何代码(无论是异常代码本身,还是对其内部可能引发异常的任何函数或方法的调用)包装在try ... except子句中来处理异常。 最基本的语法如下所示:

try:
    no_return()
except:
    print("I caught an exception")
print("executed after the exception")

如果我们使用我们现有的no_return函数运行此简单脚本,众所周知,该函数始终会引发异常,则将得到以下输出:

I am about to raise an exception
I caught an exception
executed after the exception

no_return函数高兴地通知我们它即将引发异常,但是我们欺骗了它并捕获了异常。 一旦被抓住,我们就可以自己清理(在这种情况下,通过输出我们正在处理的情况),并继续前进,而不会受到进攻职能的干扰。 no_return函数中的其余代码仍未执行,但是调用该函数的代码能够恢复并继续。

注意tryexcept周围的缩进。 try子句包装可能引发异常的所有代码。 然后,except子句返回与try行相同的缩进级别。 在except子句之后缩进任何用于处理异常的代码。 然后,普通代码将以原始缩进级别恢复。

前面的代码的问题在于它将捕获任何类型的异常。 如果我们正在编写一些可以同时引发TypeErrorZeroDivisionError的代码怎么办? 我们可能想捕获ZeroDivisionError,但让TypeError传播到控制台。 你能猜出语法吗?

这是一个相当愚蠢的功能,它可以做到这一点:

def funny_division(divider):
    try:
        return 100 / divider
    except ZeroDivisionError:
        return "Zero is not a good idea!"

print(funny_division(0))
print(funny_division(50.0))
print(funny_division("hello"))

该函数已通过print语句进行了测试,这些语句表明其行为符合预期:

Zero is not a good idea!
2.0
Traceback (most recent call last):
 File "catch_specific_exception.py", line 9, in <module>
 print(funny_division("hello"))
 File "catch_specific_exception.py", line 3, in funny_division
 return 100 / anumber
TypeError: unsupported operand type(s) for /: 'int' and 'str'.

输出的第一行显示,如果输入0,则会得到正确的模拟。 如果我们使用有效的数字进行调用(请注意,它不是整数,但仍然是有效的除数),则它可以正常运行。 但是,如果我们输入一个字符串(您想知道如何获取TypeError,不是吗?),它会失败并出现异常。 如果我们使用了没有指定ZeroDivisionError的空except子句,那么它将在向我们发送字符串时指责我们将其除以零,这根本不是正确的行为。

我们甚至可以捕获两个或更多不同的异常,并使用相同的代码处理它们。 这是一个引发三种不同类型异常的示例。 它使用相同的异常处理程序处理TypeErrorZeroDivisionError,但是如果您提供数字13,它也可能会引发ValueError

def funny_division2(anumber):
    try:
        if anumber == 13:
            raise ValueError("13 is an unlucky number")
        return 100 / anumber
    except (ZeroDivisionError, TypeError):
        return "Enter a number other than zero"

for val in (0, "hello", 50.0, 13):

    print("Testing {}:".format(val), end=" ")
    print(funny_division2(val))

底部的for循环在多个测试输入上循环并打印结果。 如果您想知道print语句中的end参数,它将仅将默认的尾随换行符转换为空格,以便将其与下一行的输出结合在一起。 这是程序的运行:

Testing 0: Enter a number other than zero
Testing hello: Enter a number other than zero
Testing 50.0: 2.0
Testing 13: Traceback (most recent call last):
 File "catch_multiple_exceptions.py", line 11, in <module>
 print(funny_division2(val))
 File "catch_multiple_exceptions.py", line 4, in funny_division2
 raise ValueError("13 is an unlucky number")
ValueError: 13 is an unlucky number

数字0和字符串都被except子句捕获,并显示适当的错误消息。 无法捕获数字13中的异常,因为它是ValueError,未包含在要处理的异常类型中。 这一切都很好,但是如果我们想捕获不同的异常并对它们执行不同的操作怎么办? 还是我们想做一个例外处理,然后让它继续冒泡至父函数,就好像从未被捕获一样? 我们不需要任何新的语法来处理这些情况。 可以堆叠except子句,并且仅执行第一个匹配项。 对于第二个问题,如果我们已经在异常处理程序中,则不带任何参数的raise关键字将引发最后一个异常。 观察以下代码:

def funny_division3(anumber):
    try:
        if anumber == 13:
            raise ValueError("13 is an unlucky number")
        return 100 / anumber
    except ZeroDivisionError:
        return "Enter a number other than zero"
    except TypeError:
        return "Enter a numerical value"
    except ValueError:
        print("No, No, not 13!")
        raise

最后一行重新显示ValueError,因此在输出No, No, not 13!之后,它将再次引发异常; 我们仍然会在控制台上获得原始堆栈跟踪。

如果像在上一个示例中那样堆叠异常子句,即使只有多个匹配子句适合,也将仅运行第一个匹配子句。 多个子句如何匹配? 请记住,异常是对象,因此可以被子类化。 正如我们将在下一节中看到的那样,大多数异常扩展了Exception类(它本身是从BaseException派生的)。 如果我们在捕获TypeError之前捕获了Exception,则仅会执行Exception处理程序,因为从继承的角度来看TypeErrorException

在我们要专门处理一些异常,然后再处理所有其余异常的情况下,这可能会派上用场。 我们可以在捕获所有特定异常之后简单地捕获Exception并在那里处理一般情况。

有时,当我们捕获到异常时,我们需要引用Exception对象本身。 当我们使用自定义参数定义自己的异常时,这种情况最经常发生,但也可能与标准异常有关。 大多数异常类在其构造函数中接受一组参数,我们可能希望在异常处理程序中访问这些属性。 如果定义了自己的异常类,则甚至可以在捕获到异常类时对其调用自定义方法。 将异常捕获为变量的语法使用as关键字:

try:
    raise ValueError("This is an argument")
except ValueError as e:
    print("The exception arguments were", e.args)

如果运行此简单代码段,它将在初始化时打印出传递给ValueError的字符串参数。

我们已经看到了用于处理异常的语法的几种变体,但是无论是否发生异常,我们仍然不知道如何执行代码。 我们也不能指定仅在没有异常发生时才应执行的代码。 另外两个关键字finallyelse可以提供缺少的内容。 没有人接受任何额外的论点。 下面的示例随机选择一个异常引发并引发它。 然后,运行一些不太复杂的异常处理代码,以说明新引入的语法:

import random
some_exceptions = [ValueError, TypeError, IndexError, None]

try:
    choice = random.choice(some_exceptions)
    print("raising {}".format(choice))
    if choice:
        raise choice("An error")
except ValueError:
    print("Caught a ValueError")
except TypeError:
    print("Caught a TypeError")
except Exception as e:
    print("Caught some other error: %s" %
        ( e.__class__.__name__))
else:
    print("This code called if there is no exception")
finally:
    print("This cleanup code is always called")

如果我们运行此示例(说明几乎所有可能的异常处理方案)几次,则每次都会得到不同的输出,具体取决于random选择的异常。 以下是一些示例运行:

$ python finally_and_else.py
raising None
This code called if there is no exception
This cleanup code is always called

$ python finally_and_else.py
raising <class 'TypeError'>
Caught a TypeError
This cleanup code is always called

$ python finally_and_else.py
raising <class 'IndexError'>
Caught some other error: IndexError
This cleanup code is always called

$ python finally_and_else.py
raising <class 'ValueError'>
Caught a ValueError
This cleanup code is always called

请注意,无论发生什么情况,如何执行finally子句中的 print语句。 当我们需要在代码完成运行后执行某些任务时(即使发生了异常),这非常有用。 一些常见的示例包括:

  • 清理打开的数据库连接
  • 关闭打开的文件
  • 通过网络发送关闭握手

当我们从try子句内部执行return语句时,finally子句也非常重要。 返回值之前,finally句柄仍将执行。

另外,在不引发异常的情况下,请注意输出:elsefinally子句均被执行。 else子句似乎是多余的,因为仅当没有引发异常时才应执行的代码可以放在整个try ... except块之后。 不同之处在于,如果捕获并处理了异常,else块仍将执行。 稍后讨论使用异常作为流控制时,我们将对此进行更多介绍。

try块之后,可以省略exceptelsefinally子句中的任何子句(尽管else本身无效)。 如果包含多个,则必须先出现except子句,然后是else子句,最后是finally子句。 except子句的顺序通常从最具体到最通用。

异常层次结构

我们已经看到了几种最常见的内置异常,您可能会在常规 Python 开发过程中遇到其余的异常。 正如我们前面所注意到的,大多数例外是Exception类的子类。 但是,并非所有例外情况都是如此。 Exception本身实际上继承自名为BaseException的类。 实际上,所有异常都必须扩展BaseException类或其子类之一。

SystemExitKeyboardInterrupt这是两个主要例外,它们直接源自BaseException而不是Exception。 每当程序自然退出时,都会引发SystemExit异常,这通常是因为我们在代码中的某个地方调用了sys.exit函数(例如,当用户选择退出菜单项时,单击了窗口上的“关闭”按钮,或者 输入了关闭服务器的命令)。 该异常旨在允许我们在程序最终退出之前清除代码,因此我们通常无需显式处理它(因为清除代码发生在finally子句中)。

如果我们确实处理了该异常,则通常会引发该异常,因为捕获该异常会阻止程序退出。 当然,在某些情况下,我们可能希望停止程序退出,例如,如果有未保存的更改,并且我们想在用户确实要退出时提示用户。 通常,如果我们完全处理SystemExit,那是因为我们要对它进行特殊处理,或者直接对其进行预期。 我们尤其不希望它被捕获所有普通异常的泛型子句意外地捕获。 这就是为什么它直接源自BaseException的原因。

KeyboardInterrupt异常在命令行程序中很常见。 当用户使用与操作系统相关的组合键(通常为 Ctrl +C)显式中断程序执行时,抛出该错误。 这是用户有意中断正在运行的程序的标准方法,并且像SystemExit一样,它几乎总是应通过终止程序来做出响应。 另外,像SystemExit一样,它应该处理finally块中的所有清理任务。

这是一个类图,充分说明了异常层次结构:

The exception hierarchy

当我们使用except:子句而不指定任何异常类型时,它将捕获BaseException的所有子类; 也就是说,它将捕获所有异常,包括两个特殊异常。 由于我们几乎总是希望它们得到特殊处理,因此不带参数使用except:语句是不明智的。 如果要捕获SystemExitKeyboardInterrupt以外的所有异常,请显式捕获Exception

此外,如果您确实想捕获所有异常,我建议使用语法except BaseException:而不是原始的except:。 这有助于明确告知将来的代码读者您有意处理特殊情况的异常。

定义我们自己的例外

通常,当我们想要引发异常时,我们发现没有合适的内置异常。 幸运的是,定义我们自己的新异常很简单。 通常,该类的名称旨在传达问题所在,并且我们可以在初始化程序中提供任意参数以包含其他信息。

我们要做的就是从Exception类继承。 我们甚至不必在课程中添加任何内容! 当然,我们可以直接扩展BaseException,但是它将不会被通用except Exception子句捕获。

这是我们可能在银行应用中使用的一个简单例外:

class InvalidWithdrawal(Exception):
    pass

raise InvalidWithdrawal("You don't have $50 in your account")

最后一行说明了如何引发新定义的异常。 我们能够将任意数量的参数传递给异常。 通常会使用字符串消息,但是可以存储在以后的异常处理程序中可能有用的任何对象。 Exception.__init__方法旨在接受任何参数,并将它们作为元组存储在名为args的属性中。 这使异常更易于定义,而无需覆盖__init__

当然,如果我们确实想自定义初始化程序,则可以随意进行。 这是一个例外情况,其初始化程序接受当前余额和用户要提取的金额。 另外,它添加了一种方法来计算请求的透支程度:

class InvalidWithdrawal(Exception):
    def __init__(self, balance, amount):
        super().__init__("account doesn't have ${}".format(
            amount))
        self.amount = amount
        self.balance = balance

    def overage(self):
        return self.amount - self.balance

raise InvalidWithdrawal(25, 50)

最后的raise语句说明了如何构造此异常。 如您所见,除了可以处理其他对象外,我们可以做任何事情。 我们可以捕获异常并将其作为工作对象传递,尽管更常见的是将对工作对象的引用作为异常的属性包括进来,然后将其传递。

如果引发一个InvalidWithdrawal异常,这是我们将如何处理:

try:
    raise InvalidWithdrawal(25, 50)
except InvalidWithdrawal as e:
    print("I'm sorry, but your withdrawal is "
            "more than your balance by "
            "${}".format(e.overage()))

在这里,我们看到as关键字的有效用法。 按照惯例,大多数 Python 编码器都将异常变量命名为e,尽管您通常可以随意将其命名为exexceptionaunt_sally

定义我们自己的例外有很多原因。 将信息添加到异常或以某种方式记录日志通常很有用。 但是,在创建旨在供其他程序员访问的框架,库或 API 时,自定义异常的实用程序真正发挥作用。 在这种情况下,请务必确保您的代码提出了对客户端程序员有意义的异常。 它们应该易于处理并清楚地描述发生了什么。 客户端程序员应该容易地看到如何解决错误(如果它反映了他们的代码中的错误)或处理异常(如果是这种情况,则需要使他们意识到)。

异常不是例外。 新手程序员倾向于认为异常仅对特殊情况有用。 但是,特殊情况的定义可能会含糊不清,并可能需要解释。 请考虑以下两个功能:

def divide_with_exception(number, divisor):
    try:
        print("{} / {} = {}".format(
            number, divisor, number / divisor * 1.0))
    except ZeroDivisionError:
        print("You can't divide by zero")

def divide_with_if(number, divisor):
    if divisor == 0:
        print("You can't divide by zero")
    else:
        print("{} / {} = {}".format(
            number, divisor, number / divisor * 1.0))

这些两个功能的行为相同。 如果divisor为零,则会显示一条错误消息;否则,将显示错误消息。 否则,显示打印除法结果的消息。 通过使用if语句进行测试,可以避免抛出ZeroDivisionError。 类似地,我们可以通过显式检查参数是否在列表的范围内来避免使用IndexError,而通过检查键是否在字典中来避免使用KeyError

但是我们不应该这样做。 一方面,我们可能会编写if语句来检查索引是否低于列表的参数,但忘记检查负值。

注意

请记住,Python 列表支持否定索引。 -1引用列表中的最后一个元素。

最终,我们将发现这一点,并且必须找到检查代码的所有位置。 但是,如果我们只是捕获并处理了IndexError,我们的代码就可以工作。

Python 程序员倾向于遵循寻求宽恕而不是许可的模型,也就是说,他们执行代码,然后处理任何出错的地方。 通常,对于在跳到之前先看一眼的选择不屑一顾。 造成这种情况的原因有很多,但是主要的原因是,不必花费大量的 CPU 周期来寻找在正常的代码路径中不会出现的异常情况。 因此,明智的做法是在例外情况下使用例外,即使这些情况只是一点例外。 更进一步,我们实际上可以看到异常语法对于流控制也是有效的。 像if语句一样,可以将异常用于决策,分支和消息传递。

想象一个库存应用,该应用销售一家小部件和小工具的公司。 当客户购买商品时,该商品可以被使用,在这种情况下,该商品将从库存中移除并退回剩余的商品数量,或者它可能无货。 现在,缺货是库存应用中发生的完全正常的事情。 当然,这不是例外情况。 但是,如果缺货我们会退货吗? 字符串说缺货? 负数? 在这两种情况下,调用方法都必须检查返回值是正整数还是其他值,以确定是否缺货。 似乎有点混乱。 相反,我们可以提高OutOfStockException并使用try语句来指导程序流控制。 有道理? 此外,我们要确保不会将同一商品卖给两个不同的客户,也不会出售没有库存的商品。 简化此操作的一种方法是锁定每种类型的项目,以确保一次只有一个人可以更新它。 用户必须锁定该物品,操纵该物品(购买,添加库存,盘点剩余物品……),然后解锁该物品。 这是一个带有文档字符串的不完整Inventory示例,该示例描述了某些方法应该执行的操作:

class Inventory:
    def lock(self, item_type):
        '''Select the type of item that is going to
        be manipulated. This method will lock the
        item so nobody else can manipulate the
        inventory until it's returned. This prevents
        selling the same item to two different
        customers.'''
        pass

    def unlock(self, item_type):
        '''Release the given type so that other
        customers can access it.'''
        pass

    def purchase(self, item_type):
        '''If the item is not locked, raise an
        exception. If the item_type  does not exist,
        raise an exception. If the item is currently
        out of stock, raise an exception. If the item
        is available, subtract one item and return
        the number of items left.'''
        pass

我们可以将的对象原型交给开发人员,让他们实现所需的方法以完全按照他们所说的进行操作,同时处理需要购买的代码。 我们将使用 Python 强大的异常处理来考虑不同的分支,具体取决于购买方式:

item_type = 'widget'
inv = Inventory()
inv.lock(item_type)
try:
    num_left = inv.purchase(item_type)
except InvalidItemType:
    print("Sorry, we don't sell {}".format(item_type))
except OutOfStock:
    print("Sorry, that item is out of stock.")
else:
    print("Purchase complete. There are "
            "{} {}s left".format(num_left, item_type))
finally:
    inv.unlock(item_type)

请注意如何使用所有可能的异常处理子句来确保正确的操作在正确的时间发生。 即使OutOfStock并不是非常例外的情况,我们也可以使用异常来适当地处理它。 可以使用if ... elif ... else结构编写相同的代码,但是它不那么容易阅读或维护。

我们还可以使用异常在不同方法之间传递消息。 例如,如果我们想通知客户该物料预计在什么日期再次进货,我们可以确保OutOfStock对象在构造时需要back_in_stock参数。 然后,当我们处理异常时,我们可以检查该值并向客户提供其他信息。 附加到对象的信息可以轻松地在程序的两个不同部分之间传递。 该异常甚至可以提供一种指示库存对象重新订购或补货的方法。

将异常用于流控制可以使一些方便的程序设计成为可能。 从此讨论中获取的重要信息是,异常不是我们应该避免的不好的事情。 发生异常并不意味着您应该防止这种特殊情况的发生。 而是,这只是在可能不会直接相互调用的两个代码段之间传递信息的有效方法。

案例研究

我们一直在以相当低的详细程度(语法和定义)研究的使用和异常处理。 此案例研究将有助于将其与我们前面的章节联系在一起,以便我们了解如何在对象,继承和模块的更大上下文中使用异常。

今天,我们将设计一个简单的中央身份验证和授权系统。 整个系统将放置在一个模块中,其他代码将能够查询该模块对象以进行身份​​验证和授权。 我们应该从一开始就承认我们不是安全专家,并且我们正在设计的系统可能充满安全漏洞。 我们的目的是研究异常,而不是保护系统。 但是,对于一个基本的登录和许可系统,其他代码可以与之交互就足够了。 以后,如果需要使其他代码更安全,我们可以请安全性或加密专家审查或重写我们的模块,最好不更改 API。

身份验证是确保用户确实是他们所说的人的过程。 我们将沿用当今常见的 Web 系统,使用用户名和私人密码组合。 其他身份验证方法包括语音识别,指纹或视网膜扫描仪和身份证。

另一方面,授权是关于确定是否允许给定(经过身份验证的)用户执行特定操作的。 我们将创建一个基本的权限列表系统,其中存储了允许执行每个操作的特定人员的列表。

此外,我们将添加一些管理功能,以允许将新用户添加到系统中。 为简便起见,添加密码后,我们将省去密码的编辑或更改权限,但是将来肯定可以添加这些(非常必要的)功能。

有一个简单的分析; 现在让我们继续设计。 显然,我们需要一个User类来存储用户名和加密密码。 该类还将允许用户通过检查提供的密码是否有效来登录。 我们可能不需要Permission类,因为这些类可以只是使用字典映射到用户列表的字符串。 我们应该有一个中央的Authenticator类来处理用户管理以及登录或注销。 难题的最后一部分是Authorizor类,该类处理权限并检查用户是否可以执行活动。 我们将在auth模块中为每个此类提供一个实例,以便其他模块可以使用此中心机制来满足其所有身份验证和授权需求。 当然,如果他们想实例化这些类的私有实例,则对于非中央授权活动,他们可以自由地这样做。

我们还将定义几个例外。 我们将从一个特殊的AuthException基类开始,该基类接受username和可选的user对象作为参数; 我们大多数的自定义异常都将从该异常继承。

让我们首先构建User类; 看起来很简单。 可以使用用户名和密码初始化新用户。 密码将以加密方式存储,以减少被盗的机会。 我们还需要一种check_password方法来测试提供的密码是否正确。 这是完整的课程:

import hashlib

class User:
    def __init__(self, username, password):
        '''Create a new user object. The password
        will be encrypted before storing.'''
        self.username = username
        self.password = self._encrypt_pw(password)
        self.is_logged_in = False

    def _encrypt_pw(self, password):
        '''Encrypt the password with the username and return
        the sha digest.'''
        hash_string = (self.username + password)
        hash_string = hash_string.encode("utf8")
        return hashlib.sha256(hash_string).hexdigest()

    def check_password(self, password):
        '''Return True if the password is valid for this
        user, false otherwise.'''
        encrypted = self._encrypt_pw(password)
        return encrypted == self.password

由于__init__check_password都需要用于加密密码的代码,因此我们将其拉出自己的方法。 这样,只有在有人意识到它不安全并且需要改进时,才需要在一个地方进行更改。 该类可以轻松扩展为包括必填或可选的个人详细信息,例如姓名,联系信息和生日。

在编写代码以添加用户之前(这将在尚未定义的Authenticator类中发生),我们应该检查一些用例。 如果一切顺利,我们可以添加一个用户名和密码。 创建User对象并将其插入字典中。 但是,在什么方面都不能顺利? 好吧,显然,我们不想添加用户名与字典中已经存在的用户相同的用户。 如果这样做,我们将覆盖现有用户的数据,新用户可能会访问该用户的特权。 因此,我们需要一个UsernameAlreadyExists异常。 另外,为了安全起见,如果密码太短,我们可能应该引发异常。 这两个异常都将扩展AuthException,这是我们前面提到的。 因此,在编写Authenticator类之前,让我们定义以下三个异常类:

class AuthException(Exception):
    def __init__(self, username, user=None):
        super().__init__(username, user)
        self.username = username
        self.user = user

class UsernameAlreadyExists(AuthException):
    pass

class PasswordTooShort(AuthException):
    pass

AuthException需要用户名并具有可选的用户参数。 第二个参数应该是与该用户名关联的User类的实例。 我们正在定义的两个特定异常只需要告知调用类异常情况,因此我们不需要向其添加任何其他方法。

现在让我们从Authenticator类开始。 它可以只是用户名到用户对象的映射,因此我们将从初始化函数中的字典开始。 添加用户的方法需要在创建新的User实例并将其添加到字典之前检查两个条件(密码长度和以前存在的用户):

class Authenticator:
    def __init__(self):
        '''Construct an authenticator to manage
        users logging in and out.'''
        self.users = {}

    def add_user(self, username, password):
        if username in self.users:
            raise UsernameAlreadyExists(username)
        if len(password) < 6:
            raise PasswordTooShort(username)
        self.users[username] = User(username, password)

当然,如果需要,我们可以扩展的密码验证,以引发太容易以其他方式破解的密码例外。 现在让我们准备login方法。 如果我们现在不考虑异常,则可能只希望该方法返回TrueFalse,具体取决于登录是否成功。 但是我们正在考虑例外情况,这可能是在不太例外的情况下使用例外情况的好地方。 我们可能会提出不同的例外情况,例如,如果用户名不存在或密码不匹配。 这样,任何试图登录用户的人都可以使用try / except / else子句轻松地处理这种情况。 因此,首先我们添加以下新异常:

class InvalidUsername(AuthException):
    pass

class InvalidPassword(AuthException):
    pass

然后,我们可以为Authenticator类定义一个简单的login方法,该方法在必要时引发这些异常。 如果没有,它将user标记为已登录并返回:

    def login(self, username, password):
        try:
            user = self.users[username]
        except KeyError:
            raise InvalidUsername(username)

        if not user.check_password(password):
            raise InvalidPassword(username, user)

        user.is_logged_in = True
        return True

注意如何处理KeyError。 可以使用if username not in self.users:处理此问题,但我们选择直接处理该异常。 我们最终吃掉了第一个例外,并提出了一个全新的,更适合面向用户的 API 的例外。

我们还可以添加一种方法来检查是否登录了特定的用户名。在此处决定是否使用异常更为棘手。 如果用户名不存在,是否应该引发异常? 如果用户未登录,是否应该引发异常?

要回答这些问题,我们需要考虑如何访问该方法。 通常,此方法将用于回答“是/否”问题:“我应该允许他们访问 <还是> 吗?” 答案将是“是,用户名有效且已登录”,或“否,用户名无效或未登录”。 因此,布尔返回值就足够了。 此处仅出于使用异常的目的而无需使用异常。

    def is_logged_in(self, username):
        if username in self.users:
            return self.users[username].is_logged_in
        return False

最后,我们可以向模块添加默认的身份验证器实例,以便客户端代码可以使用auth.authenticator轻松访问它:

authenticator = Authenticator()

该行在任何类定义之外的模块级别进入,因此可以通过auth.authenticator来访问 authenticator 变量。 现在我们可以从Authorizor类开始,该类将权限映射到用户。 如果用户未登录,则Authorizor类不应允许用户访问权限,因此,他们需要引用特定的身份验证器。 我们还需要在初始化时设置权限字典:

class Authorizor:
    def __init__(self, authenticator):
        self.authenticator = authenticator
        self.permissions = {}

现在,我们可以编写添加新权限以及设置与每个权限相关联的用户的方法:

    def add_permission(self, perm_name):
        '''Create a new permission that users
        can be added to'''
        try:
            perm_set = self.permissions[perm_name]
        except KeyError:
            self.permissions[perm_name] = set()
        else:
            raise PermissionError("Permission Exists")

    def permit_user(self, perm_name, username):
        '''Grant the given permission to the user'''
        try:
            perm_set = self.permissions[perm_name]
        except KeyError:
            raise PermissionError("Permission does not exist")
        else:
            if username not in self.authenticator.users:
                raise InvalidUsername(username)
            perm_set.add(username)

第一个方法允许我们创建一个新的权限,除非它已经存在,在这种情况下会引发异常。 第二个允许我们将用户名添加到权限中,除非该权限或用户名尚不存在。

我们使用set代替list作为用户名,因此,即使您多次授予用户权限,集合的性质也意味着该用户仅在集合中一次。 我们将在下一章中进一步讨论集合。

两种方法都将引发PermissionError。 这个新错误不需要用户名,因此我们将使其直接扩展为Exception,而不是我们的自定义AuthException

class PermissionError(Exception):
    pass

最后,我们可以添加一种方法来检查用户是否具有特定的permission。 为了使他们具有访问权限,必须将它们既登录到身份验证器中,又要登录到已被授予访问该特权的人员集中。 如果不满足这些条件之一,则会引发异常:

    def check_permission(self, perm_name, username):
        if not self.authenticator.is_logged_in(username):
            raise NotLoggedInError(username)
        try:
            perm_set = self.permissions[perm_name]
        except KeyError:
            raise PermissionError("Permission does not exist")
        else:
            if username not in perm_set:
                raise NotPermittedError(username)
            else:
                return True

这里有两个新的例外; 它们都使用用户名,因此我们将它们定义为AuthException的子类:

class NotLoggedInError(AuthException):
    pass

class NotPermittedError(AuthException):
    pass

最后,我们可以添加默认的authorizor与我们的默认身份验证器一起使用:

authorizor = Authorizor(authenticator)

从而完成了基本身份验证/授权系统。 我们可以在 Python 提示符下测试系统,检查是否允许用户joe在油漆部门执行任务:

>>> import auth
>>> auth.authenticator.add_user("joe", "joepassword")
>>> auth.authorizor.add_permission("paint")
>>> auth.authorizor.check_permission("paint", "joe")
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
 File "auth.py", line 109, in check_permission
 raise NotLoggedInError(username)
auth.NotLoggedInError: joe
>>> auth.authenticator.is_logged_in("joe")
False
>>> auth.authenticator.login("joe", "joepassword")
True
>>> auth.authorizor.check_permission("paint", "joe")
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
 File "auth.py", line 116, in check_permission
 raise NotPermittedError(username)
auth.NotPermittedError: joe
>>> auth.authorizor.check_permission("mix", "joe")
Traceback (most recent call last):
 File "auth.py", line 111, in check_permission
 perm_set = self.permissions[perm_name]
KeyError: 'mix'

During handling of the above exception, another exception occurred:
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
 File "auth.py", line 113, in check_permission
 raise PermissionError("Permission does not exist")
auth.PermissionError: Permission does not exist
>>> auth.authorizor.permit_user("mix", "joe")
Traceback (most recent call last):
 File "auth.py", line 99, in permit_user
 perm_set = self.permissions[perm_name]
KeyError: 'mix'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
 File "auth.py", line 101, in permit_user
 raise PermissionError("Permission does not exist")
auth.PermissionError: Permission does not exist
>>> auth.authorizor.permit_user("paint", "joe")
>>> auth.authorizor.check_permission("paint", "joe")
True

虽然非常详细,但显示了我们的所有代码和大多数正在使用的异常,但是要真正理解我们定义的 API,我们应该编写一些实际使用它的异常处理代码。 这是一个基本的菜单界面,允许某些用户更改或测试程序:

import auth

# Set up a test user and permission
auth.authenticator.add_user("joe", "joepassword")
auth.authorizor.add_permission("test program")
auth.authorizor.add_permission("change program")
auth.authorizor.permit_user("test program", "joe")

class Editor:
    def __init__(self):
        self.username = None
        self.menu_map = {
                "login": self.login,
                "test": self.test,
                "change": self.change,
                "quit": self.quit
           }

    def login(self):
        logged_in = False
        while not logged_in:
            username = input("username: ")
            password = input("password: ")
            try:
                logged_in = auth.authenticator.login(
                        username, password)
            except auth.InvalidUsername:
                print("Sorry, that username does not exist")
            except auth.InvalidPassword:
                print("Sorry, incorrect password")
            else:
                self.username = username
    def is_permitted(self, permission):
        try:
            auth.authorizor.check_permission(
                permission, self.username)
        except auth.NotLoggedInError as e:
            print("{} is not logged in".format(e.username))
            return False
        except auth.NotPermittedError as e:
            print("{} cannot {}".format(
                e.username, permission))
            return False
        else:
            return True

    def test(self):
        if self.is_permitted("test program"):
            print("Testing program now...")

    def change(self):
        if self.is_permitted("change program"):
            print("Changing program now...")

    def quit(self):
        raise SystemExit()

    def menu(self):
        try:
            answer = ""
            while True:
                print("""
Please enter a command:
\tlogin\tLogin
\ttest\tTest the program
\tchange\tChange the program
\tquit\tQuit
""")
                answer = input("enter a command: ").lower()
                try:
                    func = self.menu_map[answer]
                except KeyError:
                    print("{} is not a valid option".format(
                        answer))
                else:
                    func()
        finally:
            print("Thank you for testing the auth module")

Editor().menu()

这个相当长的示例在概念上非常简单。 is_permitted方法可能是最有趣的; 这是testchange都调用的一种内部方法,以确保在继续操作之前允许用户访问。 当然,这两种方法都是存根,但是我们这里不是在编写编辑器。 我们通过测试身份验证和授权框架来说明异常和异常处理程序的使用

Case study

Case study

Case study

六、何时使用面向对象的编程

在前面的章节中,我们介绍了面向对象编程的许多定义功能。 现在,我们了解了面向对象设计的原理和范例,并且介绍了 Python 中面向对象编程的语法。

但是,我们并不确切地知道如何以及何时在实践中利用这些原理和语法。 在本章中,我们将讨论所获得知识的一些有用应用,并逐步提出一些新主题:

  • 如何识别物体
  • 数据和行为,再一次
  • 使用属性将数据包装为行为
  • 使用行为限制数据
  • 不要重复自己的原则
  • 识别重复的代码

将对象视为对象

这似乎很明显。 通常,应该在问题域中为单独的对象提供代码中的特殊类。 在前几章的案例研究中,我们已经看到了这样的例子。 首先,我们确定问题中的对象,然后对它们的数据和行为进行建模。

在面向对象的分析和编程中,识别对象是一项非常重要的任务。 但这并不总是像我们一直在做的那样简单地在短段中计算名词一样容易。 记住,对象是既具有数据又具有行为的事物。 如果仅处理数据,通常最好将其存储在列表,集合,字典或其他 Python 数据结构中(我们将在第 6 章,“Python 数据结构”中进行全面介绍)。 另一方面,如果我们仅处理行为,而不处理存储的数据,则更简单的功能是更合适的。

但是,对象既具有数据又具有行为。 精通 Python 的程序员使用内置的数据结构,除非(或直到)显然需要定义一个类。 如果没有帮助组织我们的代码,则没有理由添加额外的抽象级别。 另一方面,“显而易见的”需求并不总是不言而喻的。

我们通常可以通过将数据存储在几个变量中来启动 Python 程序。 随着程序的扩展,我们以后会发现我们正在将相同的一组相关变量传递给一组函数。 现在是时候考虑将变量和函数都分组到一个类中了。 如果我们要设计一个在二维空间中对多边形建模的程序,则可以从将每个多边形表示为点列表开始。 这些点将被建模为两个元组(xy),以描述该点的位置。 这是所有数据,存储在一组嵌套数据结构(特别是元组列表)中:

square = [(1,1), (1,2), (2,2), (2,1)]

现在,如果我们要计算多边形周围的距离,我们只需要对两点之间的距离求和即可。 为此,我们还需要一个函数来计算两点之间的距离。 这是两个这样的功能:

import math

def distance(p1, p2):
    return math.sqrt((p1[0]-p2[0])**2 + (p1[1]-p2[1])**2)

def perimeter(polygon):
    perimeter = 0
    points = polygon + [polygon[0]]
    for i in range(len(polygon)):
        perimeter += distance(points[i], points[i+1])
    return perimeter

现在,作为面向对象的程序员,我们清楚地认识到polygon类可以封装点列表(数据)和perimeter函数(行为)。 此外,point类(例如我们在第 2 章,Python 中的对象中定义的)可能封装了xy坐标以及distance方法。 问题是:这样做有价值吗?

对于先前的代码,也许是,也许不是。 凭借我们在面向对象原理方面的最新经验,我们可以在记录时间内编写一个面向对象的版本。 让我们比较一下

import math

class Point:
 def __init__(self, x, y):
 self.x = x
 self.y = y

    def distance(self, p2):
        return math.sqrt((self.x-p2.x)**2 + (self.y-p2.y)**2)

class Polygon:
 def __init__(self):
 self.vertices = []

 def add_point(self, point):
 self.vertices.append((point))

    def perimeter(self):
        perimeter = 0
        points = self.vertices + [self.vertices[0]]
        for i in range(len(self.vertices)):
            perimeter += points[i].distance(points[i+1])
        return perimeter

从突出显示的部分可以看出,这里的代码是早期版本的两倍,尽管我们可以认为add_point方法不是严格必需的。

现在,为了更好地理解这些差异,让我们比较两个使用中的 API。 以下是使用面向对象的代码计算正方形的周长的方法:

>>> square = Polygon()
>>> square.add_point(Point(1,1))
>>> square.add_point(Point(1,2))
>>> square.add_point(Point(2,2))
>>> square.add_point(Point(2,1))
>>> square.perimeter()
4.0

您可能会认为这相当简洁且易于阅读,但让我们将其与基于函数的代码进行比较:

>>> square = [(1,1), (1,2), (2,2), (2,1)]
>>> perimeter(square)
4.0

嗯,也许面向对象的 API 并不是那么紧凑! 就是说,我认为更容易读懂函数示例:我们如何知道第二个版本中的元组列表应该代表什么? 我们应该如何记住应该传递给perimeter函数的哪种对象(两个元组的列表?这不直观!)? 我们将需要大量文档来解释如何使用这些功能。

相反,面向对象的代码是相对自记录的,我们只需要查看方法及其参数的列表即可了解对象的功能以及如何使用它。 到我们编写功能版本的所有文档时,它可能比面向对象的代码还要长。

最后,代码长度不是代码复杂度的良好指标。 一些程序员迷上了复杂的“单一代码”,它们在一行代码中完成了大量工作。 这可能是一个有趣的练习,但是结果通常是难以理解的,即使是第二天的原始作者也是如此。 减少代码量通常可以使程序更易于阅读,但不要盲目地认为是这种情况。

幸运的是,这种折衷是不必要的。 我们可以使面向对象的Polygon API 与功能实现一样易于使用。 我们要做的就是更改Polygon类,以便可以用多个点构造它。 让我们给它一个初始化器,它接受Point对象的列表。 实际上,让我们也允许它接受元组,如果需要,我们可以自己构造Point对象:

    def __init__(self, points=None):
        points = points if points else []
        self.vertices = []
        for point in points:
            if isinstance(point, tuple):
                point = Point(*point)
            self.vertices.append(point)

该初始化程序将遍历列表,并确保将任何元组都转换为点。 如果该对象不是元组,则假定它已经是Point对象,或者可以充当Point对象的未知鸭子类型对象,则将其保留不变。

但是,此代码的面向对象版本和面向数据版本之间并没有明显的赢家。 他们俩都做同样的事情。 如果我们有接受多边形参数的新函数,例如area(polygon)point_in_polygon(polygon, x, y),则面向对象代码的好处将变得越来越明显。 同样,如果将其他属性(例如colortexture)添加到多边形,则将数据封装到单个类中变得越来越有意义。

区别是一项设计决策,但通常,一组数据越复杂,它具有针对该数据的多个功能的可能性就越大,并且使用具有属性和 方法代替。

在做出此决定时,还需要考虑如何使用该类。 如果我们只是试图在一个更大的问题的背景下计算一个多边形的周长,那么使用一个函数可能会最快地编写代码,并且更容易使用“仅一次”。 另一方面,如果我们的程序需要以多种方式操纵多个多边形(计算周长,面积,与其他多边形的相交,移动或缩放等),则我们肯定可以确定一个对象。 需要非常通用的一种。

此外,请注意对象之间的交互。 寻找继承关系; 没有类就无法优雅地进行继承建模,因此请确保使用它们。 寻找我们在第 1 章,“面向对象设计”,关联和组合中讨论的其他类型的关系。 从技术上讲,可以仅使用数据结构对构成进行建模; 例如,我们可以有一个保存元组值的字典列表,但是创建一些对象类通常不那么复杂,尤其是当存在与数据相关联的行为时。

注意

不要仅仅因为可以使用一个对象而急于使用一个对象,但是当需要使用一个类时,绝不会忽略创建一个类。

为具有属性的类数据添加行为

在整个模块中,我们一直专注于行为和数据的分离。 这在面向对象的编程中非常重要,但是我们将看到,在 Python 中,这种区别可能会变得非常模糊。 Python 非常擅长模糊区分。 它并不能完全帮助我们“跳出框框思考”。 相反,它教会我们停止思考盒子。

在深入探讨细节之前,让我们讨论一些糟糕的面向对象理论。 许多面向对象的语言(Java 最臭名昭著)告诉我们永远不要直接访问属性。 他们坚持要求我们这样写属性访问:

class Color:
    def __init__(self, rgb_value, name):
        self._rgb_value = rgb_value
        self._name = name

    def set_name(self, name):
        self._name = name

    def get_name(self):
        return self._name

变量前面带有下划线,表示它们是私有的(其他语言实际上会强制它们私有)。 然后,get 和 set 方法提供对每个变量的访问。 该类将在实践中如下使用:

>>> c = Color("#ff0000", "bright red")
>>> c.get_name()
'bright red'
>>> c.set_name("red")
>>> c.get_name()
'red'

它不像 Python 支持的直接访问版本那样可读:

class Color:
    def __init__(self, rgb_value, name):
        self.rgb_value = rgb_value
        self.name = name

c = Color("#ff0000", "bright red")
print(c.name)
c.name = "red"

那么,为什么有人会坚持基于方法的语法呢? 他们的理由是,有一天我们可能想在设置或检索值时添加额外的代码。 例如,我们可以决定缓存一个值并返回缓存的值,或者我们可能想验证该值是否是合适的输入。

在代码中,我们可以决定更改set_name()方法,如下所示:

def set_name(self, name):
    if not name:
        raise Exception("Invalid Name")
    self._name = name

现在,在 Java 和类似语言中,如果我们编写了原始代码来进行直接属性访问,然后又将其更改为类似于上一个方法,则我们将遇到一个问题:任何编写过以下代码的人 直接访问属性现在将必须访问该方法。 如果他们没有将访问样式从属性访问更改为函数调用,则其代码将被破坏。 这些语言的口头禅是,我们绝不应将公共成员设为私人。 在 Python 中这没有多大意义,因为没有任何真正的私有成员概念!

Python 为我们提供了property关键字,以使方法看起来像属性。 因此,我们可以编写代码以使用直接成员访问,并且如果我们意外地需要更改实现以在获取或设置该属性的值时进行一些计算,则可以在不更改接口的情况下进行操作。 让我们看看它的外观:

class Color:
    def __init__(self, rgb_value, name):
        self.rgb_value = rgb_value
        self._name = name

    def _set_name(self, name):
        if not name:
            raise Exception("Invalid Name")
        self._name = name

    def _get_name(self):
        return self._name

 name = property(_get_name, _set_name)

如果我们从较早的基于非方法的类开始,该类直接设置了name属性,则我们以后可以将代码更改为类似于前面的代码。 我们首先将name属性更改为(半)私有_name属性。 然后,我们再添加两个(半)私有方法来获取并设置该变量,并在设置变量时进行验证。

最后,我们在底部有property声明。 这是魔术。 它在Color类上创建了一个名为name的新属性,该属性现在替换了先前的name属性。 它将这个属性设置为一个属性,只要访问或更改属性,它就会调用我们刚刚创建的两个方法。 可以使用与先前版本完全相同的方式来使用Color类的新版本,但是现在我们在设置name属性时可以进行验证:

>>> c = Color("#0000ff", "bright red")
>>> print(c.name)
bright red
>>> c.name = "red"
>>> print(c.name)
red
>>> c.name = ""
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
 File "setting_name_property.py", line 8, in _set_name
 raise Exception("Invalid Name")
Exception: Invalid Name

因此,如果我们先前编写了代码来访问name属性,然后将其更改为使用我们的property对象,则除非发送空的property值,否则先前的代码仍然可以使用。 我们首先要禁止的行为。 成功!

请记住,即使使用name属性,以前的代码也不是 100%安全的。 人们仍然可以直接访问_name属性,并根据需要将其设置为空字符串。 但是,如果他们访问一个变量,我们已经在其中明确标记了下划线以表明该变量是私有的,那么他们就是那些必须处理后果的人,而不是我们。

详细属性

property函数视为返回一个对象,该对象可以通过我们指定的方法代理任何设置或访问属性值的请求。 property关键字类似于此类对象的构造函数,并且该对象设置为给定属性的面向公众的成员。

property构造函数实际上可以接受两个附加参数,即删除函数和该属性的文档字符串。 实际上,delete函数很少提供,但对于记录已删除的值或在我们有理由这样做时可以否决删除的记录很有用。 docstring 只是描述属性作用的字符串,与我们在第 2 章和 Python 中的对象中讨论的 docstring 相同。 如果我们不提供此参数,则将从第一个参数的文档字符串中复制文档字符串:getter 方法。 这是一个愚蠢的示例,仅在任何方法被调用时简单声明:

class Silly:
    def _get_silly(self):
        print("You are getting silly")
        return self._silly
    def _set_silly(self, value):
        print("You are making silly {}".format(value))
        self._silly = value
    def _del_silly(self):
        print("Whoah, you killed silly!")
        del self._silly

    silly = property(_get_silly, _set_silly,
            _del_silly, "This is a silly property")

如果我们实际使用此类,则当我们要求时,它确实会打印出正确的字符串:

>>> s = Silly()
>>> s.silly = "funny"
You are making silly funny
>>> s.silly
You are getting silly
'funny'
>>> del s.silly
Whoah, you killed silly!

此外,如果我们查看Silly类的帮助文件(通过在解释器提示符处发出help(silly)),它将为我们显示silly属性的自定义文档字符串:

Help on class Silly in module __main__:

class Silly(builtins.object)
 |  Data descriptors defined here:
 |  
 |  __dict__
 |      dictionary for instance variables (if defined)
 |  
 |  __weakref__
 |      list of weak references to the object (if defined)
 |  
 |  silly
 |      This is a silly property

再一次,一切都按我们的计划进行。 实际上,通常只使用前两个参数来定义属性:getter 和 setter 函数。 如果要为属性提供文档字符串,可以在 getter 函数上定义它; 属性代理会将其复制到自己的文档字符串中。 删除功能通常留空,因为对象属性很少被删除。 如果编码人员确实尝试删除未指定删除功能的属性,则会引发异常。 因此,如果有正当理由删除我们的财产,我们应该提供该功能。

装饰器–创建属性的另一种方法

如果您以前从未使用过 Python 装饰器,那么在第 10 章和 Python 设计模式 I 中讨论装饰器模式之后,您可能希望跳过本节并返回。。 但是,您无需了解使用装饰器语法使属性方法更具可读性的情况。

属性函数可以与装饰器语法一起使用,以将 get 函数转换为属性:

class Foo:
    @property
    def foo(self):
        return "bar"

这将property函数用作装饰器,并且等效于先前的foo = property(foo)语法。 从可读性的角度来看,主要区别在于我们可以将foo函数标记为方法顶部的属性,而不是在定义之后将其容易忽略的属性。 这也意味着我们不必仅使用下划线前缀创建私有方法来定义属性。

更进一步,我们可以为新属性指定一个 setter 函数,如下所示:

class Foo:
    @property
    def foo(self):
        return self._foo

    @foo.setter
    def foo(self, value):
        self._foo = value

尽管意图很明显,但此语法看起来很奇怪。 首先,我们将foo方法修饰为吸气剂。 然后,通过应用最初装饰的foo方法的setter属性,装饰具有完全相同名称的第二种方法! property函数返回一个对象; 该对象始终带有自己的setter属性,然后可以将其用作装饰器以使用其他功能。 不需要为 get 和 set 方法使用相同的名称,但这确实有助于将访问一个属性的多个方法组合在一起。

我们还可以使用@foo.deleter指定删除功能。 我们无法使用property装饰器指定文档字符串,因此我们需要依赖于从初始 getter 方法复制文档字符串的属性。

这是我们先前的Silly类,重写为使用property作为装饰器:

class Silly:
    @property
    def silly(self):
        "This is a silly property"
        print("You are getting silly")
        return self._silly

    @silly.setter
    def silly(self, value):
        print("You are making silly {}".format(value))
        self._silly = value

    @silly.deleter
    def silly(self):
        print("Whoah, you killed silly!")
        del self._silly

此类的操作与我们的早期版本完全相同,包括帮助文本。 您可以使用任何感觉更易读和优雅的语法。

决定何时使用属性

内置的属性使行为和数据之间的划分变得模糊不清,从而使您难以选择哪个。 我们前面看到的示例用例是属性的最常见用法之一。 我们在某个类上有一些数据,稍后我们要向其添加行为。 在决定使用物业时,还需要考虑其他因素。

从技术上讲,在 Python 中,数据,属性和方法都是类的属性。 方法是可调用的这一事实并未将其与其他类型的属性区分开; 确实,我们将在第 7 章和 Python 面向对象的快捷方式中看到,可以创建可以像函数一样调用的普通对象。 我们还将发现函数和方法本身就是普通的对象。

方法只是可调用的属性,而属性只是可定制的属性,这一事实可以帮助我们做出此决定。 方法通常应代表行动; 可以对对象执行或由对象执行的事情。 当调用一个方法时,即使只有一个参数,它也应该。 方法名称通常是动词。

确认属性不是动作后,我们需要在标准数据属性和属性之间做出决定。 通常,请始终使用标准属性,直到您需要以某种方式控制对该属性的访问。 无论哪种情况,您的属性通常都是一个名词。 属性和属性之间的唯一区别是,当检索,设置或删除属性时,我们可以自动调用自定义操作。

让我们看一个更现实的例子。 自定义行为的常见需求是缓存一个难以计算或查找成本很高的值(例如,需要网络请求或数据库查询)。 目标是将值存储在本地,以避免重复调用昂贵的计算。

我们可以通过在属性上使用自定义 getter 来做到这一点。 第一次检索该值时,我们执行查找或计算。 然后,我们可以将值作为对象的私有属性在本地缓存(或在专用缓存软件中),并且下次请求该值时,我们将返回存储的数据。 这是我们缓存网页的方法:

from urllib.request import urlopen

class WebPage:
    def __init__(self, url):
        self.url = url
        self._content = None

    @property
    def content(self):
        if not self._content:
            print("Retrieving New Page...")
            self._content = urlopen(self.url).read()
        return self._content

我们可以测试此代码以查看页面仅被检索一次:

>>> import time
>>> webpage = WebPage("http://ccphillips.net/")
>>> now = time.time()
>>> content1 = webpage.content
Retrieving New Page...
>>> time.time() - now
22.43316888809204
>>> now = time.time()
>>> content2 = webpage.content
>>> time.time() - now
1.9266459941864014
>>> content2 == content1
True

最初测试此代码时,我处于糟糕的卫星连接中,并且第一次加载内容时花了 20 秒。 第二次,我在 2 秒内得到了结果(这实际上只是将行输入到解释器中所花费的时间)。

自定义获取器对于基于其他对象属性需要动态计算的属性也很有用。 例如,我们可能要计算整数列表的平均值:

class AverageList(list):
    @property
    def average(self):
        return sum(self) / len(self)

这个非常简单的类继承自list,因此我们免费获得类似列表的行为。 我们只向类添加一个属性,然后,我们的列表可以具有平均值:

>>> a = AverageList([1,2,3,4])
>>> a.average
2.5

当然,我们可以使它成为方法,但是由于方法表示动作,因此应将其命名为calculate_average()。 但是称为average的属性更合适,既易于键入,又易于阅读。

正如我们已经看到的,自定义设置器对很有用,可用于验证,但是它们也可以用于将值代理到另一个位置。 例如,我们可以在WebPage类中添加一个内容设置器,该设置器将自动登录到我们的 Web 服务器并在设置该值时上传一个新页面。

管理器对象

我们一直专注于对象及其属性和方法。 现在,我们来看看设计更高级别的对象:管理其他对象的对象的类型。 将所有东西绑在一起的物体。

这些对象与到目前为止我们看到的大多数示例之间的区别在于,我们的示例倾向于代表具体的思想。 管理对象更像是办公室经理。 他们不会在地板上进行实际的“可见”工作,但是如果没有他们,部门之间将无法进行沟通,也没人会知道他们应该做什么(尽管如果组织非常糟糕,无论如何这都是正确的) 管理!)。 类似地,管理类上的属性倾向于引用其他做“可见”工作的对象; 这样一个类的行为会在适当的时候委托给其他类,并在它们之间传递消息。

作为示例,我们将编写一个程序,对存储在压缩 ZIP 文件中的文本文件执行查找和替换操作。 我们需要对象来表示 ZIP 文件和每个单独的文本文件(幸运的是,我们不必编写这些类,它们在 Python 标准库中可用)。 manager 对象将负责确保按顺序执行三个步骤:

  1. 解压缩压缩文件。
  2. 执行查找和替换操作。
  3. 压缩新文件。

该类使用.zip文件名初始化,然后搜索并替换字符串。 我们创建一个临时目录来存储解压缩的文件,以便该文件夹保持干净。 Python 3.4 pathlib库可帮助处理文件和目录。 我们将在第 8 章,“字符串和序列化”中了解更多有关该内容的信息,但是在以下示例中,该接口应该非常清楚:

import sys
import shutil
import zipfile
from pathlib import Path

class ZipReplace:
    def __init__(self, filename, search_string, replace_string):
        self.filename = filename
        self.search_string = search_string
        self.replace_string = replace_string
        self.temp_directory = Path("unzipped-{}".format(
                filename))

然后,我们为三个步骤中的每个步骤创建一个整体的“经理”方法。 此方法将责任委托给其他方法。 显然,我们可以在一个方法中,甚至在一个脚本中完成所有三个步骤,而无需创建对象。 分离这三个步骤有几个优点:

  • 可读性:每个步骤的代码均位于一个易于阅读和理解的独立单元中。 方法名称描述了该方法的作用,并且需要较少的其他文档来了解所发生的情况。
  • 可扩展性:如果子类希望使用压缩的 TAR 文件而不是 ZIP 文件,则它可以覆盖zipunzip方法,而不必重复find_replace方法。
  • 分区:外部类可以创建此类的实例,然后直接在某个文件夹上调用find_replace方法,而无需zip内容。

委托方法是以下代码中的第一个; 为了完整起见,包括了其余方法:

    def zip_find_replace(self):
        self.unzip_files()
        self.find_replace()
        self.zip_files()

    def unzip_files(self):
        self.temp_directory.mkdir()
        with zipfile.ZipFile(self.filename) as zip:
            zip.extractall(str(self.temp_directory))

    def find_replace(self):
        for filename in self.temp_directory.iterdir():
            with filename.open() as file:
                contents = file.read()
            contents = contents.replace(
                    self.search_string, self.replace_string)
            with filename.open("w") as file:
                file.write(contents)

    def zip_files(self):
        with zipfile.ZipFile(self.filename, 'w') as file:
            for filename in self.temp_directory.iterdir():
                file.write(str(filename), filename.name)
        shutil.rmtree(str(self.temp_directory))

if __name__ == "__main__":
    ZipReplace(*sys.argv[1:4]).zip_find_replace()

为简便起见,稀疏地记录了用于压缩和解压缩文件的代码。 我们目前的重点是面向对象的设计。 如果您对zipfile模块的内部细节感兴趣,请在线或通过在交互式解释器中键入import zipfile ; help(zipfile)来参考标准库中的文档。 请注意,此示例仅搜索 ZIP 文件中的顶级文件。 如果解压缩后的内容中包含任何文件夹,则将不会对其进行扫描,也不会扫描这些文件夹中的任何文件。

该示例的最后两行允许我们通过传递zip文件名,搜索字符串并将替换字符串作为参数来从命令行运行程序:

python zipsearch.py hello.zip hello hi

当然,不必从命令行创建该对象。 它可以从另一个模块导入(以执行批处理 ZIP 文件),也可以作为 GUI 界面的一部分访问,甚至可以作为更高级的管理对象来访问,该对象知道从何处获取 ZIP 文件(例如,从 FTP 服务器或 将它们备份到外部磁盘)。

随着程序变得越来越复杂,被建模的对象越来越像物理对象。 属性是其他抽象对象,方法是更改​​这些抽象对象状态的动作。 但是,无论多么复杂,每个对象的核心都是一组具体的属性和定义明确的行为。

删除重复的代码

通常,诸如ZipReplace之类的管理样式类中的代码都是非常通用的,可以通过多种方式应用。 可以使用组合或继承来帮助将此代码保存在一个地方,从而消除重复的代码。 在查看任何此类示例之前,我们先讨论一些理论。 具体来说,为什么重复代码是一件坏事?

有多种原因,但是它们全都归结为可读性和可维护性。 当我们编写与以前的代码相似的新代码时,最简单的操作是复制旧代码并更改需要更改的内容(变量名,逻辑,注释)以使其在新代码中起作用 地点。 或者,如果我们正在编写看起来与项目中其他地方的代码相似但不相同的新代码,则通常更容易编写具有类似行为的新代码,而不是弄清楚如何提取重叠的功能。

但是,一旦有人必须阅读并理解代码,并且遇到重复的块,就会面临困境。 必须突然理解可能有意义的代码。 一个部分与另一个部分有何不同? 它们如何相同? 在什么条件下称为一节? 我们什么时候打给对方? 您可能会争辩说,您是唯一阅读代码的人,但是如果您八个月不接触该代码,对于您来说,就像对新编码员一样,您将难以理解。 当我们尝试阅读两个相似的代码片段时,我们必须了解它们为什么不同,以及它们如何不同。 这浪费了读者的时间。 代码应始终被编写为可读性强。

注意

我曾经不得不尝试理解某人的代码,这些代码具有相同的 300 行非常糟糕的代码的三个完全相同的副本。 在我终于理解这三个“相同”版本实际上执行稍有不同的税收计算之前,我已经使用了一个月的代码。 某些细微的差异是有意为之的,但在某些明显的领域中,某人已在一个函数中更新了计算而未更新其他两个函数。 无法计算代码中微妙的,难以理解的错误的数量。 我最终将所有 900 行替换为 20 行左右的易于阅读的功能。

读取这样的重复代码可能会很麻烦,但是代码维护更加痛苦。 如前所述,将两个相似的代码保持最新可能是一场噩梦。 我们必须记住每当更新两个部分时都要更新两个部分,并且必须记住多个部分的不同之处,以便在编辑每个部分时可以修改更改。 如果我们忘记更新这两个部分,最终将导致极其烦人的错误,这些错误通常会表现为:“但是我已经解决了这一问题,为什么它仍然会发生?”

结果是,与我们最初以非重复的方式编写代码相比,正在阅读或维护我们的代码的人们必须花费大量的时间来理解和测试它。 当我们进行维护时,这更加令人沮丧。 我们发现自己在说:“为什么我第一次没有这样做呢?” 通过复制粘贴现有代码节省的时间在我们第一次维护它时就丢失了。 与被编写的代码相比,代码被读取和修改的次数更多,并且更改的频率也更高。 易懂的代码应始终至关重要。

这就是为什么程序员,尤其是 Python 程序员(倾向于重视优雅代码而不是平均水平)遵循不要重复自己DRY)原理的原因。 DRY 代码是可维护的代码。 我对初学者的建议是不要使用其编辑器的复制和粘贴功能。 对于中级程序员,我建议他们在按 Ctrl +C之前,应该考虑三次。

但是我们应该做什么而不是代码重复呢? 最简单的解决方案通常是将代码移到接受参数的函数中,以说明各个部分是否不同。 这不是一个非常面向对象的解决方案,但是它通常是最佳的。

例如,如果我们有两段代码将一个 ZIP 文件解压缩到两个不同的目录中,那么我们可以轻松地编写一个函数,该函数接受应该将其解压缩到的目录的参数。 这可能会使函数本身更难以阅读,但是一个好的函数名和文档字符串可以轻松地弥补这一点,并且调用该函数的任何代码都将更易于阅读。

这当然够理论了! 这个故事的寓意是:始终努力将代码重构为易于阅读,而不是编写仅易于编写的不良代码。

实践中

让我们探索两种可以重用现有代码的方式。 在写完代码以替换包含文本文件的 ZIP 文件中的字符串后,我们后来签订了将 ZIP 文件中的所有图像缩放到 640 x 480 的合同。看起来我们可以使用与ZipReplace。 第一个冲动可能是保存该文件的副本,然后将find_replace方法更改为scale_image或类似的方法。

但是,那太酷了。 如果有一天我们想更改unzipzip方法以也打开 TAR 文件怎么办? 也许我们想为临时文件使用一个保证唯一的目录名。 无论哪种情况,我们都必须在两个不同的地方进行更改!

我们将首先说明该问题的基于继承的解决方案。 首先,我们将我们的原始ZipReplace类修改为用于处理常规 ZIP 文件的超类:

import os
import shutil
import zipfile
from pathlib import Path

class ZipProcessor:
    def __init__(self, zipname):
        self.zipname = zipname
        self.temp_directory = Path("unzipped-{}".format(
                zipname[:-4]))

    def process_zip(self):
        self.unzip_files()
        self.process_files()
        self.zip_files()

    def unzip_files(self):
        self.temp_directory.mkdir()
        with zipfile.ZipFile(self.zipname) as zip:
            zip.extractall(str(self.temp_directory))

    def zip_files(self):
        with zipfile.ZipFile(self.zipname, 'w') as file:
            for filename in self.temp_directory.iterdir():
                file.write(str(filename), filename.name)
        shutil.rmtree(str(self.temp_directory))

我们将的filename属性更改为zipname,以避免与各种方法中的filename局部变量混淆。 即使实际上不是设计更改,这也有助于使代码更具可读性。

我们还将ZipReplace特有的两个参数分别丢给了__init__search_stringreplace_string)。 然后,我们将zip_find_replace方法重命名为process_zip,并使其(尚未定义)称为process_files方法而不是find_replace; 这些名称的更改有助于证明我们新班级的更普遍的性质。 注意,我们已经完全删除了find_replace方法; 该代码特定于ZipReplace,在这里没有业务。

这个新的ZipProcessor类实际上没有定义process_files方法; 因此,如果我们直接运行它,它将引发异常。 因为它不打算直接运行,所以我们删除了原始脚本底部的 main 调用。

现在,在继续进行图像处理应用之前,让我们修复原始的zipsearch类以使用此父类:

from zip_processor import ZipProcessor
import sys
import os

class ZipReplace(ZipProcessor):
    def __init__(self, filename, search_string,
            replace_string):
        super().__init__(filename)
        self.search_string = search_string
        self.replace_string = replace_string

    def process_files(self):
        '''perform a search and replace on all files in the
        temporary directory'''
        for filename in self.temp_directory.iterdir():
            with filename.open() as file:
                contents = file.read()
            contents = contents.replace(
                    self.search_string, self.replace_string)
            with filename.open("w") as file:
                file.write(contents)

if __name__ == "__main__":
    ZipReplace(*sys.argv[1:4]).process_zip()

该代码比原始版本短,因为它从父类继承了其 ZIP 处理功能。 我们首先导入刚刚编写的基类,然后使ZipReplace扩展该类。 然后,我们使用super()初始化父类。 find_replace方法仍然在这里,但是我们将其重命名为process_files,以便父类可以从其管理界面调用它。 由于此名称不像旧名称那样具有描述性,因此我们添加了一个文档字符串来描述其功能。

现在,考虑到我们现在所拥有的只是一个功能上与我们开始时没有区别的程序,这是相当多的工作! 但是完成这项工作后,现在我们可以轻松编写在 ZIP 存档中的文件上运行的其他类,例如(假设要求的)照片缩放器。 此外,如果我们想改善或错误修复 zip 功能,我们可以通过仅更改一个ZipProcessor基类来对所有类都做到这一点。 维护将更加有效。

看看现在创建利用ZipProcessor功能的照片缩放类非常简单。 (注意:此类需要第三方pillow库来获取PIL模块。您可以使用pip install pillow安装它。)

from zip_processor import ZipProcessor
import sys
from PIL import Image

class ScaleZip(ZipProcessor):

    def process_files(self):
        '''Scale each image in the directory to 640x480'''
        for filename in self.temp_directory.iterdir():
            im = Image.open(str(filename))
            scaled = im.resize((640, 480))
            scaled.save(str(filename))

if __name__ == "__main__":
    ScaleZip(*sys.argv[1:4]).process_zip()

看看这个课程有多简单! 我们之前所做的所有工作都得到了回报。 我们要做的就是打开每个文件(假设它是一个图像;如果无法打开文件,它将毫无意外地崩溃),缩放它并保存回去。 ZipProcessor类负责压缩和解压缩,而无需我们做任何额外的工作。

案例研究

对于本案例,我们将尝试进一步探讨以下问题:“何时选择对象还是内置类型?” 我们将为可能在文本编辑器或文字处理器中使用的Document类建模。 它应该具有哪些对象,功能或属性?

我们可能从Document内容的str开始,但是在 Python 中,字符串是不可变的(可以更改)。 一旦定义了str,它将永远存在。 如果不创建全新的字符串对象,则无法在其中插入或删除字符。 那会留下很多str对象占用内存,直到 Python 的垃圾收集器认为适合清除我们的内存为止。

因此,我们将使用一个字符列表,而不是字符串,可以随意对其进行修改。 另外,Document类将需要知道列表中的当前光标位置,并且可能还应该存储文档的文件名。

注意

实际文本编辑器使用称为rope的基于二叉树的数据结构来建模其文档内容。 该模块的标题不是“高级数据结构”,因此,如果您想了解更多有关此有趣主题的信息,则可能需要在网上搜索绳索数据结构。

现在,它应该有什么方法? 我们可能想对文本文档做很多事情,包括插入,删除和选择字符,剪切,复制,粘贴,选择以及保存或关闭文档。 看起来数据和行为都很多,因此将所有这些内容放入自己的Document类是有意义的。

一个相关的问题是:此类是否应该由一堆基本的 Python 对象组成,例如str文件名,int光标位置和list字符? 还是其中的某些或全部本身就是专门定义的对象? 个别的线条和字符呢,他们需要自己的类吗?

我们将在回答这些问题的同时,首先让我们从最简单的Document类开始,然后看看它可以做什么:

class Document:
    def __init__(self):
        self.characters = []
        self.cursor = 0
        self.filename = ''

    def insert(self, character):
        self.characters.insert(self.cursor, character)
        self.cursor += 1

    def delete(self):
        del self.characters[self.cursor]

    def save(self):
        with open(self.filename, 'w') as f:
            f.write(''.join(self.characters))

    def forward(self):
        self.cursor += 1

    def back(self):
        self.cursor -= 1

这个简单的类使我们可以完全控制编辑基本文档。 看一下它的作用:

>>> doc = Document()
>>> doc.filename = "test_document"
>>> doc.insert('h')
>>> doc.insert('e')
>>> doc.insert('l')
>>> doc.insert('l')
>>> doc.insert('o')
>>> "".join(doc.characters)
'hello'
>>> doc.back()
>>> doc.delete()
>>> doc.insert('p')
>>> "".join(doc.characters)
'hellp'

看起来正在运作。 我们可以将键盘的字母和箭头键连接到这些方法,并且文档可以很好地跟踪所有内容。

但是,如果我们不仅要连接箭头键,该怎么办。 如果我们也想连接 HomeEnd 键,该怎么办? 我们可以向Document类添加更多方法来向前或向后搜索字符串中的换行符(在 Python 中为换行符,或\n代表一行的末尾和新行的开头),然后跳转 给他们,但是如果我们针对所有可能的移动动作(逐字移动,逐句移动,向上翻页向下翻页,行尾,空格开头等等)进行操作 ),该课程将非常庞大。 将这些方法放在单独的对象上也许会更好。 因此,让我们将 cursor 属性变成一个知道其位置并可以操纵该位置的对象。 我们可以将前进和后退方法移至该类,并为 HomeEnd 键添加更多:

class Cursor:
    def __init__(self, document):
        self.document = document
        self.position = 0

    def forward(self):
        self.position += 1

    def back(self):
        self.position -= 1

    def home(self):
        while self.document.characters[
                self.position-1] != '\n':
            self.position -= 1
            if self.position == 0:
                # Got to beginning of file before newline
                break

    def end(self):
        while self.position < len(self.document.characters
                ) and self.document.characters[
                    self.position] != '\n':
            self.position += 1

此类将文档作为初始化参数,因此这些方法可以访问文档字符列表的内容。 然后,它提供了简单的方法来像以前一样向前和向后移动,以及移动到homeend位置。

注意

此代码不是很安全。 您可以轻松地移至结束位置,如果尝试返回一个空文件,它将崩溃。 这些示例简短易读,但并不代表防御性! 您可以通过练习来改进此代码的错误检查。 这可能是扩展您的异常处理技能的绝佳机会。

Document类本身几乎没有更改,除了删除了移到Cursor类的两个方法:

class Document:
    def __init__(self):
        self.characters = []
        self.cursor = Cursor(self)
        self.filename = ''

       def insert(self, character):
        self.characters.insert(self.cursor.position,
                character)
        self.cursor.forward()

    def delete(self):
        del self.characters[self.cursor.position]

    def save(self):
        f = open(self.filename, 'w')
        f.write(''.join(self.characters))
        f.close()

我们只是将访问旧游标整数的所有内容更新为使用新对象。 我们可以测试home方法是否真的移至换行符:

>>> d = Document()
>>> d.insert('h')
>>> d.insert('e')
>>> d.insert('l')
>>> d.insert('l')
>>> d.insert('o')
>>> d.insert('\n')
>>> d.insert('w')
>>> d.insert('o')
>>> d.insert('r')
>>> d.insert('l')
>>> d.insert('d')
>>> d.cursor.home()
>>> d.insert("*")
>>> print("".join(d.characters))
hello
*world

现在,由于经常使用该字符串join函数(以连接字符,以便我们可以看到实际的文档内容),因此可以向Document类添加属性以提供完整的信息 细绳:

    @property
    def string(self):
        return "".join(self.characters)

这使我们的测试更加简单:

>>> print(d.string)
hello
world

这个框架很简单(尽管可能会很费时!),可以扩展以创建和编辑完整的纯文本文档。 现在,让我们将其扩展为适用于富文本格式; 可以使用粗体,带下划线或斜体字符的文本。

我们可以通过两种方式处理此问题; 第一种是将“伪”字符插入我们的字符列表中,这些字符的行为类似于指令,例如“粗体字符,直到找到停止的粗体字符”。 第二种是向每个字符添加信息,以指示其应采用的格式。 尽管前一种方法可能更常见,但我们将实现后一种解决方案。 为此,我们显然需要一个字符类。 此类将具有表示字符的属性,以及三个表示粗体,斜体或带下划线的布尔属性。

嗯,等等! 这个Character类将具有任何方法吗? 如果没有,也许我们应该使用许多 Python 数据结构之一; 一个元组或命名元组可能就足够了。 我们要对角色执行或对角色调用任何动作吗?

好吧,很明显,我们可能想对字符进行处理,例如删除或复制它们,但是这些是需要在Document级别处理的事情,因为它们实际上是在修改字符列表。 是否需要对单个角色执行某些操作?

实际上,现在我们正在考虑Character类实际上是什么...这是什么? 可以肯定地说Character类是字符串吗? 也许我们应该在这里使用继承关系? 然后,我们可以利用str实例随附的众多方法。

我们在谈论什么样的方法? 有startswithstripfindlower等。 这些方法中的大多数都希望对包含多个字符的字符串起作用。 相反,如果Characterstr的子类,那么如果提供了多字符字符串,我们最好重写__init__以引发异常。 因为我们免费获得的所有这些方法实际上都不会应用于我们的Character类,所以毕竟我们似乎不需要使用继承。

这使我们回到了最初的问题; Character应该是一类吗? object类上有一个非常重要的特殊方法,我们可以利用它来表示字符。 此方法称为__str__(两个下划线,如__init_ _),用于诸如printstr构造函数之类的字符串操作函数中,可将任何类转换为字符串。 默认实现会执行一些无聊的工作,例如在内存中打印模块和类的名称及其地址。 但是,如果我们覆盖它,我们可以使它打印出我们喜欢的任何东西。 在我们的实现中,我们可以使用特殊字符作为前缀字符,以表示它们是粗体,斜体还是带下划线。 因此,我们将创建一个表示字符的类,这里是:

class Character:
    def __init__(self, character,
            bold=False, italic=False, underline=False):
        assert len(character) == 1
        self.character = character
        self.bold = bold
        self.italic = italic
        self.underline = underline

    def __str__(self):
        bold = "*" if self.bold else ''
        italic = "/" if self.italic else ''
        underline = "_" if self.underline else ''
        return bold + italic + underline + self.character

此类允许我们创建字符,并在将str()功能应用于它们时给它们添加特殊字符前缀。 那里没有什么太令人兴奋的。 我们只需要对DocumentCursor类进行一些小的修改即可使用该类。 在Document类中,我们在insert方法的开头添加以下两行:

    def insert(self, character):
        if not hasattr(character, 'character'):
            character = Character(character)

这是有点奇怪的代码。 其基本目的是检查传入的字符是Character还是str。 如果是字符串,则将其包装在Character类中,因此列表中的所有对象均为Character对象。 但是,使用我们的代码的人很可能想通过鸭子类型使用既不是Character也不是字符串的类。 如果对象具有字符属性,则假定它是“ Character -like”对象。 但是,如果不是这样,我们假定它是“类似于str的对象”,并将其包装在Character中。 这有助于程序利用鸭子类型和多态性。 只要对象具有字符属性,就可以在Document类中使用它。

例如,如果我们想使用语法突出显示功能来使程序员的编辑器,这种通用检查可能非常有用:我们需要字符上的额外数据,例如字符所属的语法标记类型。 请注意,如果我们进行了大量此类比较,则最好将Character作为具有适当__subclasshook__的抽象基类来实现,如第 3 章,中所述。

另外,我们需要修改Document的字符串属性以接受新的Character值。 我们需要做的就是在加入每个字符之前调用str()

    @property
    def string(self):
 return "".join((str(c) for c in self.characters))

此代码使用生成器表达式,我们将在第 9 章,“迭代器模式”中进行讨论。 这是对序列中的所有对象执行特定操作的快捷方式。

最后,当我们查看Character.characterend函数是否匹配换行符时,还需要检查Character.character,而不仅仅是我们之前存储的字符串字符:

    def home(self):
        while self.document.characters[
                self.position-1].character != '\n':
            self.position -= 1
            if self.position == 0:
                # Got to beginning of file before newline
                break

    def end(self):
        while self.position < len(
                self.document.characters) and \
                self.document.characters[
                        self.position
                        ].character != '\n':
            self.position += 1

这样就完成了字符的格式化。 我们可以对其进行测试以查看它是否有效:

>>> d = Document()
>>> d.insert('h')
>>> d.insert('e')
>>> d.insert(Character('l', bold=True))
>>> d.insert(Character('l', bold=True))
>>> d.insert('o')
>>> d.insert('\n')
>>> d.insert(Character('w', italic=True))
>>> d.insert(Character('o', italic=True))
>>> d.insert(Character('r', underline=True))
>>> d.insert('l')
>>> d.insert('d')
>>> print(d.string)
he`l`lo
/w/o_rld
>>> d.cursor.home()
>>> d.delete()
>>> d.insert('W')
>>> print(d.string)
he`l`lo
W/o_rld
>>> d.characters[0].underline = True
>>> print(d.string)
_he`l`lo
W/o_rld

不出所料,每当我们打印字符串时,每个粗体字符前面都会带有*字符,每个斜体字符之前带有/字符,每个带下划线的字符之前都带有_字符。 我们所有的功能似乎都可以正常工作,事实发生后我们可以修改列表中的字符。 我们有一个工作的富文本文档对象,可以将其插入适当的用户界面中,并与用于输入的键盘和用于输出的屏幕挂钩。 自然,我们希望在屏幕上显示真实的粗体,斜体和带下划线的字符,而不是使用__str__方法,但这足以满足我们要求的基本测试要求。

Case study

Case study

Case study

Case study

七、Python 数据结构

到目前为止,在我们的示例中,我们已经看到了许多内置的 Python 数据结构在起作用。 您可能还在入门书籍或教程中介绍了其中许多内容。 在本章中,我们将讨论这些数据结构的面向对象功能,何时使用它们而不是常规类以及何时不使用它们。 特别是,我们将介绍:

  • 元组和命名元组
  • 辞典
  • 清单和集合
  • 如何以及为什么扩展内置对象
  • 三种队列

空物体

让我们从最基本的 Python 内置开始,我们已经看过很多次了,在我们创建的每个类中都扩展了一个object。 从技术上讲,我们可以在不编写子类的情况下实例化object

>>> o = object()
>>> o.x = 5
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
AttributeError: 'object' object has no attribute 'x'

不幸的是,如您所见,无法在直接实例化的object上设置任何属性。 这不是因为 Python 开发人员想强迫我们编写自己的类,或者如此险恶的东西。 他们这样做是为了节省内存。 很多内存。 当 Python 允许对象具有任意属性时,它会占用一定数量的系统内存来跟踪每个对象具有的属性,以存储属性名称及其值。 即使未存储任何属性,也会为潜在的新属性分配内存。 给定一个典型的 Python 程序中的数十个,数百个或数千个对象(每个类都扩展了对象); 这种少量的内存将很快变成大量的内存。 因此,默认情况下,Python 会禁用object和其他几个内置组件上的任意属性。

注意

可以使用插槽在我们自己的类上限制任意属性。 插槽超出了本模块的范围,但是如果您要查找更多信息,现在可以使用搜索词。 在正常使用中,使用插槽并没有太多好处,但是,如果您要编写一个将在整个系统中重复数千次的对象,则它们可以像object一样帮助节省内存。

但是,创建我们自己的空对象类是微不足道的。 我们在最早的示例中看到了它:

class MyObject:
    pass

而且,正如我们已经看到的,可以在此类上设置属性:

>>> m = MyObject()
>>> m.x = "hello"
>>> m.x
'hello'

如果我们想将属性分组在一起,则可以将它们存储在这样的空对象中。 但是通常最好使用其他用于存储数据的内置函数。 在整个模块中已强调,仅当要同时指定数据和行为时才使用类和对象。 编写空类的主要原因是要迅速阻止某些内容,因为我们知道稍后会添加行为。 使行为适应类比将数据结构替换为对象并更改对该对象的所有引用要容易得多。 因此,重要的是从一开始就确定数据仅仅是数据还是变相对象。 一旦做出设计决定,其余的设计自然就会落到位。

元组和命名元组

元组是对象,可以依次存储特定数量的其他对象。 它们是不可变的,因此我们不能即时添加,删除或替换对象。 这似乎是一个巨大的限制,但事实是,如果您需要修改元组,则使用了错误的数据类型(通常,列表会更合适)。 元组不变性的主要好处是,我们可以将它们用作字典中的键以及对象需要哈希值的其他位置的键。

元组用于存储数据; 行为不能存储在元组中。 如果需要行为来操作元组,则必须将元组传递给执行该操作的函数(或另一个对象的方法)。

元组通常应存储彼此有所不同的值。 例如,我们不会在一个元组中放置三个股票代号,但可以创建一个股票代号,当前价格,当天的最高价和最低价的元组。 元组的主要目的是将不同的数据片段聚合到一个容器中。 因此,元组可能是替换“无数据对象”的最简单工具。

我们可以通过用逗号分隔值来创建元组。 通常,将元组用括号括起来以使其易于阅读并将它们与表达式的其他部分分开,但这并不总是强制性的。 以下两个分配是相同的(它们记录了一个盈利的公司的股票,当前价格,最高价和最低价):

>>> stock = "FB", 75.00, 75.03, 74.90
>>> stock2 = ("FB", 75.00, 75.03, 74.90)

如果我们要在其他对象(例如函数调用,列表推导或生成器)中对元组进行分组,则需要使用括号。 否则,解释器将不可能知道它是元组还是下一个函数参数。 例如,以下函数接受一个元组和一个日期,并返回日期和股票的高值和低值之间的中间值的元组:

import datetime
def middle(stock, date):
    symbol, current, high, low = stock
    return (((high + low) / 2), date)

mid_value, date = middle(("FB", 75.00, 75.03, 74.90),
        datetime.date(2014, 10, 31))

通过用逗号分隔值并将整个元组括在括号中,可以直接在函数调用内部创建元组。 然后,该元组后面用逗号将其与第二个参数分开。

此示例还说明了元组拆包。 函数内的第一行将stock参数解压缩为四个不同的变量。 元组的长度必须与变量的数量完全相同,否则将引发异常。 我们还可以在最后一行看到元组拆包的示例,其中将函数内部返回的元组拆包为两个值mid_valuedate。 当然,这是一件很奇怪的事情,因为我们首先将日期提供给函数,但这给了我们一次查看工作中的拆包的机会。

解包是在 Python 中非常有用的功能。 我们可以将变量分组在一起,以使存储和传递它们变得更加简单,但是当我们需要访问所有变量时,我们可以将它们分解为单独的变量。 当然,有时我们只需要访问元组中的变量之一即可。 我们可以使用与其他序列类型(例如列表和字符串)相同的语法来访问单个值:

>>> stock = "FB", 75.00, 75.03, 74.90
>>> high = stock[2]
>>> high
75.03

我们甚至可以使用切片符号来提取较大的元组片段:

>>> stock[1:3]
(75.00, 75.03)

这些示例在说明元组可以有多灵活的同时,还显示了其主要缺点之一:可读性。 读取此代码的人如何知道特定元组的第二个位置是什么? 他们可以从我们分配给它的变量的名称中猜测它是某种high,但是如果我们只是在计算中访问了元组值而没有分配它,则不会有这样的指示。 他们将不得不仔细检查代码,以找到元组在哪里声明,然后才能发现它的作用。

在某些情况下,直接访问元组成员是好的,但是不要养成习惯。 这种所谓的“幻数”(似乎是凭空冒出来的数字,在代码中没有明显的含义)是许多编码错误的根源,并导致数小时的调试工作受挫。 仅在知道所有值都将立即有用并且通常在访问它时将它们解包时,才尝试使用元组。 如果您必须直接访问成员或使用切片,而该值的用途并不立即明显,请至少添加一条注释,说明其来源。

命名元组

因此,当我们想将值分组在一起但知道我们经常需要分别访问它们时,和会做什么? 好吧,我们可以使用一个空对象,如上一节中所述(但是除非我们稍后会期望添加行为,否则它很少有用),或者我们可以使用字典(如果我们不知道确切的数量或具体的对象,则非常有用。 数据将被存储),我们将在下一部分中介绍。

但是,如果我们不需要向对象添加行为,并且我们事先知道需要存储哪些属性,则可以使用命名元组。 元组是具有态度的元组。 它们是将只读数据分组在一起的好方法。

构造一个命名的元组比普通的元组需要更多的工作。 首先,我们必须导入namedtuple,因为默认情况下它不在名称空间中。 然后,我们通过命名命名元组并概述其属性来描述命名元组。 这将返回一个类类对象,我们可以根据需要多次实例化所需的值:

from collections import namedtuple
Stock = namedtuple("Stock", "symbol current high low")
stock = Stock("FB", 75.00, high=75.03, low=74.90)

namedtuple构造函数接受两个参数。 第一个是命名元组的标识符。 第二个是命名元组可以具有的以空格分隔的属性字符串。 应该列出第一个属性,然后是一个空格(如果您愿意,可以用逗号),然后是第二个属性,然后是另一个空格,依此类推。 结果是可以像调用普通对象类一样实例化其他对象的对象。 构造函数必须具有正确数量的参数,可以作为参数或关键字参数传递。 与普通对象一样,我们可以根据需要创建任意数量的“类”实例,每个实例具有不同的值。

然后可以将生成的namedtuple打包,拆包或以其他方式像普通元组一样对待,但是我们也可以像对待对象一样访问其上的各个属性:

>>> stock.high
75.03
>>> symbol, current, high, low = stock
>>> current
75.00

注意

请记住,创建命名元组是一个两步过程。 首先,使用collections.namedtuple创建一个类,然后构造该类的实例。

元组是许多“仅数据”表示形式的理想选择,但并不是在所有情况下都理想。 像元组和字符串一样,命名元组是不可变的,因此一旦设置了属性,我们将无法对其进行修改。 例如,自从我们开始讨论以来,我公司股票的当前价值已经下降,但是我们不能设置新的价值:

>>> stock.current = 74.98
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
AttributeError: can't set attribute

如果我们需要能够更改存储的数据,则可能需要字典。

字典

字典是有用的容器,它使我们可以将对象直接映射到其他对象。 具有属性的空对象是一种字典。 属性的名称映射到属性值。 实际上,这比听起来更接近真理。 在内部,对象通常将属性表示为字典,其中值是对象上的属性或方法(如果您不相信我,请参见__dict__属性)。 甚至模块上的属性都在内部存储在字典中。

给定映射到该值的特定键对象,字典在查找值时非常有效。 当您要基于其他对象找到一个对象时,应始终使用它们。 被存储的对象称为; 用作索引的对象称为。 在前面的一些示例中,我们已经看到了字典语法。

可以使用dict()构造函数或{}语法快捷方式创建字典。 实际上,后一种格式几乎总是被使用。 我们可以通过使用冒号将键与值分开,并使用逗号分隔键值对来预填充字典。

例如,在股票申请中,我们通常希望通过股票代码查找价格。 我们可以创建一个字典,使用股票代码作为键,并使用 current,high 和 low 的元组作为值,如下所示:

stocks = {"GOOG": (613.30, 625.86, 610.50),
          "MSFT": (30.25, 30.70, 30.19)}

正如我们在前面的示例中所看到的,我们可以通过在方括号内请求键来在字典中查找值。 如果键不在字典中,它将引发异常:

>>> stocks["GOOG"]
(613.3, 625.86, 610.5)
>>> stocks["RIM"]
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
KeyError: 'RIM'

当然,我们可以捕获并处理KeyError。 但是我们还有其他选择。 请记住,字典是对象,即使其主要目的是保存其他对象。 因此,它们具有几种与之相关的行为。 这些方法中最有用的一种是get方法。 它接受键作为第一个参数,如果键不存在,则接受可选的默认值:

>>> print(stocks.get("RIM"))
None
>>> stocks.get("RIM", "NOT FOUND")
'NOT FOUND'

为了获得更多控制,我们可以使用setdefault方法。 如果键在字典中,则此方法的行为类似于get; 它返回该键的值。 否则,如果键不在字典中,它不仅会返回我们在方法调用中提供的默认值(就像get一样),还会将键设置为相同的值。 另一种思考的方式是,setdefault仅在先前未设置该值的情况下才在字典中设置该值。 然后,它返回字典中的值,要么是已有的值,要么是新提供的默认值。

>>> stocks.setdefault("GOOG", "INVALID")
(613.3, 625.86, 610.5)
>>> stocks.setdefault("BBRY", (10.50, 10.62, 10.39))
(10.50, 10.62, 10.39)
>>> stocks["BBRY"]
(10.50, 10.62, 10.39)

GOOG库存已经在字典中,因此当我们尝试将setdefault库存到无效值时,它只是返回字典中已经存在的值。 BBRY不在词典中,因此setdefault返回了默认值,并为我们在词典中设置了新值。 然后,我们检查新库存是否确实在词典中。

keys()values()items()这三种非常有用的字典方法是非常有用的。 前两个返回字典中所有键和所有值的列表。 如果我们要处理所有键或值,则可以使用这些类似列表或在for循环中使用。 items()方法可能是最有用的。 对于字典中的每个项目,它会针对(key, value)对的元组返回一个迭代器。 这非常适合for循环中的元组解包,以循环关联的键和值。 这个例子只是用当前值打印字典中的每只股票:

>>> for stock, values in stocks.items():
...     print("{} last value is {}".format(stock, values[0]))
...
GOOG last value is 613.3
BBRY last value is 10.50
MSFT last value is 30.25

每个键/值元组都被解压缩为两个名为stockvalues的变量(我们可以使用我们想要的任何变量名,但它们看起来都合适),然后以格式化字符串打印。

请注意,股票的显示顺序与插入时的顺序不同。 由于用于使键查找如此快速的高效算法(称为散列),字典本身是未排序的。

因此,一旦字典被实例化,有很多方法可以从字典中检索数据。 我们可以使用方括号作为索引语法,get方法,setdefault方法或迭代items方法等。

最后,您可能已经知道,我们可以使用与检索值相同的索引语法在字典中设置值:

>>> stocks["GOOG"] = (597.63, 610.00, 596.28)
>>> stocks['GOOG']
(597.63, 610.0, 596.28)

Google 的价格今天较低,因此我更新了字典中的元组值。 我们可以使用此索引语法为任何键设置一个值,而不管该键是否在字典中。 如果在字典中,则旧值将被新值替换; 否则,将创建一个新的键/值对。

到目前为止,我们一直在使用字符串作为字典键,但是我们不仅限于字符串键。 通常将字符串用作键,尤其是当我们将数据存储在字典中以将其收集在一起时(而不是使用具有命名属性的对象)。 但是我们也可以使用元组,数字甚至是我们自己定义为字典键的对象。 我们甚至可以在一个字典中使用不同类型的键:

random_keys = {}
random_keys["astring"] = "somestring"
random_keys[5] = "aninteger"
random_keys[25.2] = "floats work too"
random_keys[("abc", 123)] = "so do tuples"

class AnObject:
    def __init__(self, avalue):
        self.avalue = avalue

my_object = AnObject(14)
random_keys[my_object] = "We can even store objects"
my_object.avalue = 12
try:
    random_keys[[1,2,3]] = "we can't store lists though"
except:
    print("unable to store list\n")

for key, value in random_keys.items():
    print("{} has value {}".format(key, value))

此代码显示了我们可以提供给字典的几种不同类型的键。 它还显示了一种无法使用的对象。 我们已经广泛使用了列表,我们将在下一部分中看到它们的更多详细信息。 因为列表可以随时更改(例如,通过添加或删除项目),所以它们不能哈希为特定值。

可对进行哈希处理的对象 基本上具有已定义的算法,该算法将该对象转换为唯一的整数值以进行快速查找。 该哈希实际上是用于在字典中查找值的内容。 例如,字符串基于字符串中的字符映射为整数,而元组将元组内各项的哈希组合在一起。 以某种方式被认为相等的任何两个对象(例如具有相同字符的字符串或具有相同值的元组)应具有相同的哈希值,并且对象的哈希值永远不应改变。 但是,列表可以更改其内容,这将更改其哈希值(两个列表仅在其内容相同时才应相等)。 因此,它们不能用作字典键。 由于相同的原因,词典不能用作其他词典的键。

相反,可以用作字典值的对象类型没有限制。 例如,我们可以使用映射到列表值的字符串键,也可以将嵌套的词典作为另一个词典中的值。

词典用例

字典具有多种用途,并且用途广泛。 可以使用两种主要方法来使用字典。 第一个是字典,其中所有键代表相似对象的不同实例; 例如,我们的股票字典。 这是一个索引系统。 我们使用股票代码作为值的索引。 这些值甚至可能是复杂的自定义对象,而不是我们的简单元组,它们会做出买卖决定或设置止损。

第二种设计是字典,其中每个键代表单个结构的某些方面; 在这种情况下,我们可能会为每个对象使用一个单独的字典,并且它们都具有相似(尽管通常不相同)的键集。 后一种情况通常也可以通过命名元组解决。 当我们确切地知道数据必须存储什么属性,并且知道必须立即提供所有数据片段(在构造项目时)时,通常应使用这些属性。 但是,如果我们需要随着时间的推移来创建或更改字典键,或者我们不确切知道键可能是什么,那么字典更合适。

使用 defaultdict

我们已经看到如果不存在键,如何使用setdefault设置默认值,但是如果每次查找值时都需要设置默认值,这可能会有些单调。 例如,如果我们正在编写计算给定句子中字母出现次数的代码,则可以执行以下操作:

def letter_frequency(sentence):
    frequencies = {}
    for letter in sentence:
        frequency = frequencies.setdefault(letter, 0)
        frequencies[letter] = frequency + 1
    return frequencies

每次访问字典时,我们都需要检查它是否已经有一个值,如果没有,则将其设置为零。 当每次需要输入空键时都需要执行此类操作时,我们可以使用另一种版本的字典,称为defaultdict

from collections import defaultdict
def letter_frequency(sentence):
    frequencies = defaultdict(int)
    for letter in sentence:
        frequencies[letter] += 1
    return frequencies

此代码似乎无法正常运行。 defaultdict在其构造函数中接受一个函数。 每当访问字典中尚不存在的键时,它都会调用该函数(不带参数)以创建默认值。

在这种情况下,它调用的函数是int,它是整数对象的构造函数。 通常,整数是简单地通过在代码中键入一个整数来创建的,如果确实使用int构造函数创建一个整数,则将要创建的项目传递给它(例如,将数字字符串转换为整数) )。 但是,如果我们不带任何参数调用int,它将方便地返回数字零。 在此代码中,如果defaultdict中不存在字母,则在我们访问它时将返回数字零。 然后,向该数字添加一个,以表示我们找到了该字母的一个实例,下次找到一个字母时,该数字将被返回,并且可以再次增加该值。

defaultdict对于创建容器字典很有用。 如果我们要创建过去 30 天的股价字典,可以使用股票符号作为关键字并将价格存储在list中; 第一次访问股票价格时,我们希望它创建一个空列表。 只需将list传递到defaultdict,则每次访问空键时都会调用它。 如果要将集合与键相关联,我们可以对集合甚至空字典进行类似的操作。

当然,我们也可以编写自己的函数并将其传递给defaultdict。 假设我们要创建一个defaultdict,其中每个新元素都包含当时插入字典中的项数的元组和一个用于容纳其他内容的空列表。 没有人知道为什么要创建这样的对象,但让我们看一下:

from collections import defaultdict
num_items = 0
def tuple_counter():
    global num_items
    num_items += 1
    return (num_items, [])

d = defaultdict(tuple_counter)

运行此代码时,我们可以访问空键,并在一条语句中全部插入到列表中:

>>> d = defaultdict(tuple_counter)
>>> d['a'][1].append("hello")
>>> d['b'][1].append('world')
>>> d
defaultdict(<function tuple_counter at 0x82f2c6c>,
{'a': (1, ['hello']), 'b': (2, ['world'])})

当我们最后打印dict时,我们看到计数器确实在工作。

注意

这个例子虽然简短地演示了如何为defaultdict创建我们自己的函数,但实际上并不是很好的代码; 使用全局变量意味着如果我们创建四个不同的defaultdict段,每个段都使用tuple_counter,它将计算所有词典中条目的数量,而不是每个条目都有不同的计数。 最好创建一个类并将该类上的方法传递给defaultdict

计数器

您可能会认为不会比defaultdict(int)简单得多,但“我想在一个可迭代的实例中计算特定实例”用例非常普遍,以至于 Python 开发人员为该类创建了一个特定的类。 它。 前面计算字符串中字符数的代码可以很容易地在一行中计算出:

from collections import Counter
def letter_frequency(sentence):
    return Counter(sentence)

Counter对象的行为类似于增强字典,其中键是要计数的项目,值是此类项目的数量。 most_common()方法是最有用的功能之一。 它返回按计数顺序排序的(键,计数)元组的列表。 您可以选择将整数参数传递给most_common(),以仅请求最常见的元素。 例如,您可以编写一个简单的轮询应用,如下所示:

from collections import Counter

responses = [
    "vanilla",
    "chocolate",
    "vanilla",
    "vanilla",
    "caramel",
    "strawberry",
    "vanilla"
]

print(
    "The children voted for {} ice cream".format(
        Counter(responses).most_common(1)[0][0]
    )
)

大概,您将从数据库中获得响应,或者使用复杂的视觉算法来计算举手的孩子。 在这里,我们对其进行硬编码,以便可以测试most_common方法。 它返回仅包含一个元素的列表(因为我们在参数中请求了一个元素)。 该元素将最佳选择的名称存储在零位置,因此在调用结束时存储了双精度[0][0]。 我认为它们看起来像是一张惊讶的脸,不是吗? 您的计算机可能很惊讶,它可以如此轻松地对数据进行计数。 它的祖先是霍勒里斯(Hollerith)在 1890 年美国人口普查中使用的制表机,一定非常嫉妒!

列表

列表是最少的面向对象的 Python 数据结构。 虽然列表本身就是对象,但 Python 中有很多语法可以使列表的使用尽可能轻松。 与许多其他面向对象的语言不同,Python 中的列表仅可用。 我们不需要导入它们,并且很少需要在它们上调用方法。 我们可以遍历列表而无需显式请求迭代器对象,并且可以使用自定义语法构造一个列表(与字典一样)。 此外,列表理解和生成器表达式将它们变成了名副其实的计算功能的瑞士军刀。

我们不会过多地介绍语法。 您已经在网上的入门教程和本模块的先前示例中看到了它。 如果不学习如何使用列表,就不能花很长时间编写 Python! 相反,我们将介绍何时应使用列表以及它们作为对象的性质。 如果您不知道如何创建或追加到列表,如何从列表中检索项目,或者“切片符号”是什么,我将指导您直接使用 Python 官方教程。 可以在这个页面在线找到。

在 Python 中,当我们要存储对象的“相同”类型的多个实例时,通常应使用列表。 字符串列表或数字列表; 最常见的是我们自己定义的对象列表。 当我们要以某种顺序存储项目时,应始终使用列表。 通常,这是它们插入的顺序,但是也可以按照某些条件对其进行排序。

当我们需要修改内容时,列表也非常有用:在列表的任意位置插入或删除列表,或更新列表中的值。

像字典一样,Python 列表使用非常有效且经过良好调整的内部数据结构,因此我们可以担心存储的内容而不是存储的方式。 许多面向对象的语言为队列,堆栈,链接列表和基于数组的列表提供了不同的数据结构。 如果需要优化对大量数据的访问,Python 确实提供了其中一些类的特殊实例。 但是,通常,列表数据结构可以立即满足所有这些目的,并且编码人员可以完全控制它们的访问方式。

不要将列表用于来收集各个项目的不同属性。 例如,我们不需要特定形状具有的属性列表。 元组,命名元组,字典和对象都将更适合于此目的。 在某些语言中,他们可能会创建一个列表,其中每个替代项都是不同的类型。 例如,他们可能在我们的字母频率列表中写['a', 1, 'b', 3]。 他们必须使用一个奇怪的循环来一次访问列表中的两个元素,或者使用一个模数运算符来确定要访问的位置。

不要在 Python 中执行此操作。 我们可以像上一节中那样(如果排序顺序无关紧要),使用字典将相关项目分组在一起,或者使用元组列表。 这是一个令人费解的示例,演示了如何使用列表进行频率示例。 它比字典示例复杂得多,并且说明了选择正确(或错误)数据结构对代码可读性的影响:

import string
CHARACTERS  = list(string.ascii_letters) + [" "]

def letter_frequency(sentence):
    frequencies = [(c, 0) for c in CHARACTERS]
    for letter in sentence:
        index = CHARACTERS.index(letter)
        frequencies[index] = (letter,frequencies[index][1]+1)
    return frequencies

此代码以可能的字符列表开头。 string.ascii_letters属性按顺序提供所有字母的字符串,包括小写和大写。 我们将其转换为列表,然后使用列表串联(加号运算符使两个列表合并为一个)再添加一个字符,即空格。 这些是频率列表中的可用字符(如果我们尝试添加不在列表中的字母,则代码会中断,但是可以使用异常处理程序解决此问题)。

函数内部的第一行使用列表推导将CHARACTERS列表转换为元组列表。 列表推导是 Python 中重要的,非面向对象的工具; 我们将在下一章详细介绍它们。

然后,我们在句子中的每个字符上循环。 我们首先在CHARACTERS列表中查找字符的索引,因为我们刚刚从第一个列表创建了第二个列表,所以我们知道它在频率列表中具有相同的索引。 然后,我们通过创建一个新的元组(而不是原始的元组)来更新频率列表中的索引。 除了垃圾回收和内存浪费的问题外,这还很难阅读!

像字典一样,列表也是对象,它们具有可以在其上调用的几种方法。 这是一些常见的:

  • append(element)方法将元素添加到列表的末尾
  • insert(index, element)方法将项目插入特定位置
  • count(element)方法告诉我们元素在列表中出现了多少次
  • index()方法告诉我们列表中某项的索引,如果找不到该项,则会引发异常
  • find()方法执行相同的操作,但是返回-1而不是引发缺少项的异常
  • reverse()方法完全按照它说的去做-将列表翻转
  • sort()方法具有一些相当复杂的面向对象的行为,我们现在将讨论

排序列表

如果没有任何参数,sort通常会完成预期的工作。 如果是字符串列表,它将按字母顺序放置。 此操作区分大小写,因此所有大写字母将在小写字母之前排序,即Za之前。 如果是数字列表,则将按数字顺序对其进行排序。 如果提供了元组列表,则该列表将按每个元组中的第一个元素进行排序。 如果提供了包含无法分类项目的混合物,则分类将引发TypeError异常。

如果我们要放置对象,则将自己定义为一个列表并使这些对象可排序,我们需要做更多的工作。 应该在类上定义代表“小于”的特殊方法__lt__,以使该类的实例具有可比性。 列表中的sort方法将在每个对象上访问此方法,以确定它在列表中的位置。 如果我们的类在某种程度上小于传递的参数,则此方法应返回True,否则返回False。 这是一个很愚蠢的类,可以根据字符串或数字进行排序:

class WeirdSortee:
    def __init__(self, string, number, sort_num):
        self.string = string
        self.number = number
        self.sort_num = sort_num

    def __lt__(self, object):
        if self.sort_num:
            return self.number < object.number
        return self.string < object.string

    def __repr__(self):
        return"{}:{}".format(self.string, self.number)

__repr__方法使我们在打印列表时很容易看到两个值。 __lt__方法的实现将对象与同一类的另一个实例进行比较(或具有stringnumbersort_num属性的任何鸭子类型的对象;如果缺少这些属性,它将失败)。 以下输出说明了有关排序的实际类:

>>> a = WeirdSortee('a', 4, True)
>>> b = WeirdSortee('b', 3, True)
>>> c = WeirdSortee('c', 2, True)
>>> d = WeirdSortee('d', 1, True)
>>> l = [a,b,c,d]
>>> l
[a:4, b:3, c:2, d:1]
>>> l.sort()
>>> l
[d:1, c:2, b:3, a:4]
>>> for i in l:
...     i.sort_num = False
...
>>> l.sort()
>>> l
[a:4, b:3, c:2, d:1]

我们第一次将称为sort,它是按数字排序的,因为在所有要比较的对象上sort_numTrue。 第二次,它按字母排序。 __lt__方法是我们需要实现的唯一一种启用排序的方法。 但是,从技术上讲,如果实现了,则该类通常也应该实现类似的__gt____eq____ne____ge____le__方法,以便所有<>==!=>=<=运算符也可以正常工作。 您可以通过实现__lt____eq__,然后应用@total_ordering类修饰器来提供其余内容,以免费获得此功能:

from functools import total_ordering

@total_ordering
class WeirdSortee:
    def __init__(self, string, number, sort_num):
        self.string = string
        self.number = number
        self.sort_num = sort_num

    def __lt__(self, object):
        if self.sort_num:
            return self.number < object.number
        return self.string < object.string

    def __repr__(self):
        return"{}:{}".format(self.string, self.number)

    def __eq__(self, object):
        return all((
            self.string == object.string,
            self.number == object.number,
            self.sort_num == object.number
        ))

如果我们希望能够在对象上使用运算符,这将很有用。 但是,如果我们要做的只是自定义排序顺序,那么这太过分了。 对于这种用例,sort方法可以采用可选的key参数。 此参数是一个函数,可以将列表中的每个对象转换为可以以某种方式进行比较的对象。 例如,我们可以使用str.lower作为键参数对字符串列表执行不区分大小写的排序:

>>> l = ["hello", "HELP", "Helo"]
>>> l.sort()
>>> l
['HELP', 'Helo', 'hello']
>>> l.sort(key=str.lower)
>>> l
['hello', 'Helo', 'HELP']

请记住,尽管尽管lower是字符串对象的一种方法,但它也是一个可以接受单个参数self的函数。 换句话说,str.lower(item)等效于item.lower()。 当我们将此函数作为键传递时,它将对小写值执行比较,而不是执行默认的区分大小写的比较。

Python 团队提供了一些常见的排序键操作,因此您不必自己编写它们。 例如,通常用除列表中第一项之外的其他方式对元组列表进行排序。 operator.itemgetter方法可以用作执行此操作的键:

>>> from operator import itemgetter
>>> l = [('h', 4), ('n', 6), ('o', 5), ('p', 1), ('t', 3), ('y', 2)]
>>> l.sort(key=itemgetter(1))
>>> l
[('p', 1), ('y', 2), ('t', 3), ('h', 4), ('o', 5), ('n', 6)]

itemgetter函数是最常用的函数(如果对象也是字典也可以使用),但是有时您会发现attrgettermethodcaller的用法,它们返回对象的属性和方法调用的结果 出于相同的目的。 有关更多信息,请参见operator模块文档。

套装

列表是非常适合的通用工具,适用于大多数容器对象应用。 但是,当我们要确保列表中的对象唯一时,它们就没有用。 例如,歌曲库可能包含同一位艺术家的许多歌曲。 如果要对库进行排序并创建所有艺术家的列表,则必须在再次添加艺术家之前检查该列表,看看是否已经添加了艺术家。

这就是集合的来源。集合来自数学,它们代表无序的一组(通常)唯一数字。 我们可以将一个数字添加到集合中五次,但它只会在集合中显示一次。

在 Python 中,集合可以保存任何可哈希对象,而不仅仅是数字。 可哈希对象与可用作字典中键的对象相同; 如此一来,列表和字典就消失了。 像数学集一样,它们只能存储每个对象的一个​​副本。 因此,如果我们尝试创建歌曲艺术家列表,则可以创建一组字符串名称并将其简单地添加到该字符串名称中。 此示例以(歌曲,艺术家)元组的列表开始,并创建一组艺术家:

song_library = [("Phantom Of The Opera", "Sarah Brightman"),
        ("Knocking On Heaven's Door", "Guns N' Roses"),
        ("Captain Nemo", "Sarah Brightman"),
        ("Patterns In The Ivy", "Opeth"),
        ("November Rain", "Guns N' Roses"),
        ("Beautiful", "Sarah Brightman"),
        ("Mal's Song", "Vixy and Tony")]

artists = set()
for song, artist in song_library:
    artists.add(artist)

print(artists)

空列表没有内置语法,列表和字典也没有内置语法。 我们使用set()构造函数创建一个集合。 但是,我们可以使用花括号(从字典语法中借用)来创建一个集合,只要该集合包含值即可。 如果我们使用冒号分隔值对,则它是一个字典,如{'key': 'value', 'key2': 'value2'}所示。 如果我们只用逗号分隔值,则它是一个集合,如{'value', 'value2'}所示。 可以使用add方法将项目单独添加到集合中。 如果运行此脚本,我们将看到该集合按公布的方式工作:

{'Sarah Brightman', "Guns N' Roses", 'Vixy and Tony', 'Opeth'}

如果您关注输出,则会注意到这些项目没有按照它们添加到集合中的顺序进行打印。 像字典一样,集合是无序的。 它们都使用底层的基于散列的数据结构来提高效率。 因为它们是无序的,所以集合不能具有按索引查找的项目。 集合的主要目的是将世界分为两组:“集合中的事物”和“集合中不存在的事物”。 检查项目是否在集合中或在集合中的项目上循环很容易,但是如果我们要对它们进行排序或排序,则必须将集合转换为列表。 此输出显示所有这三个活动:

>>> "Opeth" in artists
True
>>> for artist in artists:
...     print("{} plays good music".format(artist))
...
Sarah Brightman plays good music
Guns N' Roses plays good music
Vixy and Tony play good music
Opeth plays good music
>>> alphabetical = list(artists)
>>> alphabetical.sort()
>>> alphabetical
["Guns N' Roses", 'Opeth', 'Sarah Brightman', 'Vixy and Tony']

集合的主要特征是唯一的,但这不是其主要目的。 当集合中的两个或多个结合使用时,集合最有用。 集合类型上的大多数方法都可以在其他集合上使用,从而使我们可以有效地组合或比较两个或更多集合中的项目。 这些方法使用奇怪的名称,因为它们使用的数学术语相同。 我们将从三个返回相同结果的方法开始,而不管哪个是调用集,哪个是被调用集。

union方法是最常见且最容易理解的方法。 它使用第二个集合作为参数,并返回一个新集合,该集合包含两个集合中或中的所有元素; 如果元素在两个原始集中都存在,那么它在新集中只会出现一次。 联合就像一个逻辑上的or操作,实际上,如果您不喜欢调用方法,|运算符可以用于两个集合上以执行联合操作。

相反,交集方法接受第二个集合并返回一个新集合,该集合仅包含两个集中的中的元素。 它类似于逻辑and操作,也可以使用&运算符进行引用。

最后, symmetric_difference方法告诉我们还剩下什么; 它是一组或另一组中的一组对象,但不是两者都存在。 下面的示例通过比较我的歌曲库中的一些艺术家和姐姐的艺术家中的艺术家来说明这些方法:

my_artists = {"Sarah Brightman", "Guns N' Roses",
        "Opeth", "Vixy and Tony"}

auburns_artists = {"Nickelback", "Guns N' Roses",
        "Savage Garden"}

print("All: {}".format(my_artists.union(auburns_artists)))
print("Both: {}".format(auburns_artists.intersection(my_artists)))
print("Either but not both: {}".format(
    my_artists.symmetric_difference(auburns_artists)))

如果我们运行此代码,我们将看到这三种方法可以执行 print 语句建议的操作:

All: {'Sarah Brightman', "Guns N' Roses", 'Vixy and Tony',
'Savage Garden', 'Opeth', 'Nickelback'}
Both: {"Guns N' Roses"}
Either but not both: {'Savage Garden', 'Opeth', 'Nickelback',
'Sarah Brightman', 'Vixy and Tony'}

这些方法都返回相同的结果,而不管哪个集合调用另一个。 我们可以说my_artists.union(auburns_artists)auburns_artists.union(my_artists)并获得相同的结果。 还有一些方法可以返回不同的结果,具体取决于谁是调用者,谁是参数。

这些方法包括issubsetissuperset,它们彼此相反。 两者都返回bool。 如果调用集中的所有项目也都在作为参数传递的集中,则issubset方法返回True。 如果参数中的所有项目也都在调用集中,则issuperset方法将返回True。 因此,s.issubset(t)t.issuperset(s)是相同的。 如果t包含s中的所有元素,它们都将返回True

最后,difference方法返回调用集中的所有元素,但不作为参数传递的集中的所有元素; 这就像symmetric_difference的一半。 difference方法也可以由-运算符表示。 以下代码说明了这些方法的实际作用:

my_artists = {"Sarah Brightman", "Guns N' Roses",
        "Opeth", "Vixy and Tony"}

bands = {"Guns N' Roses", "Opeth"}

print("my_artists is to bands:")
print("issuperset: {}".format(my_artists.issuperset(bands)))
print("issubset: {}".format(my_artists.issubset(bands)))
print("difference: {}".format(my_artists.difference(bands)))
print("*"*20)
print("bands is to my_artists:")
print("issuperset: {}".format(bands.issuperset(my_artists)))
print("issubset: {}".format(bands.issubset(my_artists)))
print("difference: {}".format(bands.difference(my_artists)))

当从另一组调用时,此代码简单地显示为即可打印出每种方法的响应。 运行它会为我们提供以下输出:

my_artists is to bands:
issuperset: True
issubset: False
difference: {'Sarah Brightman', 'Vixy and Tony'}
********************
bands is to my_artists:
issuperset: False
issubset: True
difference: set()

在第二种情况下,difference方法返回一个空集,因为bands中没有my_artists中没有的项目。

unionintersectiondifference方法都可以采用多个集合作为参数。 如我们所料,它们将返回在所有参数上调用该操作时创建的集合。

因此,集合上的方法清楚地表明,集合是要在其他集合上运行的,而不仅仅是容器。 如果我们有来自两个不同来源的数据,并且需要以某种方式快速组合它们,以确定数据重叠或不同之处,则可以使用设置操作来有效地比较它们。 或者,如果我们收到的数据可能包含已经处理过的数据的重复项,则可以使用集合比较两者并仅处理新数据。

最后,知道有价值,当使用in关键字检查成员资格时,集合比列表更有效。 如果在集合或列表上使用语法value in container,则如果container中的元素之一等于value,则返回True,否则返回False。 但是,在列表中,它将查看容器中的每个对象,直到找到值为止;而在集合中,它只是对值进行哈希处理并检查成员资格。 这意味着无论容器有多大,集合都将在相同的时间内找到该值,但是随着列表包含越来越多的值,列表将花费越来越长的时间来搜索值。

扩展内置

现在,我们将更详细地说明何时需要这样做。

当我们有一个要添加功能的内置容器对象时,我们有两个选择。 我们可以创建一个新对象,将该容器作为属性保存(组成),也可以对内置对象进行子类化,并在其上添加或修改方法以完成我们想要的工作(继承)。

如果我们要做的就是使用容器来使用该容器的功能来存储一些对象,那么通常是最好的替代方法。 这样,就很容易将该数据结构传递给其他方法,并且他们将知道如何与之交互。 但是,如果我们想改变容器的实际工作方式,就需要使用继承。 例如,如果我们要确保list中的每个项目都是一个正好包含五个字符的字符串,则需要扩展list并覆盖append()方法以引发无效输入的异常。 我们还必须至少重写__setitem__(self, index, value),这是列表上的一种特殊方法,每当我们使用x[index] = "value"语法和extend()方法时都会调用该方法。

是的,列表是对象。 我们一直在寻找用于访问列表或字典键,遍历容器以及类似的任务的所有特殊的非面向对象的语法,实际上都是“语法糖”,它映射到下面的面向对象范例。 我们可能会问 Python 设计师为什么这样做。 面向对象编程总是不是更好吗? 这个问题很容易回答。 在下面的假设示例中,以程序员的身份更容易阅读? 哪个需要更少的输入?

c = a + b
c = a.add(b)

l[0] = 5
l.setitem(0, 5)
d[key] = value
d.setitem(key, value)

for x in alist:
    #do something with x
it = alist.iterator()
while it.has_next():
 x = it.next()
    #do something with x

突出显示的部分显示了面向对象的代码的外观(实际上,这些方法实际上作为关联对象上的特殊双下划线方法存在)。 Python 程序员一致认为,非面向对象的语法更易于阅读和编写。 然而,所有前面的 Python 语法在后台都映射到面向对象的方法。 这些方法有特殊的名称(前后都有双下划线),以提醒我们那里有更好的语法。 但是,它为我们提供了覆盖这些行为的手段。 例如,我们可以创建一个特殊的整数,当我们将两个整数相加时,该整数总是返回0

class SillyInt(int):
    def __add__(self, num):
        return 0

当然,这是一件极其奇怪的事情,但是它完美地说明了这些面向对象的原则在行动:

>>> a = SillyInt(1)
>>> b = SillyInt(2)
>>> a + b
0

关于__add__方法的很棒的事情是我们可以将其添加到我们编写的任何类中,如果我们在该类的实例上使用+运算符,则将调用它。 例如,这就是字符串,元组和列表串联的工作方式。

所有特殊方法都是如此。 如果我们要对自定义对象使用x in myobj语法,则可以实现__contains__。 如果要使用myobj[i] = value语法,则提供__setitem__方法,如果要使用something = myobj[i],则实现__getitem__

list类上有这些特殊方法中的 33 种。 我们可以使用dir函数查看所有这些信息:

>>> dir(list)

['__add__', '__class__', '__contains__', '__delattr__','__delitem__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getitem__', '__gt__', '__hash__', '__iadd__', '__imul__', '__init__', '__iter__', '__le__', '__len__', '__lt__', '__mul__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__reversed__', '__rmul__', '__setattr__', '__setitem__', '__sizeof__', '__str__', '__subclasshook__', 'append', 'count', 'extend', 'index', 'insert', 'pop', 'remove', 'reverse', 'sort'

此外,如果我们需要有关这些方法的工作方式的附加信息,可以使用help函数:

>>> help(list.__add__)
Help on wrapper_descriptor:

__add__(self, value, /)
 Return self+value.

列表上的加号运算符将两个列表连接在一起。 我们没有空间讨论此模块中所有可用的特殊功能,但是您现在可以使用dirhelp探索所有这些功能。 官方在线 Python 参考也提供了大量有用的信息。 特别要关注collections模块中讨论的抽象基类。

因此,回到关于何时使用组合与继承的较早点:如果我们需要以某种方式更改类中的任何方法(包括特殊方法),我们肯定需要使用继承。 如果使用组合,则可以编写进行验证或更改的方法,并要求调用方使用这些方法,但是没有什么可以阻止它们直接访问该属性。 他们可能会在我们的列表中插入一个不包含五个字符的项目,这可能会混淆列表中的其他方法。

通常,需要扩展内置数据类型表明我们使用了错误的数据类型。 并非总是如此,但是如果我们要扩展内置函数,则应仔细考虑是否应该使用其他数据结构。

例如,考虑创建一个能记住键插入顺序的字典的过程。 一种方法是保留存储在dict的特殊派生子类中的键的有序列表。 然后,我们可以覆盖方法keysvalues__iter__items以按顺序返回所有内容。 当然,我们还必须覆盖__setitem__setdefault,以使列表保持最新。 dir(dict)输出中可能还有其他一些方法需要重写,以使列表和字典保持一致(想到clear__delitem__,以跟踪何时删除项目),但我们不会 在此示例中,不必为它们担心。

因此,我们将扩展为dict ,并添加一个有序键列表。 琐碎的,但我们在哪里创建实际列表? 我们可以将其包含在__init__方法中,该方法可以正常工作,但我们不能保证任何子类都会调用该初始化程序。 还记得我们在第 2 章和 Python 中的对象中讨论的__new__方法吗? 我说过,它通常仅在非常特殊的情况下有用。 这是那些特殊情况之一。 我们知道__new__只会被调用一次,因此我们可以在新实例上创建一个列表,该列表将始终可供我们的类使用。 考虑到这一点,这是我们整个排序的字典:

from collections import KeysView, ItemsView, ValuesView
class DictSorted(dict):
    def __new__(*args, **kwargs):
        new_dict = dict.__new__(*args, **kwargs)
        new_dict.ordered_keys = []
        return new_dict

    def __setitem__(self, key, value):
        '''self[key] = value syntax'''
        if key not in self.ordered_keys:
            self.ordered_keys.append(key)
        super().__setitem__(key, value)

    def setdefault(self, key, value):
        if key not in self.ordered_keys:
            self.ordered_keys.append(key)
        return super().setdefault(key, value)

    def keys(self):
        return KeysView(self)

    def values(self):
        return ValuesView(self)

    def items(self):
        return ItemsView(self)

    def __iter__(self):
        '''for x in self syntax'''
        return self.ordered_keys.__iter__()

__new__方法创建一个新字典,然后在该对象上放置一个空列表。 我们不会覆盖__init__,因为默认实现有效(实际上,只有初始化一个空的DictSorted对象(这是标准行为),这才是正确的。如果我们要支持dict构造函数的其他变体, 其中接受字典或元组列表,我们需要修复__init__才能更新我们的ordered_keys列表)。 设置项目的两种方法非常相似。 它们都将更新键列表,但前提是之前未添加任何项。 我们不希望列表中有重复项,但是我们不能在此处使用集合。 它是无序的!

keysitemsvalues方法都将视图返回到字典。 集合库在字典上提供了三个只读View对象; 他们使用__iter__方法来遍历键,然后使用__getitem__(我们不需要重写)来检索值。 因此,我们只需要定义我们的自定义__iter__方法即可使这三个视图正常工作。 您可能会认为超类会使用多态性正确创建这些视图,但是如果我们不重写这三种方法,它们将不会返回正确排序的视图。

最后,__iter__方法是真正的特殊方法。 它确保了如果我们遍历字典的键(使用for ... in语法),它将以正确的顺序返回值。 它通过返回ordered_keys列表的__iter__来完成此操作,该列表返回与在列表中使用for ... in时将使用的迭代器对象相同的迭代器对象。 由于ordered_keys是所有可用键的列表(由于我们覆盖其他方法的方式),因此这也是字典的正确迭代器对象。

与普通字典相比,让我们看一下其中的一些方法:

>>> ds = DictSorted()
>>> d = {}
>>> ds['a'] = 1
>>> ds['b'] = 2
>>> ds.setdefault('c', 3)
3
>>> d['a'] = 1
>>> d['b'] = 2
>>> d.setdefault('c', 3)
3
>>> for k,v in ds.items():
...     print(k,v)
...
a 1
b 2
c 3
>>> for k,v in d.items():
...     print(k,v)
...
a 1
c 3
b 2

啊,我们的字典是按排序的,而普通字典不是。 欢呼!

注意

如果要在生产中使用此类,则必须重写其他几种特殊方法,以确保密钥在所有情况下都是最新的。 但是,您不需要这样做; 使用collections模块中的OrderedDict对象,该类提供的功能已在 Python 中提供。 尝试从collections导入类,然后使用help(OrderedDict)进一步了解它。

队列

队列是特有的数据结构,因为像集一样,它们的功能可以完全使用列表来处理。 但是,尽管列表是用途极为广泛的通用工具,但它们有时并不是容器操作的最有效数据结构。 如果您的程序使用的是小型数据集(在当今的处理器上多达数百甚至数千个元素),则列表可能会涵盖您的所有用例。 但是,如果您需要将数据规模扩展到数百万,则可能需要针对特定​​用例的更高效容器。 因此,Python 提供了三种类型的队列数据结构,具体取决于您要寻找的访问类型。 这三个都使用相同的 API,但是行为和数据结构都不同。

但是,在开始队列之前,请考虑信任列表数据结构。 对于许多用例,Python 列表是最有利的数据结构:

  • 它们支持有效地随机访问列表中的任何元素
  • 它们具有严格的元素顺序
  • 他们有效地支持附加操作

但是,如果在列表末尾以外的任何地方插入元素,它们往往会使变慢(特别是在列表的开头时)。 正如我们在集合一节中讨论的那样,它们对于检查列表中是否存在某个元素以及通过扩展进行搜索也很慢。 按排序顺序存储数据或对数据重新排序也可能效率不高。

让我们看一下 Python queue模块提供的三种容器。

FIFO 队列

FIFO 代表先进先出**,代表最普遍理解的单词“队列”定义。 想象有一群人在银行或收银机旁排队。 进入队伍的第一个人首先得到服务,队伍中的第二个人获得第二服务,如果有新人想要服务,他们会加入队伍的末端并等待轮到他们。**

Python Queue类就是这样。 当一个或多个对象正在生成数据而一个或多个其他对象以某种方式(可能以不同的速率)使用数据时,通常将其用作一种通信介质。 考虑一种正在从网络接收消息,但一次只能向用户显示一条消息的消息传递应用。 可以按照其他消息的接收顺序将它们缓存在队列中。 在此类并发应用中,FIFO 队列被大量利用。 (我们将在第 12 章,“测试面向对象程序”中详细讨论并发性。)

当您不需要访问要使用的下一个对象之外的数据结构中的任何数据时,Queue类是一个不错的选择。 为此使用列表的效率较低,因为在幕后,在列表开头插入数据(或从列表开头删除数据)可能需要移动列表中的所有其他元素。

队列具有非常简单的 API。 Queue可以具有“无限”(直到计算机内存用尽)容量,但通常限制为某个最大大小。 主要方法是put()get(),它们将元素原样添加到行的末尾,并从前开始按顺序检索它们。 这两种方法都接受可选参数,以控制如果由于队列为空(无法获取)或已满(无法放入)而无法成功完成操作时将发生的情况。 默认行为是阻止或空闲,直到Queue对象具有可用于完成操作的数据或空间。 您可以通过传递block=False参数来使其引发异常。 或者,您可以通过传递timeout参数让它等待指定的时间,然后引发异常。

该类还具有检查Queuefull()还是empty()的方法,并且还有一些其他方法来处理并发访问,我们将在这里不进行讨论。 这是一个交互式会话,展示了这些原理:

>>> from queue import Queue
>>> lineup = Queue(maxsize=3)
>>> lineup.get(block=False)
Traceback (most recent call last):
 File "<ipython-input-5-a1c8d8492c59>", line 1, in <module>
 lineup.get(block=False)
 File "/usr/lib64/python3.3/queue.py", line 164, in get
 raise Empty
queue.Empty
>>> lineup.put("one")
>>> lineup.put("two")
>>> lineup.put("three")
>>> lineup.put("four", timeout=1)
Traceback (most recent call last):
 File "<ipython-input-9-4b9db399883d>", line 1, in <module>
 lineup.put("four", timeout=1)
 File "/usr/lib64/python3.3/queue.py", line 144, in put
raise Full
queue.Full
>>> lineup.full()
True
>>> lineup.get()
'one'
>>> lineup.get()
'two'
>>> lineup.get()
'three'
>>> lineup.empty()
True

在幕后,Python 在collections.deque数据结构之上实现了队列。 双端队列是高级数据结构,可以有效访问集合的两端。 它提供的接口比Queue公开的接口更灵活。 如果您想尝试更多 Python 文档,请参考。

LIFO queues

LIFO后进先出)队列更多,经常被称为堆栈。 考虑一堆纸,您只能访问最上面的纸。 您可以将另一张纸放在纸叠的顶部,使其成为新的最上面的纸,或者可以拿走最上面的纸以露出下面的纸。

传统上,堆栈上的操作称为 push 和 pop,但是 Python queue模块使用与 FIFO 队列完全相同的 API:put()get()。 但是,在 LIFO 队列中,这些方法在堆栈的“顶部”操作,而不是在行的前面和后面。 这是多态性的一个很好的例子。 如果查看 Python 标准库中的Queue源代码,您实际上会看到一个超类,该超类具有 FIFO 和 LIFO 队列的子类,这些子类实现了一些操作(在堆栈的顶部而不是前面和后面)进行操作。 deque实例的返回)在两者之间存在重大差异。

这是运行中的 LIFO 队列的示例:

>>> from queue import LifoQueue
>>> stack = LifoQueue(maxsize=3)
>>> stack.put("one")
>>> stack.put("two")
>>> stack.put("three")
>>> stack.put("four", block=False)
Traceback (most recent call last):
 File "<ipython-input-21-5473b359e5a8>", line 1, in <module>
 stack.put("four", block=False)
 File "/usr/lib64/python3.3/queue.py", line 133, in put
 raise Full
queue.Full

>>> stack.get()
'three'
>>> stack.get()
'two'
>>> stack.get()
'one'
>>> stack.empty()
True
>>> stack.get(timeout=1)
Traceback (most recent call last):
 File "<ipython-input-26-28e084a84a10>", line 1, in <module>
 stack.get(timeout=1)
 File "/usr/lib64/python3.3/queue.py", line 175, in get
 raise Empty
queue.Empty

您可能会想知道为什么不能仅在标准列表上使用append()pop()方法。 坦率地说,这可能就是我要做的。 我很少有机会在生产代码中使用LifoQueue类。 使用列表末尾是一种有效的操作; 实际上,它是如此高效,以至于LifoQueue都在引擎盖下使用了标准清单!

有几个原因可能需要使用LifoQueue而不是列表。 最重要的是LifoQueue支持从多个线程进行干净的并发访问。 如果在并发设置中需要类似堆栈的行为,则应将列表放在家里。 其次,LifoQueue强制执行堆栈接口。 例如,您不能无意间将值插入LifoQueue中的错误位置(尽管作为练习,您可以找出如何完全有意识地做到这一点)。

优先队列

优先级队列强制执行与先前队列实现完全不同的排序方式。 再次,它们遵循完全相同的get()put() API,但是不是依赖项到达的顺序来确定何时应返回它们,而是返回最“重要”的项。 按照惯例,最重要或优先级最高的项是使用小于运算符对最低项进行排序的项。

通用约定是将元组存储在优先级队列中,其中元组中的第一个元素是该元素的优先级,第二个元素是数据。 如本章前面所述,另一个常见的范例是实现__lt__方法。 队列中有多个具有相同优先级的元素是完全可以接受的,尽管不能保证首先返回一个元素。

例如,搜索引擎可能会使用优先级队列来确保优先级队列在搜寻不太可能被搜索的网站之前刷新最流行网页的内容。 产品推荐工具可能会使用它来显示有关排名最高的产品的信息,同时仍会加载排名较低的数据。

请注意,优先级队列将始终返回队列中当前最重要的元素。 如果队列为空,get()方法将阻止(默认情况下),但是如果队列中已经有东西,则它不会阻止并等待添加更高优先级的元素。 队列对尚未添加的元素(甚至是先前已提取的元素)一无所知,仅根据队列的当前内容做出决定。

此交互式会话显示了一个运行中的优先级队列,使用元组作为权重来确定要处理哪些订单项:

>>> heap.put((3, "three"))
>>> heap.put((4, "four"))
>>> heap.put((1, "one") )
>>> heap.put((2, "two"))
>>> heap.put((5, "five"), block=False)
Traceback (most recent call last):
 File "<ipython-input-23-d4209db364ed>", line 1, in <module>
 heap.put((5, "five"), block=False)
 File "/usr/lib64/python3.3/queue.py", line 133, in put
 raise Full
Full
>>> while not heap.empty():
 print(heap.get())
(1, 'one')
(2, 'two')
(3, 'three')
(4, 'four')

优先级队列几乎都是使用heap数据结构实现的。 Python 的实现利用heapq模块将堆有效地存储在普通列表中。 我将您引向算法和数据结构的教科书,以获取有关堆的更多信息,更不用说我们这里未介绍的许多其他有趣的结构。 无论数据结构如何,都可以使用面向对象的原理来包装相关算法(行为),例如heapq模块中提供的算法,就像queue一样围绕它们在计算机内存中构造的数据。 模块已代表我们在标准库中完成。

案例研究

为了将结合在一起,我们将编写一个简单的链接收集器,该链接收集器将访问一个网站并收集在该网站上找到的每个页面上的每个链接。 不过,在开始之前,我们需要一些测试数据。 只需编写一些 HTML 文件即可使用,这些文件包含彼此之间的链接以及与 Internet 上其他站点的链接,如下所示:

<html>
    <body>
        <a href="contact.html">Contact us</a>
        <a href="blog.html">Blog</a>
        <a href="esme.html">My Dog</a>
        <a href="/hobbies.html">Some hobbies</a>
        <a href="/contact.html">Contact AGAIN</a>
        <a href="http://www.archlinux.org/">Favorite OS</a>
    </body>
</html>

命名其中一个文件index.html,以便在提供页面时它首先显示。 确保其他文件存在,并使事情复杂,以便它们之间有很多链接。 如果您不想自己进行设置,则本章的示例包括一个名为case_study_serve的目录(现存最糟糕的个人网站之一!)。

现在,通过输入包含所有这些文件的目录来启动简单的 Web 服务器,然后运行以下命令:

python3 -m http.server

这将启动在端口 8000 上运行的服务器。 您可以在网络浏览器中访问http://localhost:8000/来查看创建的页面。

注意

我怀疑任何人都可以以更少的工作来建立并运行一个网站! 永远不要说“用 Python 不能轻易做到这一点”。

目标是传递给我们的收集器网站的基本 URL(在本例中为http://localhost:8000/),并使其创建一个包含该网站上每个唯一链接的列表。 我们需要考虑三种类型的 URL(到其他站点的链接(以http://开头的外部站点链接,以/字符开头的绝对内部链接和相对链接))。 我们还需要注意,页面可能会循环链接在一起; 我们需要确保不会多次处理同一个页面,否则它可能永远也不会结束。 随着所有这些独特性的进行,听起来我们将需要一些集合。

在开始讨论之前,让我们从基础开始。 我们需要什么代码才能连接到页面并解析该页面上的所有链接?

from urllib.request import urlopen
from urllib.parse import urlparse
import re
import sys
LINK_REGEX = re.compile(
        "<a [^>]*href=['\"]([^'\"]+)['\"][^>]*>")

class LinkCollector:
    def __init__(self, url):
        self.url = "" + urlparse(url).netloc

    def collect_links(self, path="/"):
        full_url = self.url + path
        page = str(urlopen(full_url).read())
        links = LINK_REGEX.findall(page)
        print(links)

if __name__ == "__main__":
    LinkCollector(sys.argv[1]).collect_links()

考虑到它在做什么,这是一个短代码。 它通过命令行传递的参数连接到服务器,下载页面,并提取该页面上的所有链接。 __init__方法使用urlparse函数从 URL 中仅提取主机名; 因此,即使我们传入http://localhost:8000/some/page.html,它仍将在主机http://localhost:8000/的顶层运行。 这是有道理的,因为我们希望收集站点上的所有链接,尽管它假定每个页面都是通过某些链接序列连接到索引的。

collect_links方法连接到服务器并从服务器下载指定的页面,并使用正则表达式查找页面中的所有链接。 正则表达式是一个非常强大的字符串处理工具。 不幸的是,他们的学习曲线陡峭。 如果您以前从未使用过它们,我强烈建议您学习有关该主题的所有书籍或网站。 如果您认为不值得了解它们,请尝试在没有它们的情况下编写前面的代码,您会改变主意。

该示例也停在collect_links方法的中间,以打印链接的值。 这是在编写程序时测试程序的一种常见方法:停止并输出值以确保它是我们期望的值。 这是我们的示例输出的内容:

['contact.html', 'blog.html', 'esme.html', '/hobbies.html',
'/contact.html', 'http://www.archlinux.org/']

现在,我们在第一页中包含了所有链接的集合。 我们该怎么办? 我们不能只是将链接弹出到集合中以删除重复项,因为链接可能是相对的或绝对的。 例如,contact.html/contact.html指向同一页面。 因此,我们要做的第一件事是规范所有指向其完整 URL 的链接,包括主机名和相对路径。 我们可以通过向我们的对象添加normalize_url方法来做到这一点:

    def normalize_url(self, path, link):
        if link.startswith("http://"):
            return link
        elif link.startswith("/"):
            return self.url + link
        else:
            return self.url + path.rpartition(
                '/')[0] + '/' + link

此方法将每个 URL 转换为包含协议和主机名的完整地址。 现在,两个联系人页面具有相同的值,我们可以将它们存储在一组中。 我们必须修改__init__来创建集合,并修改collect_links来将所有链接放入其中。

然后,我们必须访问所有非外部链接并收集它们。 等一下 如果这样做,当我们两次遇到相同页面时,如何避免重新访问链接? 看来我们实际上需要两个集合:一组收集的链接和一组已访问的链接。 这表明我们明智的选择一个代表数据的集合。 我们知道,当我们要处理多个集合时,集合是最有用的。 让我们进行设置:

class LinkCollector:
    def __init__(self, url):
        self.url = "http://+" + urlparse(url).netloc
        self.collected_links = set()
        self.visited_links = set()

    def collect_links(self, path="/"):
        full_url = self.url + path
        self.visited_links.add(full_url)
        page = str(urlopen(full_url).read())
        links = LINK_REGEX.findall(page)
        links = {self.normalize_url(path, link
            ) for link in links}
        self.collected_links = links.union(
                self.collected_links)
        unvisited_links = links.difference(
                self.visited_links)
        print(links, self.visited_links,
                self.collected_links, unvisited_links)

创建标准化链接列表的行使用set理解,与列表理解没有区别,不同之处在于结果是一组值。 我们将在下一章详细介绍这些内容。 再次,该方法停止打印当前值,因此我们可以验证我们没有混淆集合,并且difference确实是我们要调用的用于收集unvisited_links的方法。 然后,我们可以添加几行代码来循环所有未访问的链接,并将它们也添加到集合中:

        for link in unvisited_links:
            if link.startswith(self.url):
                self.collect_links(urlparse(link).path)

if语句确保我们仅从一个网站收集链接; 我们不想离开并收集 Internet 上所有页面的所有链接(除非我们是 Google 或 Internet 存档!)。 如果我们修改程序底部的主要代码以输出收集的链接,我们可以看到似乎已经收集了所有链接:

if __name__ == "__main__":
    collector = LinkCollector(sys.argv[1])
    collector.collect_links()
    for link in collector.collected_links:
        print(link)

它仅显示一次我们收集的所有链接,即使示例中的许多页面多次链接在一起也是如此:

$ python3 link_collector.py http://localhost:8000
http://localhost:8000/
http://en.wikipedia.org/wiki/Cavalier_King_Charles_Spaniel
http://beluminousyoga.com
http://archlinux.me/dusty/
http://localhost:8000/blog.html
http://ccphillips.net/
http://localhost:8000/contact.html
http://localhost:8000/taichi.html
http://www.archlinux.org/
http://localhost:8000/esme.html
http://localhost:8000/hobbies.html

即使它收集了外部页面的链接,它也没有从链接到我们链接到的任何外部页面中收集链接*。 如果我们要收集站点中的所有链接,这是一个很棒的小程序。 但这并不能为我提供构建站点地图可能需要的所有信息。 它告诉我我有哪些页面,但没有告诉我哪些页面链接到其他页面。 如果要改为执行此操作,则必须进行一些修改。*

我们应该做的第一件事是查看我们的数据结构。 收集的链接集不再起作用。 我们想知道哪些链接链接到哪些页面。 然后,我们要做的第一件事就是将所设置的页面变成每个访问页面的页面字典。 字典键将代表集合中当前完全相同的数据。 这些值将是该页面上所有链接的集合。 更改如下:

from urllib.request import urlopen
from urllib.parse import urlparse
import re
import sys
LINK_REGEX = re.compile(
        "<a [^>]*href=['\"]([^'\"]+)['\"][^>]*>")

class LinkCollector:
    def __init__(self, url):
        self.url = "http://%s" % urlparse(url).netloc
        self.collected_links = {}
        self.visited_links = set()

    def collect_links(self, path="/"):
        full_url = self.url + path
        self.visited_links.add(full_url)
        page = str(urlopen(full_url).read())
        links = LINK_REGEX.findall(page)
        links = {self.normalize_url(path, link
            ) for link in links}
        self.collected_links[full_url] = links
        for link in links:
            self.collected_links.setdefault(link, set())
        unvisited_links = links.difference(
                self.visited_links)
        for link in unvisited_links:
            if link.startswith(self.url):
                self.collect_links(urlparse(link).path)

    def normalize_url(self, path, link):
        if link.startswith("http://"):
            return link
        elif link.startswith("/"):
            return self.url + link
        else:
            return self.url + path.rpartition('/'
                    )[0] + '/' + link
if __name__ == "__main__":
    collector = LinkCollector(sys.argv[1])
    collector.collect_links()
    for link, item in collector.collected_links.items():
        print("{}: {}".format(link, item))

这是一个令人惊讶的小更改; 最初创建两个集合的并集的行已替换为更新字典的三行。 其中第一个只是告诉字典该页面收集的链接是什么。 第二个方法使用setdefault为字典中尚未添加到字典中的任何项目创建一个空集。 结果是一个字典,其中包含所有链接作为其键,映射到所有内部链接的链接集和外部链接的空集。

最后,我们可以使用队列来存储尚未处理的链接,而不必递归调用collect_links。 此实现不支持此实现,但这将是创建多线程版本的好第一步,该版本可以并行发出多个请求以节省时间。

from urllib.request import urlopen
from urllib.parse import urlparse
import re
import sys
from queue import Queue
LINK_REGEX = re.compile("<a [^>]*href=['\"]([^'\"]+)['\"][^>]*>")

class LinkCollector:
    def __init__(self, url):
        self.url = "http://%s" % urlparse(url).netloc
        self.collected_links = {}
        self.visited_links = set()

    def collect_links(self):
        queue = Queue()
        queue.put(self.url)
        while not queue.empty():
            url = queue.get().rstrip('/')
            self.visited_links.add(url)
            page = str(urlopen(url).read())
            links = LINK_REGEX.findall(page)
            links = {
                self.normalize_url(urlparse(url).path, link)
                for link in links
            }
            self.collected_links[url] = links
            for link in links:
                self.collected_links.setdefault(link, set())
            unvisited_links = links.difference(self.visited_links)
            for link in unvisited_links:
                if link.startswith(self.url):
                    queue.put(link)

    def normalize_url(self, path, link):
        if link.startswith("http://"):
            return link.rstrip('/')
        elif link.startswith("/"):
            return self.url + link.rstrip('/')
        else:
            return self.url + path.rpartition('/')[0] + '/' + link.rstrip('/')

if __name__ == "__main__":
    collector = LinkCollector(sys.argv[1])
    collector.collect_links()
    for link, item in collector.collected_links.items():
        print("%s: %s" % (link, item))

我必须手动剥离normalize_url方法中的所有尾随正斜杠,以删除此版本代码中的重复项。

由于最终结果是未排序的字典,因此对链接的处理顺序没有限制。因此,在这里我们可以很容易地使用LifoQueue而不是Queue。 优先级队列可能没有多大意义,因为在这种情况下,没有明显的优先级附加到链接。

Case study

Case study

Case study