Skip to content

Latest commit

 

History

History
946 lines (656 loc) · 52.4 KB

File metadata and controls

946 lines (656 loc) · 52.4 KB

八、Python 面向对象的快捷方式

Python 的许多方面似乎比面向对象的编程更让人联想到结构或功能编程。 尽管在过去的二十年中,面向对象的编程一直是最明显的范例,但是旧模型却在最近复苏了。 与 Python 的数据结构一样,大多数这些工具都是基于底层面向对象实现的语法糖。 我们可以将它们视为基于(已经抽象的)面向对象范式之上的进一步抽象层。 在本章中,我们将介绍并非严格面向对象的 Python 功能:

  • 内置功能可一键处理常见任务
  • 文件 I / O 和上下文管理器
  • 方法重载的替代方法
  • 作为对象

Python 内置函数

Python 中有个函数,它们可以对某些类型的对象执行任务或计算结果,而无需作为基础类的方法。 他们通常抽象适用于多种类型的类的通用计算。 这是最好的鸭子打字方式。 这些函数接受具有某些属性或方法的对象,并能够使用这些方法执行常规操作。 其中许多(但不是全部)是特殊的双下划线方法。 我们已经使用了许多内置函数,但是让我们快速浏览重要的函数,并逐步学习一些巧妙的技巧。

len()函数

最简单的示例是len()函数,该函数计算某种容器对象(例如字典或列表)中的项目数。 您之前看过:

>>> len([1,2,3,4])
4

为什么这些对象不具有 length 属性,而不必在其上调用函数? 从技术上讲,他们做到了。 len()将应用的大多数对象都具有一个称为__len__()的方法,该方法返回相同的值。 因此len(myobj)似乎叫myobj.__len__()

为什么我们应该使用len()函数而不是__len__方法? 显然__len__是一种特殊的双下划线方法,建议我们不要直接调用它。 对此必须有一个解释。 Python 开发人员不会轻易做出此类设计决策。

主要原因是效率。 当我们在对象上调用__len__时,该对象必须在其命名空间中查找该方法,并且,如果定义了特殊的__getattribute__方法(每次访问对象上的属性或方法时都会调用该方法), 该对象,也必须调用它。 此外,针对该特定方法的__getattribute__可能被编写为令人讨厌,例如拒绝让我们访问诸如__len__之类的特殊方法! len()功能没有遇到任何这些。 它实际上在基础类上调用__len__函数,因此len(myobj)映射到MyObj.__len__(myobj)

另一个原因是可维护性。 将来,Python 开发人员可能希望更改len(),以便可以计算不具有__len__的对象的长度,例如,通过计算迭代器中返回的项目数。 他们只需要更改一项功能,而不必更改无数种__len__方法。

len()作为外部函数还有另一个极其重要且经常被忽略的原因:向后兼容。 这在文章中经常被引用为“出于历史原因”,这是一个轻微的不屑一顾的词组,作者会用它来表达某些事情,因为很早以前就犯了一个错误,我们一直坚持下去。 严格来说,len()不是错误,它是设计决策,但是该决策是在较少的面向对象的时间内完成的。 它经受了时间的考验并具有一些好处,因此请习惯它。

反转

reversed()函数将任何序列作为输入,并以相反的顺序返回该序列的副本。 当我们要从头到尾循环播放项目时,通常在for循环中使用。

len相似,reversed对该参数的类调用__reversed__()函数。 如果该方法不存在,则reversed会使用对__len____getitem__的调用来构建反向序列本身,这些调用用于定义序列。 如果我们想以某种方式自定义或优化流程,则只需要覆盖__reversed__

normal_list=[1,2,3,4,5]

class CustomSequence():
 def __len__(self):
        return 5

 def __getitem__(self, index):
        return "x{0}".format(index)

class FunkyBackwards():

 def __reversed__(self):
        return "BACKWARDS!"

for seq in normal_list, CustomSequence(), FunkyBackwards():
    print("\n{}: ".format(seq.__class__.__name__), end="")
    for item in reversed(seq):
        print(item, end=", ")

最后的for循环会打印正常列表的反向版本以及两个自定义序列的实例。 输出显示reversed适用于所有这三个参数,但是当我们自己定义__reversed__时,结果却大不相同:

list: 5, 4, 3, 2, 1,
CustomSequence: x4, x3, x2, x1, x0,
FunkyBackwards: B, A, C, K, W, A, R, D, S, !,

当我们反转CustomSequence时,将为每个项目调用__getitem__方法,该方法只是在索引之前插入x。 对于FunkyBackwards__reversed__ 方法返回一个字符串,其每个字符在for循环中分别输出。

注意

前面的两个类不是很好的序列,因为它们没有定义__iter__的正确版本,因此对它们的正向for循环将永远不会结束。

上市

有时,当我们在for循环中的容器上循环时,我们想要访问正在处理的当前项目的索引(列表中的当前位置)。 for循环没有为我们提供索引,但是enumerate函数为我们提供了更好的东西:它创建了一个元组序列,其中每个元组中的第一个对象是索引,第二个对象是原始项。

如果我们需要直接使用索引号,这将很有用。 考虑一些简单的代码,该文件输出具有行号的文件中的每一行:

import sys
filename = sys.argv[1]

with open(filename) as file:
 for index, line in enumerate(file):
        print("{0}: {1}".format(index+1, line), end='')

使用其自己的文件名作为输入文件来运行此代码,将显示其工作方式:

1: import sys
2: filename = sys.argv[1]
3:
4: with open(filename) as file:
5:     for index, line in enumerate(file):
6:         print("{0}: {1}".format(index+1, line), end='')

enumerate函数返回一个元组序列,我们的for循环将每个元组分成两个值,然后print语句将它们格式化在一起。 因为enumerate与所有序列一样,都是从零开始的,所以它会为每个行号的索引加 1。

我们只涉及了一些更重要的 Python 内置函数。 如您所见,其中许多调用了面向对象的概念,而另一些则订阅了纯粹的功能或过程范例。 标准库中还有许多其他内容。 一些更有趣的包括:

  • allany,它们接受一个可迭代的对象并返回True,如果所有或任何一项的计算结果为 true(例如,非空字符串或列表,非零数字,不是None的对象 ]或文字True)。
  • evalexeccompile,它们在解释器内部将字符串作为代码执行。 小心这些。 它们不安全,因此请不要执行未知用户提供给您的代码(通常,假定所有未知用户都是恶意的,愚蠢的,或两者兼有)。
  • hasattrgetattrsetattrdelattr,它们可以通过对象的字符串名称来操纵对象上的属性。
  • zip,它接受两个或更多序列,并返回一个新的元组序列,其中每个元组都包含来自每个序列的单个值。
  • 还有很多! 有关dir(__builtins__)中列出的每个功能,请参见解释器帮助文档。

文件 I / O

到目前为止,我们的示例已完全在文本文件上操作了触摸文件系统,而没有过多考虑幕后情况。 但是,操作系统实际上将文件表示为字节序列,而不是文本。 请注意,从文件读取文本数据是一个相当复杂的过程。 Python,尤其是 Python 3,在幕后为我们完成了大部分工作。 我们不是很幸运吗?

文件的概念早在任何人创造面向对象编程这个术语之前就已经存在。 但是,Python 将操作系统提供的接口包装成了一个甜美的抽象,使我们能够处理文件(或类似文件,相对于鸭子输入)对象。

open()内置函数用于打开文件并返回文件对象。 为了从文件中读取文本,我们只需要将文件名传递给函数。 该文件将打开以供读取,并且字节将使用平台默认编码转换为文本。

当然,我们并不总是要读取文件;而是要读取文件。 通常我们想向他们写数据! 要打开要写入的文件,我们需要传递mode参数作为第二个位置参数,值"w"

contents = "Some file contents"
file = open("filename", "w")
file.write(contents)
file.close()

我们还可以提供"a"值作为模式参数,以附加到文件末尾,而不是完全覆盖现有文件内容。

这些带有用于将字节转换为文本的内置包装器的文件很棒,但是如果我们要打开的文件是图像,可执行文件或其他二进制文件,那将是非常不便的,不是吗?

要打开二进制文件,我们修改模式字符串以附加'b'。 因此,'wb'将打开一个文件来写入字节,而'rb'则允许我们读取它们。 它们的行为类似于文本文件,但没有将文本自动编码为字节的功能。 当我们读取这样的文件时,它将返回bytes对象而不是str,并且在写入该文件时,如果尝试传递文本对象,它将失败。

注意

这些用于控制如何打开文件的模式字符串相当隐秘,既不是 pythonic 也不是面向对象的。 但是,它们与几乎所有其他编程语言都一致。 文件 I / O 是操作系统必须处理的基本工作之一,并且所有编程语言都必须使用相同的系统调用与 OS 进行通信。 很高兴 Python 返回具有有用方法的文件对象,而不是大多数主要操作系统用来标识文件句柄的整数!

打开文件以供阅读后,我们可以调用readreadlinereadlines方法来获取文件的内容。 read方法根据模式中是否存在'b',将文件的全部内容作为strbytes对象返回。 注意不要在没有大文件的情况下使用此方法。 您不想弄清楚如果尝试将那么多数据加载到内存中会发生什么!

也可以从文件中读取固定数量的字节。 我们将一个整数参数传递给read方法,以描述要读取的字节数。 下次调用read将加载下一个字节序列,依此类推。 我们可以在while循环中执行此操作,以可管理的块形式读取整个文件。

readline方法从文件返回一行(其中每一行以换行符结尾,回车符或两者都结束,这取决于在其上创建文件的操作系统)。 我们可以反复调用它以获得更多行。 复数readlines方法返回文件中所有行的列表。 像read方法一样,在非常大的文件上使用也不安全。 这两种方法甚至都可以在bytes模式下打开文件时起作用,但是只有当我们分析在适当位置具有换行符的类似文本的数据时,这才有意义。 例如,图像或音频文件中将没有换行符(除非换行字节恰好代表某个像素或声音),因此应用readline毫无意义。

为了提高可读性,并避免一次将大文件读入内存,通常最好直接在文件对象上使用for循环。 对于文本文件,它将一次读取每一行,我们可以在循环体内对其进行处理。 对于二进制文件,最好使用read()方法读取固定大小的数据块,并传递最大读取字节数的参数。

写入文件同样容易。 文件对象上的write方法将字符串(或字节,对于二进制数据)对象写入文件。 可以反复调用以写入多个字符串,一个接一个。 writelines方法接受字符串序列,并将每个迭代值写入文件。 writelines方法不会而不是在序列中的每个项目之后添加新行。 从根本上来说,方便的函数是一个不好命名的函数,它可以编写字符串序列的内容,而不必使用for循环对其进行显式迭代。

最后,我的意思是,最后,我们来谈谈close方法。 当我们完成文件的读取或写入后,应调用此方法,以确保将所有缓冲的写操作写入磁盘,已正确清理了文件以及与该文件关联的所有资源都释放回了操作系统。 。 从技术上讲,这将在脚本退出时自动发生,但最好是显式并自行清理,尤其是在长时间运行的进程中。

将其放在上下文中

需要在完成文件处理后关闭文件,这会使我们的代码很难看。 由于在文件 I / O 期间随时可能发生异常,因此我们应该将对文件的所有调用包装在try ... finally子句中。 不管 I / O 是否成功,都应在finally子句中关闭该文件。 这不是非常 Pythonic。 当然,还有一种更优雅的方法。

如果在类似文件的对象上运行dir,我们会看到它有两个名为__enter____exit__的特殊方法。 这些方法将文件对象转换为所谓的上下文管理器。 基本上,如果我们使用称为with语句的特殊语法,则将在执行嵌套代码之前和之后调用这些方法。 在文件对象上,__exit__方法可确保关闭文件,即使引发异常也是如此。 我们不再需要显式管理文件的关闭。 实际上,这是with语句的样子:

with open('filename') as file:
    for line in file:
        print(line, end='')

open调用返回一个文件对象,该对象具有__enter____exit__方法。 通过as子句将返回的对象分配给名为file的变量。 我们知道,当代码返回到外部缩进级别时,文件将关闭,并且即使引发异常也会发生这种情况。

with语句在标准库中需要执行启动或清除代码的多个位置中使用。 例如,urlopen调用返回一个对象,该对象可以在完成后的with语句中用于清理套接字。 执行语句后,线程模块中的锁可以自动释放锁。

大多数有趣,因为with语句可以应用于具有适当特殊方法的任何对象,因此我们可以在自己的框架中使用它。 例如,记住字符串是不可变的,但是有时您需要从多个部分构建一个字符串。 为了提高效率,通常是通过将组件字符串存储在列表中并在最后将它们连接起来来完成的。 让我们创建一个简单的上下文管理器,它允许我们构造一个字符序列,并在退出时自动将其转换为字符串:

class StringJoiner(list):
    def __enter__(self):
        return self

    def __exit__(self, type, value, tb):
        self.result = "".join(self)

此代码将上下文管理器所需的两个特殊方法添加到其继承的list类。 __enter__方法执行任何必需的设置代码(在这种情况下,没有任何设置代码),然后在with语句中的as之后返回将分配给变量的对象。 通常,就像我们在这里所做的那样,这仅仅是上下文管理器对象本身。 __exit__方法接受三个参数。 在正常情况下,这些值都被赋予None的值。 但是,如果with块内发生异常,则将它们设置为与异常的类型,值和追溯相关的值。 这使__exit__方法可以执行可能需要的任何清除代码,即使发生异常也是如此。 在我们的示例中,我们采用不负责任的路径并通过将字符串中的字符连接在一起来创建结果字符串,而不管是否引发了异常。

尽管这是我们可以编写的最简单的上下文管理器之一,并且其用途尚不确定,但它确实可以与with语句一起使用。 看一下它的作用:

import random, string
with StringJoiner() as joiner:
    for i in range(15):
        joiner.append(random.choice(string.ascii_letters))

print(joiner.result)

此代码构造了一个由 15 个随机字符组成的字符串。 使用从list继承的append方法将它们附加到StringJoiner。 当with语句超出范围(回到外部缩进级别)时,将调用__exit__方法,并且result属性在连接器对象上变为可用。 我们打印该值以查看随机字符串。

方法重载的替代方法

许多面向对象编程语言中的一个突出特征是称为方法重载的工具。 方法重载只是指具有多个名称相同的方法,它们接受不同的参数集。 在静态类型的语言中,如果我们想要一个可以接受整数或字符串的方法,这将非常有用。 在非面向对象的语言中,我们可能需要两个函数add_sadd_i来适应这种情况。 在静态类型的面向对象的语言中,我们需要两种方法,两种方法都称为add,一种方法接受字符串,而另一种方法接受整数。

在 Python 中,我们只需要一个方法即可接受任何类型的对象。 它可能必须对对象类型进行一些测试(例如,如果它是字符串,则将其转换为整数),但是仅需要一种方法。

但是,当我们希望具有相同名称的方法接受不同的数字或参数集时,方法重载也很有用。 例如,电子邮件方法可能有两个版本,其中一个版本接受“发件人”电子邮件地址的参数。 另一种方法可能会查找默认的“发件人”电子邮件地址。 Python 不允许使用多个具有相同名称的方法,但它确实提供了一个不同的,同样灵活的接口。

在前面的示例中,我们已经看到了一些向方法和函数发送参数的可能方法,但是现在我们将涵盖所有细节。 最简单的函数不接受任何参数。 我们可能不需要示例,但是为了完整起见,下面是一个示例:

def no_args():
    pass

称呼为:

no_args()

确实接受参数的函数将在逗号分隔的列表中提供这些参数的名称。 只需要提供每个参数的名称。

调用函数时,必须按顺序指定这些位置参数,并且不能遗漏或跳过这些位置参数。 这是在前面的示例中指定参数的最常见方式:

def mandatory_args(x, y, z):
    pass

调用它:

mandatory_args("a string", a_variable, 5)

任何类型的对象都可以作为参数传递:对象,容器,基元甚至函数和类。 前面的调用显示了一个硬编码的字符串,一个未知变量和一个传入函数的整数。

默认参数

如果我们希望使参数成为可选参数,而不是创建带有不同参数集的第二种方法,则可以在单个方法中使用等号指定默认值。 如果调用代码不提供此参数,则将为其分配默认值。 但是,调用代码仍然可以选择通过传递其他值来覆盖默认值。 通常,默认值为None或空字符串或列表都是合适的。

这是带有默认参数的函数定义:

def default_arguments(x, y, z, a="Some String", b=False):
    pass

前三个参数仍然是必需的,并且必须由调用代码传递。 最后两个参数提供了默认参数。

有几种方法可以调用此函数。 我们可以按顺序提供所有参数,就像所有参数都是位置参数一样:

default_arguments("a string", variable, 8, "", True)

或者,我们可以仅按顺序提供必需参数,而将关键字参数保留为其默认值:

default_arguments("a longer string", some_variable, 14)

在调用函数以不同顺序提供值或跳过我们不感兴趣的默认值时,我们还可以使用等号语法。例如,我们可以跳过第一个关键字参数并提供第二个关键字参数:

default_arguments("a string", variable, 14, b=True)

令人惊讶的是,只要提供了所有位置参数,我们甚至可以使用等号语法来混合位置参数的顺序:

>>> default_arguments(y=1,z=2,x=3,a="hi")
3 1 2 hi False

有这么多的选项,似乎很难选择一个,但是如果您将位置参数视为有序列表,而将关键字参数视为像字典一样,则会发现正确的布局趋于下降 到位。 如果您需要要求调用者指定一个参数,则将其设为必需; 如果您有合理的默认值,则将其设为关键字参数。 选择方法的调用方法通常会自行处理,具体取决于需要提供哪些值,以及哪些可以保留为默认值。

关键字参数要注意的一件事是,我们作为默认参数提供的任何内容都是在首次解释该函数时(而不是在调用它时)进行评估的。 这意味着我们不能动态生成默认值。 例如,下面的代码将无法达到预期的效果:

number = 5
def funky_function(number=number):
    print(number)

number=6
funky_function(8)
funky_function()
print(number)

如果我们运行此代码,它将首先输出数字 8,但随后将输出不带参数的调用的数字 5。 正如输出的最后一行所示,我们已将变量设置为数字 6,但是当调用函数时,将打印数字 5;否则,将输出 5。 默认值是在定义函数时计算的,而不是在调用函数时计算的。

使用空容器(例如列表,集合和字典)时,这很棘手。 例如,通常要求调用代码提供我们的函数将要操作的列表,但是该列表是可选的。 我们想将一个空列表作为默认参数。 我们不能这样做; 首次构建代码时,它将仅创建一个列表:

>>> def hello(b=[]):
...     b.append('a')
...     print(b)
...
>>> hello()
['a']
>>> hello()
['a', 'a']

糟糕,这并不是我们期望的! 解决此问题的常用方法是设置默认值None,然后在方法内部使用惯用语iargument = argument if argument else []。 密切关注!

变量参数列表

仅使用默认值并不能为我们提供方法重载的所有灵活好处。 使 Python 真正精巧的是能够编写无需任意命名位置或关键字参数的方法的能力。 我们还可以将任意列表和字典传递给此类函数。

例如,接受链接或链接列表并下载网页的功能可以使用此类可变参数或 varargs。 我们可以接受任意数量的参数,而每个参数是一个不同的链接,而不是接受一个期望的链接列表的单个值。 我们通过在函数定义中指定*运算符来做到这一点:

def get_pages(*links):
    for link in links:
        #download the link with urllib
        print(link)

*links参数说:“我将接受任意数量的参数,并将它们全部放在名为links的列表中”。 如果我们仅提供一个参数,它将是一个包含一个元素的列表。 如果我们不提供任何参数,它将是一个空列表。 因此,所有这些函数调用均有效:

get_pages()
get_pages('http://www.archlinux.org')
get_pages('http://www.archlinux.org',
        'http://ccphillips.net/')

我们还可以接受任意关键字参数。 这些作为字典进入函数。 它们在函数声明中带有两个星号(如**kwargs中所示)。 此工具通常在配置设置中使用。 下列类使我们可以指定一组具有默认值的选项:

class Options:
    default_options = {
            'port': 21,
            'host': 'localhost',
            'username': None,
            'password': None,
            'debug': False,
            }
    def __init__(self, **kwargs):
        self.options = dict(Options.default_options)
        self.options.update(kwargs)

    def __getitem__(self, key):
        return self.options[key]

此类中所有有趣的东西都在__init__方法中发生。 我们在类级别上有一个默认选项和值的字典。 __init__方法要做的第一件事是复制此词典。 我们这样做是为了避免实例化两组单独的选项,而不是直接修改字典。 (请记住,在类的实例之间共享类级别的变量。)然后,__init__使用新字典上的update方法将所有非默认值更改为作为关键字参数提供的值。 __getitem__方法仅允许我们使用索引语法来使用新类。 这是一个演示班级活动的课程:

>>> options = Options(username="dusty", password="drowssap",
 debug=True)
>>> options['debug']
True
>>> options['port']
21
>>> options['username']
'dusty'

我们可以使用字典索引语法访问选项实例,并且字典既包含默认值,也包含使用关键字参数设置的值。

关键字参数语法可能很危险,因为它可能违反“显式优于隐式”规则。 在前面的示例中,可以将任意关键字参数传递给Options初始化程序,以表示默认字典中不存在的选项。 根据类的目的,这可能不是一件坏事,但这使使用该类的人很难发现可用的有效选项。 这也使输入混乱的错字(例如,“ Debug”而不是“ debug”)变得容易,该错字在只有一个应有的地方添加了两个选项。

当我们需要接受任意参数以传递给第二个函数时,关键字参数也非常有用,但是我们不知道这些参数将是什么。 当然,我们可以在一个函数调用中结合使用变量参数和变量关键字参数语法,并且也可以使用普通的位置参数和默认参数。 下面的示例有些人为设计,但演示了四种实际的类型:

import shutil
import os.path
def augmented_move(target_folder, *filenames,
        verbose=False, **specific):
    '''Move all filenames into the target_folder, allowing
    specific treatment of certain files.'''

    def print_verbose(message, filename):
        '''print the message only if verbose is enabled'''
        if verbose:
            print(message.format(filename))

    for filename in filenames:
        target_path = os.path.join(target_folder, filename)
        if filename in specific:
            if specific[filename] == 'ignore':
                print_verbose("Ignoring {0}", filename)
            elif specific[filename] == 'copy':
                print_verbose("Copying {0}", filename)
                shutil.copyfile(filename, target_path)
        else:
            print_verbose("Moving {0}", filename)
            shutil.move(filename, target_path)

本示例将处理文件的任意列表。 第一个参数是目标文件夹,默认行为是将所有剩余的非关键字参数文件移动到该文件夹​​中。 然后有一个仅关键字的参数verbose,它告诉我们是否在每个处理的文件上打印信息。 最后,我们可以提供一个字典,其中包含对特定文件名执行的操作; 默认行为是移动文件,但是如果在关键字参数中指定了有效的字符串操作,则可以忽略或复制它。 注意函数中参数的顺序; 首先指定位置参数,然后指定*filenames列表,然后指定所有仅关键字的参数,最后指定**specific词典来保存其余的关键字参数。

我们创建一个内部帮助程序功能print_verbose,仅在设置了verbose键的情况下才会打印消息。 通过将此功能封装到单个位置,该功能可保持代码可读性。

在常见情况下,假设存在相关文件,此函数可以称为:

>>> augmented_move("move_here", "one", "two")

该命令会将文件onetwo移到move_here目录中(假设它们存在)(该函数中没有错误检查或异常处理,因此如果文件或目标目录未包含错误或失败) 不存在)。 由于默认情况下verboseFalse,因此该移动将没有任何输出。

如果要查看输出,可以使用以下命令进行调用:

>>> augmented_move("move_here", "three", verbose=True)
Moving three

这将移动一个名为three的文件,并告诉我们它在做什么。 注意,在此示例中,不可能将verbose指定为位置参数。 我们必须传递关键字参数。 否则,Python 会认为它是*filenames列表中的另一个文件名。

如果我们要复制或忽略列表中的某些文件,而不是移动它们,则可以传递其他关键字参数:

>>> augmented_move("move_here", "four", "five", "six",
 four="copy", five="ignore")

这将移动第六个文件并复制第四个文件,但由于我们未指定verbose,因此不会显示任何输出。 当然,我们也可以这样做,并且关键字参数可以按任何顺序提供:

>>> augmented_move("move_here", "seven", "eight", "nine",
 seven="copy", verbose=True, eight="ignore")
Copying seven
Ignoring eight
Moving nine

解压缩参数

还有个更巧妙的技巧,涉及可变参数和关键字参数。 我们在之前的一些示例中使用了它,但是对于它进行解释永远不会太晚。 给定一个值列表或字典,我们可以将这些值传递给函数,就好像它们是常规的位置或关键字参数一样。 看一下这段代码:

def show_args(arg1, arg2, arg3="THREE"):
    print(arg1, arg2, arg3)

some_args = range(3)
more_args = {
        "arg1": "ONE",
        "arg2": "TWO"}

print("Unpacking a sequence:", end=" ")

show_args(*some_args)
print("Unpacking a dict:", end=" ")

show_args(**more_args)

这是我们运行它时的样子:

Unpacking a sequence: 0 1 2
Unpacking a dict: ONE TWO THREE

该函数接受三个参数,其中一个具有默认值。 但是,当我们具有三个参数的列表时,可以在函数调用内使用*运算符将其解压缩为三个参数。 如果我们有一个参数字典,则可以使用**语法将其解压缩为关键字参数的集合。

当将已从用户输入或从外部源(例如,Internet 页面或文本文件)收集的信息映射到函数或方法调用时,这通常非常有用。

还记得我们之前的示例,该示例在文本文件中使用标题和行来创建包含联系人信息的词典列表吗? 除了将字典添加到列表中,我们还可以使用关键字解包将参数传递给接受相同参数集的特制Contact对象上的__init__方法。 看看您是否可以改编该示例以完成这项工作。

函数也是对象

过分强调面向对象原理的编程语言往往对那些不是方法的函数不满意。 在这种语言中,您应该创建一个对象来包装所涉及的单个方法。 在很多情况下,我们都希望传递一个被称为执行动作的小对象。 这通常在事件驱动的编程中完成,例如图形工具箱或异步服务器。

在 Python 中,我们不需要将此类方法包装在对象中,因为函数已经是对象! 我们可以在函数上设置属性(尽管这不是常见的活动),我们可以传递它们以供日后调用。 它们甚至具有一些可以直接访问的特殊属性。 这是另一个人为的例子:

def my_function():
    print("The Function Was Called")
my_function.description = "A silly function"

def second_function():
    print("The second was called")
second_function.description = "A sillier function."

def another_function(function):
    print("The description:", end=" ")
    print(function.description)
    print("The name:", end=" ")
    print(function.__name__)
    print("The class:", end=" ")
    print(function.__class__)
    print("Now I'll call the function passed in")
    function()

another_function(my_function)
another_function(second_function)

如果运行此代码,我们可以看到我们能够将两个不同的函数传递给我们的第三个函数,并且每个函数都得到不同的输出:

The description: A silly function
The name: my_function
The class: <class 'function'>
Now I'll call the function passed in
The Function Was Called
The description: A sillier function.
The name: second_function
The class: <class 'function'>
Now I'll call the function passed in
The second was called

我们在函数上设置了名为description的属性(公认的,它不是很好的描述)。 我们还能够看到该函数的__name__属性,并访问其类,这表明该函数实际上是具有属性的对象。 然后,我们通过使用可调用语法(括号)来调用该函数。

函数是顶级对象的事实通常用于传递它们,以便在以后某个日期执行(例如,当满足特定条件时)。 让我们构建一个事件驱动的计时器来执行以下操作:

import datetime
import time

class TimedEvent:
    def __init__(self, endtime, callback):
        self.endtime = endtime
        self.callback = callback

    def ready(self):
        return self.endtime <= datetime.datetime.now()

class Timer:
    def __init__(self):
        self.events = []

    def call_after(self, delay, callback):
        end_time = datetime.datetime.now() + \
                datetime.timedelta(seconds=delay)

        self.events.append(TimedEvent(end_time, callback))

    def run(self):
        while True:
            ready_events = (e for e in self.events if e.ready())
            for event in ready_events:
                event.callback(self)
                self.events.remove(event)
            time.sleep(0.5)

在生产中,此代码肯定应该有使用 docstrings 的额外文档! call_after方法至少应提及delay参数以秒为单位,并且callback函数应接受一个参数:计时器进行调用。

我们在这里有两个课程。 TimedEvent类并不是真的要被其他类访问; 它所做的只是存储endtimecallback。 我们甚至可以在此处使用tuplenamedtuple,但是由于为对象赋予行为以告知我们事件是否准备就绪的行为很方便,因此我们使用一个类。

Timer类仅存储即将发生的事件的列表。 它具有call_after方法来添加新事件。 此方法接受表示执行回调之前要等待的秒数的delay参数,以及callback函数本身:将在正确的时间执行的函数。 此callback函数应接受一个参数。

run方法非常简单; 它使用生成器表达式过滤掉时间到了的所有事件,并按顺序执行它们。 然后,计时器循环会无限期地继续,因此必须通过键盘中断来中断( Ctrl +CCtrl + Break ) 。 每次迭代后,我们睡半秒钟,以免使系统停顿下来。

这里要注意的重要事项是涉及回调函数的行。 该函数像任何其他对象一样被传递,并且计时器从不知道或不在乎函数的原始名称是什么或定义它的位置。 该调用该函数时,计时器只需将括号语法应用于所存储的变量。

这是一组测试计时器的回调:

from timer import Timer
import datetime

def format_time(message, *args):
    now = datetime.datetime.now().strftime("%I:%M:%S")
    print(message.format(*args, now=now))

def one(timer):
    format_time("{now}: Called One")

def two(timer):
    format_time("{now}: Called Two")

def three(timer):
    format_time("{now}: Called Three")

class Repeater:
    def __init__(self):
        self.count = 0
    def repeater(self, timer):
        format_time("{now}: repeat {0}", self.count)
        self.count += 1
        timer.call_after(5, self.repeater)

timer = Timer()
timer.call_after(1, one)
timer.call_after(2, one)
timer.call_after(2, two)
timer.call_after(4, two)
timer.call_after(3, three)
timer.call_after(6, three)
repeater = Repeater()
timer.call_after(5, repeater.repeater)
format_time("{now}: Starting")
timer.run()

此示例允许我们查看多个回调如何与计时器交互。 第一个功能是format_time功能。 它使用字符串format方法将当前时间添加到消息中,并说明了正在使用的可变参数。 format_time方法将使用可变参数语法接受任意数量的位置参数,然后将其作为位置参数转发给字符串的format方法。 此后,我们创建了三个简单的回调方法,它们仅输出当前时间和一条短消息,告诉我们已触发了哪个回调。

Repeater类演示方法也可以用作回调,因为它们实际上只是函数。 这也说明了为什么回调函数的timer参数很有用:我们可以从当前正在运行的回调中向计时器添加新的定时事件。 然后,我们创建一个计时器,并向其中添加几个在不同的时间量后调用的事件。 最后,我们启动计时器运行; 输出显示事件按预期顺序运行:

02:53:35: Starting
02:53:36: Called One
02:53:37: Called One
02:53:37: Called Two
02:53:38: Called Three
02:53:39: Called Two
02:53:40: repeat 0
02:53:41: Called Three
02:53:45: repeat 1
02:53:50: repeat 2
02:53:55: repeat 3
02:54:00: repeat 4

Python 3.4 引入了类似于的通用事件循环架构。

使用函数作为属性

函数作为对象的有趣的效果之一是,可以将它们设置为其他对象上的可调用属性。 可以向实例化对象添加或更改函数:

class A:
    def print(self):
        print("my class is A")

def fake_print():
    print("my class is not A")

a = A()
a.print()
a.print = fake_print
a.print()

这段代码使用print方法创建了一个非常简单的类,该类不会告诉我们我们所不知道的任何内容。 然后,我们创建一个新函数,告诉我们一些我们不相信的东西。

当我们在A类的实例上调用print时,它的行为符合预期。 然后,如果我们将print方法设置为指向新函数,它将告诉我们一些不同的东西:

my class is A
my class is not A

也可以替换类而不是对象的方法,尽管在这种情况下,我们必须将self参数添加到参数列表中。 这将更改该对象的所有实例的方法,甚至是已经实例化的实例。 显然,替换这样的方法既危险又难以维护。 读过代码的人会看到已经调用了一个方法,并在原始类上查找了该方法。 但是原始类上的方法不是被调用的方法。 弄清实际发生的情况可能会成为棘手的,令人沮丧的调试会话。

它确实有其用途。 在自动化测试中,通常在运行时替换或添加方法(称为猴子补丁)。 如果要测试客户端服务器应用,我们可能不希望在测试客户端时实际连接到服务器。 这可能会导致资金意外转移或发送给真实人的尴尬测试电子邮件。 相反,我们可以设置测试代码来替换将请求发送到服务器的对象上的某些关键方法,因此它仅记录该方法已被调用。

猴子修补程序也可用于修复错误或在我们正在与之交互的第三方代码中添加功能,并且行为方式与我们所需的不完全相同。 但是,应谨慎使用; 它几乎总是“杂乱无章”。 但是有时候,这是适应现有库以满足我们需求的唯一方法。

可调用对象

正如函数是可以在其上设置属性的对象一样,可以创建一个可以作为函数调用的对象。

通过简单地给它一个接受所需参数的__call__方法,就可以使任何对象成为可调用的。 让我们通过计时器示例使Repeater类变得易于使用,方法是将其设为可调用的:

class Repeater:
    def __init__(self):
        self.count = 0

    def __call__(self, timer):
        format_time("{now}: repeat {0}", self.count)
        self.count += 1

        timer.call_after(5, self)

timer = Timer()

timer.call_after(5, Repeater())
format_time("{now}: Starting")
timer.run()

这个例子与之前的课程没有太大的不同。 我们所做的就是将repeater函数的名称更改为__call__,并将对象本身作为可调用对象传递。 请注意,当我们调用call_after时,将传递参数Repeater()。 这两个括号正在创建该类的新实例; 他们没有显式调用该类。 这稍后会在计时器内部发生。 如果要在新实例化的对象上执行__call__方法,则将使用一种比较奇怪的语法:Repeater()()。 第一组括号构造对象。 第二组执行__call__方法。 如果发现自己这样做,则可能是使用的不是正确的抽象。 如果要将该对象视为函数,则仅在该对象上实现__call__函数。

案例研究

为了将结合到本章中介绍的一些原理中,让我们构建一个邮件列表管理器。 管理员将跟踪分类为命名组的电子邮件地址。 当需要发送邮件时,我们可以选择一个组并将该邮件发送到分配给该组的所有电子邮件地址。

现在,在我们开始这个项目之前,我们应该有一种安全的方法来对其进行测试,而不必向一群真实的人发送电子邮件。 幸运的是,Python 在这里得到了支持。 像测试 HTTP 服务器一样,它具有内置的简单邮件传输协议SMTP)服务器,我们可以指示捕获实际上没有发送的任何邮件。 我们可以使用以下命令运行服务器:

python -m smtpd -n -c DebuggingServer localhost:1025

在命令提示符处运行此命令将启动在本地计算机上的端口 1025 上运行的 SMTP 服务器。 但是,我们已指示它使用DebuggingServer类(内置 SMTP 模块随附),该类无需将邮件发送给目标收件人,而是在接收到它们时将它们打印在终端屏幕上。 整洁吧?

现在,在编写邮件列表之前,让我们编写一些实际发送邮件的代码。 当然,Python 在标准库中也支持此功能,但是它有点奇怪,因此我们将编写一个新函数来将所有内容包装干净:

import smtplib
from email.mime.text import MIMEText

def send_email(subject, message, from_addr, *to_addrs,
        host="localhost", port=1025, **headers):

    email = MIMEText(message)
    email['Subject'] = subject
    email['From'] = from_addr
    for header, value in headers.items():
        email[header] = value

    sender = smtplib.SMTP(host, port)
    for addr in to_addrs:
        del email['To']
        email['To'] = addr
        sender.sendmail(from_addr, addr, email.as_string())
    sender.quit()

我们不会过于详尽地介绍此方法中的代码。 标准库中的文档可以为您提供有效使用smtplibemail模块所需的所有信息。

我们在函数调用中同时使用了变量参数和关键字参数语法。 可变参数列表允许我们在具有单个to地址的默认情况下提供单个字符串,并允许在需要时提供多个地址。 任何其他关键字参数都将映射到电子邮件标题。 这是可变参数和关键字参数的令人兴奋的用法,但对于调用函数的人来说,这并不是一个很好的接口。 实际上,这使程序员想做的许多事情变得不可能。

传递给函数的标题代表可以附加到方法的辅助标题。 这样的标头可能包括Reply-ToReturn-PathX 几乎任何东西。 但是,为了在 Python 中成为有效的标识符,名称不能包含-字符。 通常,该字符表示减法。 因此,无法使用Reply-To = [email protected]调用函数。 看来我们太急于使用关键字参数,因为它们是我们在本章刚刚学到的新工具。

我们必须将参数更改为普通字典。 这将起作用,因为任何字符串都可以用作字典中的键。 默认情况下,我们希望此字典为空,但不能将默认参数设为空字典。 因此,我们必须设置默认参数None,然后在方法开始处设置字典:

def send_email(subject, message, from_addr, *to_addrs,
        host="localhost", port=1025, headers=None):

    headers = {} if headers is None else headers

如果我们在一个终端上运行调试的 SMTP 服务器,则可以在 Python 解释器中测试以下代码:

>>> send_email("A model subject", "The message contents",
 "[email protected]", "[email protected]", "[email protected]")

然后,如果检查调试 SMTP 服务器的输出,则会得到以下信息:

---------- MESSAGE FOLLOWS ----------
Content-Type: text/plain; charset="us-ascii"
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit
Subject: A model subject
From: from@example.com
To: to1@example.com
X-Peer: 127.0.0.1

The message contents
------------ END MESSAGE ------------
---------- MESSAGE FOLLOWS ----------
Content-Type: text/plain; charset="us-ascii"
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit
Subject: A model subject
From: from@example.com
To: to2@example.com
X-Peer: 127.0.0.1

The message contents
------------ END MESSAGE ------------

太好了,它已经将我们的电子邮件“发送”到了包含主题和消息内容的两个预期地址。 现在我们可以发送消息了,让我们在电子邮件组管理系统上工作。 我们需要一个对象,该对象以某种方式将电子邮件地址与它们所在的组进行匹配。由于这是多对多关系(任何一个电子邮件地址都可以位于多个组中;任何一个组都可以与之关联 多个电子邮件地址),但我们研究的数据结构似乎都不理想。 我们可以尝试使用与关联的电子邮件地址列表匹配的组名字典,但这将重复电子邮件地址。 我们也可以尝试将电子邮件地址的字典与组匹配,从而导致组重复。 两者都不是最佳选择。 让我们尝试使用后一个版本,即使直觉告诉我电子邮件地址组的解决方案会更简单。

由于我们词典中的值将始终是唯一电子邮件地址的集合,因此我们可能应将它们存储在set容器中。 我们可以使用defaultdict来确保每个键始终有一个set容器:

from collections import defaultdict
class MailingList:
    '''Manage groups of e-mail addresses for sending e-mails.'''
    def __init__(self):
        self.email_map = defaultdict(set)

    def add_to_group(self, email, group):
        self.email_map[email].add(group)

现在,让我们添加一种方法,该方法允许我们收集一个或多个组中的所有电子邮件地址。 这可以通过将组列表转换为集合来完成:

    def emails_in_groups(self, *groups):
        groups = set(groups)
        emails = set()
        for e, g in self.email_map.items():
            if g & groups:
                emails.add(e)
        return emails

首先,看看我们要迭代的内容:self.email_map.items()。 当然,此方法为字典中的每个项目返回键-值对的元组。 值是代表组的字符串集。 我们将它们分为两个变量eg,这是电子邮件和组的缩写。 仅当传入的组与电子邮件地址组相交时,才将电子邮件地址添加到返回值集中。 g & groups语法是g.intersection(groups)的快捷方式; set类通过实现特殊的__and__方法来调用intersection来实现此目的。

现在,借助这些构建块,我们可以轻松地向MailingList类添加一个方法,该方法将消息发送到特定的组:

def send_mailing(self, subject, message, from_addr,
        *groups, headers=None):
    emails = self.emails_in_groups(*groups)
    send_email(subject, message, from_addr,
            *emails, headers=headers)

此函数依赖于变量参数列表。 作为输入,它将组列表作为变量参数。 它获取指定组的电子邮件列表,并将这些电子邮件作为变量参数以及传递给此方法的其他参数传递给send_email

可以通过确保 SMTP 调试服务器在一个命令提示符下运行,然后在第二个提示符下使用以下命令加载代码来测试该程序:

python -i mailing_list.py

创建具有以下内容的MailingList对象:

>>> m = MailingList()

然后按照以下方式创建一些虚假的电子邮件地址和组:

>>> m.add_to_group("[email protected]", "friends")
>>> m.add_to_group("[email protected]", "friends")
>>> m.add_to_group("[email protected]", "family")
>>> m.add_to_group("[email protected]", "professional")

最后,使用如下命令将电子邮件发送到特定的组:

>>> m.send_mailing("A Party",
"Friends and family only: a party", "[email protected]", "friends",
"family", headers={"Reply-To": "[email protected]"})

发送到指定组中每个地址的电子邮件应显示在 SMTP 服务器上的控制台中。

邮件列表可以正常工作,但是没有用; 一旦退出程序,我们的信息数据库就会丢失。 让我们对其进行修改,以添加一些方法来加载和保存文件中的电子邮件组列表。

通常,在磁盘上存储结构化数据时,最好考虑一下如何存储数据。 存在大量数据库系统的原因之一是,如果其他人将这种思想纳入了数据存储方式中,则不必这样做。 对于此示例,让我们保持简单,并使用可能可行的第一个解决方案。

我想到的数据格式是存储每个电子邮件地址,后跟一个空格,后跟一个逗号分隔的组列表。 这种格式似乎是合理的,我们将继续使用它,因为数据格式化不是本章的主题。 但是,为了说明为什么您需要认真考虑如何格式化磁盘上的数据,让我们重点介绍一下格式上的一些问题。

首先,在电子邮件地址中,空格字符在技术上是合法的。 大多数电子邮件提供商都禁止这样做(有充分的理由),但是定义电子邮件地址的规范指出,如果电子邮件中带有引号,则可以包含空格。 如果要将空格用作数据格式的标记,从技术上讲,我们应该能够区分该空格和作为电子邮件一部分的空格。 为了简单起见,我们将假装这不是事实,但是现实生活中的数据编码充满了诸如此类的愚蠢问题。 其次,考虑以逗号分隔的组列表。 如果有人决定在组名中添加逗号会怎样? 如果决定使组名中的逗号不合法,则应在add_to_group方法中添加验证以确保这一点。 为了教学上的清晰,我们也将忽略此问题。 最后,我们需要考虑许多安全隐患:有人可以通过在电子邮件地址中添加假逗号来使自己陷入错误的群体吗? 如果解析器遇到无效文件,该怎么办?

讨论的重点是尝试使用经过现场测试的数据存储方法,而不是设计自己的数据序列化协议。 您可能会忽略很多奇怪的边缘情况,最好使用已经遇到并修复这些边缘情况的代码。

但是请记住,让我们编写一些基本代码,使用不健康的一厢情愿来假装这种简单的数据格式是安全的:

email1@mydomain.com group1,group2
email2@mydomain.com group2,group3

执行此操作的代码如下:

    def save(self):
        with open(self.data_file, 'w') as file:
            for email, groups in self.email_map.items():
                file.write(
                    '{} {}\n'.format(email, ','.join(groups))
                )

    def load(self):
        self.email_map = defaultdict(set)
        try:
            with open(self.data_file) as file:
                for line in file:
                    email, groups = line.strip().split(' ')
                    groups = set(groups.split(','))
                    self.email_map[email] = groups
        except IOError:
            pass

save方法中,我们在上下文管理器中打开文件,然后将文件写为格式化字符串。 记住换行符; Python 不会为我们添加它。 load方法首先使用for ... in语法(在文件的每一行上循环)来重置字典(如果字典包含来自先前调用load的数据)。 同样,换行符包含在 line 变量中,因此我们必须调用.strip()将其删除。

在使用这些方法之前,我们需要确保对象具有self.data_file属性,可以通过修改__init__来实现:

    def __init__(self, data_file):
        self.data_file = data_file
        self.email_map = defaultdict(set)

我们可以在解释器中测试这两种方法,如下所示:

>>> m = MailingList('addresses.db')
>>> m.add_to_group('[email protected]', 'friends')
>>> m.add_to_group('[email protected]', 'friends')
>>> m.add_to_group('[email protected]', 'family')
>>> m.save()

生成的addresses.db文件包含以下几行,如预期的那样:

friend1@example.com friends
family1@example.com friends,family

我们还可以成功地将此数据加载回MailingList对象:

>>> m = MailingList('addresses.db')
>>> m.email_map
defaultdict(<class 'set'>, {})
>>> m.load()
>>> m.email_map
defaultdict(<class 'set'>, {'[email protected]': {'friends\n'}, '[email protected]': {'family\n'}, '[email protected]': {'friends\n'}})

正如您所看到的,我忘记了执行load命令,也很容易忘记save命令。 为了使想要在自己的代码中使用我们的MailingList API 的用户更轻松一些,让我们提供支持上下文管理器的方法:

    def __enter__(self):
        self.load()
        return self

    def __exit__(self, type, value, tb):
        self.save()

这些简单的方法只是委派了他们的工作来加载和保存,但是我们现在可以在交互式解释器中编写这样的代码,并且知道以前存储的所有地址都是以我们的名义加载的,并且整个列表将在保存时保存到文件中。 我们完了:

>>> with MailingList('addresses.db') as ml:
...    ml.add_to_group('[email protected]', 'friends')
...    ml.send_mailing("What's up", "hey friends, how's it going", '[email protected]', 'friends')

Case study

Case study

Case study