Python 入门指南(六)(1)https://developer.aliyun.com/article/1507433
案例研究
对于这个案例研究,我们将尝试进一步探讨一个问题,即何时应该选择对象而不是内置类型?我们将建模一个可能在文本编辑器或文字处理器中使用的Document
类。它应该具有哪些对象、函数或属性?
我们可能会从Document
内容开始使用str
,但在 Python 中,字符串是不可变的。一旦定义了一个str
,它就永远存在。我们无法在其中插入字符或删除字符,而不创建全新的字符串对象。这将导致大量的str
对象占用内存,直到 Python 的垃圾收集器决定清理它们。
因此,我们将使用字符列表而不是字符串,这样我们可以随意修改。此外,我们需要知道列表中的当前光标位置,并且可能还需要存储文档的文件名。
真正的文本编辑器使用一种名为rope
的基于二叉树的数据结构来模拟其文档内容。本书的标题不是高级数据结构,所以如果你对这个迷人的主题感兴趣,你可能想在网上搜索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'
看起来它正在工作。我们可以将键盘的字母和箭头键连接到这些方法,文档将正常跟踪一切。
但是,如果我们想要连接的不仅仅是箭头键。如果我们还想连接Home和End键怎么办?我们可以向Document
类添加更多方法,用于在字符串中向前或向后搜索换行符(换行符,转义为\n
,表示一行的结束和新行的开始),并跳转到它们,但如果我们为每种可能的移动操作(按单词移动,按句子移动,Page Up,Page Down,行尾,空格开头等)都这样做,那么这个类将会很庞大。也许把这些方法放在一个单独的对象上会更好。因此,让我们将Cursor
属性转换为一个对象,该对象知道自己的位置并可以操纵该位置。我们可以将向前和向后的方法移到该类中,并为Home和End键添加另外两个方法,如下所示:
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].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] != "\n" ): self.position += 1
这个类将文档作为初始化参数,以便方法可以访问文档字符列表的内容。然后提供了向后和向前移动的简单方法,以及移动到home
和end
位置的方法。
这段代码并不是很安全。你很容易越过结束位置,如果你试图在空文件上回到开头,它会崩溃。这些示例被保持简短以便阅读,但这并不意味着它们是防御性的!你可以通过练习来改进这段代码的错误检查;这可能是扩展你的异常处理技能的绝佳机会。
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): with open(self.filename, "w") as f: f.write("".join(self.characters))
我们刚刚更新了访问旧光标整数的任何内容,以使用新对象代替。我们现在可以测试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
实例带来的众多方法。
我们在谈论什么样的方法?有startswith
、strip
、find
、lower
等等。这些方法中的大多数都希望在包含多个字符的字符串上工作。相比之下,如果Character
是str
的子类,我们可能最好重写__init__
,以便在提供多字符字符串时引发异常。由于我们将免费获得的所有这些方法实际上并不适用于我们的Character
类,因此似乎我们不应该使用继承。
这让我们回到了最初的问题;Character
甚至应该是一个类吗?object
类上有一个非常重要的特殊方法,我们可以利用它来表示我们的字符。这个方法叫做__str__
(两端都有两个下划线,就像__init__
一样),它在字符串操作函数中被使用,比如print
和str
构造函数,将任何类转换为字符串。默认实现做了一些无聊的事情,比如打印模块和类的名称,以及它在内存中的地址。但如果我们重写它,我们可以让它打印任何我们喜欢的东西。
对于我们的实现,我们可以使用特殊字符作为前缀来表示字符是否为粗体、斜体或下划线。因此,我们将创建一个表示字符的类,如下所示:
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()
函数时用特殊字符作为前缀。没有太多激动人心的地方。我们只需要对Document
和Cursor
类进行一些小修改,以便与这个类一起工作。在Document
类中,我们在insert
方法的开头添加以下两行:
def insert(self, character): if not hasattr(character, 'character'): character = Character(character)
这是一段相当奇怪的代码。它的基本目的是检查传入的字符是Character
还是str
。如果是字符串,它就会被包装在Character
类中,以便列表中的所有对象都是Character
对象。然而,完全有可能有人使用我们的代码想要使用既不是Character
也不是字符串的类,使用鸭子类型。如果对象有一个字符属性,我们假设它是类似Character
的对象。但如果没有,我们假设它是类似str
的对象,并将其包装在Character
中。这有助于程序利用鸭子类型和多态性;只要对象具有字符属性,它就可以在Document
类中使用。
这种通用检查可能非常有用。例如,如果我们想要制作一个带有语法高亮的程序员编辑器,我们需要字符的额外数据,比如字符属于哪种类型的语法标记。请注意,如果我们要做很多这种比较,最好实现Character
作为一个带有适当__subclasshook__
的抽象基类,如第十七章中讨论的那样,当对象相似。
此外,我们需要修改Document
上的字符串属性,以接受新的Character
值。我们只需要在连接之前对每个字符调用str()
,如下所示:
@property def string(self): return "".join((str(c) for c in self.characters))
这段代码使用了一个生成器表达式,我们将在第二十一章中讨论,迭代器模式。这是一个在序列中对所有对象执行特定操作的快捷方式。
最后,我们还需要检查home
和end
函数中的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
正如预期的那样,每当我们打印字符串时,每个粗体字符前面都有一个*
字符,每个斜体字符前面都有一个/
字符,每个下划线字符前面都有一个_
字符。我们所有的函数似乎都能工作,并且我们可以在事后修改列表中的字符。我们有一个可以插入到适当的图形用户界面中并与键盘进行输入和屏幕进行输出的工作的富文本文档对象。当然,我们希望在 UI 中显示真正的粗体、斜体和下划线字体,而不是使用我们的__str__
方法,但它对我们要求的基本测试是足够的。
练习
我们已经看过了在面向对象的 Python 程序中对象、数据和方法可以相互交互的各种方式。和往常一样,您的第一个想法应该是如何将这些原则应用到您自己的工作中。您是否有一些混乱的脚本横七竖八地散落在那里,可以使用面向对象的管理器进行重写?浏览一下您的旧代码,寻找一些不是动作的方法。如果名称不是动词,尝试将其重写为属性。
思考您用任何语言编写的代码。它是否违反了 DRY 原则?是否有任何重复的代码?您是否复制和粘贴了代码?您是否编写了两个类似代码的版本,因为您不想理解原始代码?现在回顾一下您最近的一些代码,看看是否可以使用继承或组合重构重复的代码。尝试选择一个您仍然有兴趣维护的项目;不要选择那些您永远不想再碰的代码。这将有助于在您进行改进时保持您的兴趣!
现在,回顾一下本章中我们看过的一些例子。从使用属性缓存检索数据的缓存网页示例开始。这个示例的一个明显问题是缓存从未被刷新。在属性的 getter 中添加一个超时,并且只有在页面在超时过期之前被请求时才返回缓存的页面。您可以使用time
模块(time.time() - an_old_time
返回自an_old_time
以来经过的秒数)来确定缓存是否已过期。
还要看看基于继承的ZipProcessor
。在这里使用组合而不是继承可能是合理的。您可以在ZipProcessor
构造函数中传递这些类的实例,并调用它们来执行处理部分。实现这一点。
您觉得哪个版本更容易使用?哪个更优雅?哪个更容易阅读?这些都是主观问题;答案因人而异。然而,了解答案是重要的。如果您发现自己更喜欢继承而不是组合,那么您需要注意不要在日常编码中过度使用继承。如果您更喜欢组合,请确保不要错过创建优雅的基于继承的解决方案的机会。
最后,在案例研究中为各种类添加一些错误处理程序。它们应确保输入单个字符,不要尝试将光标移动到文件的末尾或开头,不要删除不存在的字符,也不要保存没有文件名的文件。尽量考虑尽可能多的边缘情况,并对其进行考虑(考虑边缘情况大约占专业程序员工作的 90%!)。考虑不同的处理方式;当用户尝试移动到文件末尾时,您应该引发异常,还是只停留在最后一个字符?
在您的日常编码中,注意复制和粘贴命令。每次在编辑器中使用它们时,考虑是否改进程序的组织结构,以便您只有要复制的代码的一个版本。
总结
在这一章中,我们专注于识别对象,特别是那些不太明显的对象;管理和控制对象。对象应该既有数据又有行为,但属性可以用来模糊两者之间的区别。 DRY 原则是代码质量的重要指标,继承和组合可以用来减少代码重复。
在下一章中,我们将讨论如何整合 Python 的面向对象和非面向对象的方面。在这个过程中,我们会发现它比起初看起来更加面向对象!
第二十章:Python 面向对象的快捷方式
Python 的许多方面看起来更像结构化或函数式编程,而不是面向对象编程。尽管面向对象编程在过去的二十年中是最可见的范式,但旧模型最近又出现了。与 Python 的数据结构一样,这些工具大多是在基础面向对象实现之上的一层语法糖;我们可以将它们看作是建立在(已经抽象化的)面向对象范式之上的进一步抽象层。在本章中,我们将涵盖一些不严格面向对象的 Python 特性:
- 内置函数可以一次性处理常见任务
- 文件 I/O 和上下文管理器
- 方法重载的替代方法
- 函数作为对象
Python 内置函数
Python 中有许多函数可以在某些类型的对象上执行任务或计算结果,而不是作为基础类的方法。它们通常抽象出适用于多种类型的类的常见计算。这是鸭子类型的最佳体现;这些函数接受具有某些属性或方法的对象,并能够使用这些方法执行通用操作。我们已经使用了许多内置函数,但让我们快速浏览一下重要的函数,并学习一些巧妙的技巧。
len()函数
最简单的例子是len()
函数,它计算某种容器对象中的项目数量,比如字典或列表。你之前已经见过它,演示如下:
>>> len([1,2,3,4]) 4
你可能会想为什么这些对象没有一个长度属性,而是必须在它们上调用一个函数。从技术上讲,它们是有的。大多数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 f"x{index}" class FunkyBackwards: def __reversed__(self): return "BACKWARDS!" for seq in normal_list, CustomSequence(), FunkyBackwards(): print(f"\n{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(f"{index+1}: {line}", end="")
使用自己的文件名作为输入文件运行此代码,可以显示它是如何工作的:
1: import sys 2: 3: filename = sys.argv[1] 4: 5: with open(filename) as file: 6: for index, line in enumerate(file): 7: print(f"{index+1}: {line}", end="")
enumerate
函数返回一个元组序列,我们的for
循环将每个元组拆分为两个值,并且print
语句将它们格式化在一起。对于每行号,它会将索引加一,因为enumerate
,像所有序列一样,是从零开始的。
我们只是涉及了一些更重要的 Python 内置函数。正如你所看到的,其中许多调用面向对象的概念,而其他一些则遵循纯函数式或过程式范例。标准库中还有许多其他函数;一些更有趣的包括以下内容:
all
和any
,它们接受一个可迭代对象,并在所有或任何项目评估为 true 时返回True
(例如非空字符串或列表,非零数,不是None
的对象,或文字True
)。eval
、exec
和compile
,它们将字符串作为代码在解释器中执行。对于这些要小心;它们不安全,所以不要执行未知用户提供给你的代码(一般来说,假设所有未知用户都是恶意的、愚蠢的,或两者兼有)。hasattr
、getattr
、setattr
和delattr
,它们允许通过它们的字符串名称操作对象的属性。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
,当我们向其写入时,如果尝试传递文本对象,它将失败。
这些用于控制文件打开方式的模式字符串相当神秘,既不符合 Python 的风格,也不是面向对象的。但是,它们与几乎所有其他编程语言一致。文件 I/O 是操作系统必须处理的基本工作之一,所有编程语言都必须使用相同的系统调用与操作系统进行通信。只要 Python 返回一个带有有用方法的文件对象,而不是大多数主要操作系统用于标识文件句柄的整数,就应该感到高兴!
一旦文件被打开以进行读取,我们就可以调用read
、readline
或readlines
方法来获取文件的内容。read
方法返回文件的整个内容作为str
或bytes
对象,具体取决于模式中是否有'b'
。不要在大文件上不带参数地使用此方法。您不希望知道如果尝试将这么多数据加载到内存中会发生什么!
还可以从文件中读取固定数量的字节;我们将整数参数传递给read
方法,描述我们要读取多少字节。对read
的下一次调用将加载下一个字节序列,依此类推。我们可以在while
循环中执行此操作,以以可管理的块读取整个文件。
readline
方法返回文件中的一行(每行以换行符、回车符或两者结尾,具体取决于创建文件的操作系统)。我们可以重复调用它以获取其他行。复数readlines
方法返回文件中所有行的列表。与read
方法一样,它不适用于非常大的文件。这两种方法甚至在文件以bytes
模式打开时也可以使用,但只有在解析具有合理位置的换行符的文本数据时才有意义。例如,图像或音频文件不会包含换行符(除非换行符字节恰好表示某个像素或声音),因此应用readline
是没有意义的。
为了可读性,并且避免一次将大文件读入内存,通常最好直接在文件对象上使用for
循环。对于文本文件,它将一次读取每一行,我们可以在循环体内处理它。对于二进制文件,最好使用read()
方法读取固定大小的数据块,传递一个参数以读取的最大字节数。
写入文件同样简单;文件对象上的write
方法将一个字符串(或字节,用于二进制数据)对象写入文件。可以重复调用它来写入多个字符串,一个接着一个。writelines
方法接受一个字符串序列,并将迭代的每个值写入文件。writelines
方法在序列中的每个项目后面不添加新行。它基本上是一个命名不当的便利函数,用于写入字符串序列的内容,而无需使用for
循环显式迭代它。
最后,我是指最后,我们来到close
方法。当我们完成读取或写入文件时,应调用此方法,以确保任何缓冲写入都写入磁盘,文件已经得到适当清理,并且与文件关联的所有资源都已释放回操作系统。从技术上讲,当脚本退出时,这将自动发生,但最好是明确地清理自己,特别是在长时间运行的进程中。
放在上下文中
当我们完成文件时需要关闭文件,这可能会使我们的代码变得非常丑陋。因为在文件 I/O 期间可能会发生异常,我们应该将对文件的所有调用都包装在try
…finally
子句中。文件应该在finally
子句中关闭,无论 I/O 是否成功。这并不是很 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
类中所需的两个特殊方法添加到它继承的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__
方法,并且joiner
对象上的result
属性变得可用。然后我们打印这个值来看一个随机字符串。
方法重载的替代方法
许多面向对象的编程语言的一个显著特点是一个称为方法重载的工具。方法重载简单地指的是具有相同名称的多个方法,这些方法接受不同的参数集。在静态类型的语言中,如果我们想要一个方法既可以接受整数也可以接受字符串,这是很有用的。在非面向对象的语言中,我们可能需要两个函数,称为add_s
和add_i
,来适应这种情况。在静态类型的面向对象语言中,我们需要两个方法,都称为add
,一个接受字符串,一个接受整数。
在 Python 中,我们已经看到我们只需要一个方法,它接受任何类型的对象。它可能需要对对象类型进行一些测试(例如,如果它是一个字符串,将其转换为整数),但只需要一个方法。
然而,方法重载在我们希望一个方法接受不同数量或一组不同的参数时也很有用。例如,电子邮件消息方法可能有两个版本,其中一个接受from电子邮件地址的参数。另一个方法可能会查找默认的from电子邮件地址。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
偶尔你可能会发现创建一个仅限关键字参数很有用,也就是说,必须作为关键字参数提供的参数。你可以通过在关键字参数前面加上*
来实现这一点:
def kw_only(x, y='defaultkw', *, a, b='only'): print(x, y, a, b)
这个函数有一个位置参数x
,和三个关键字参数y
、a
和b
。x
和y
都是必需的,但是a
只能作为关键字参数传递。y
和b
都是可选的,默认值是,但是如果提供了b
,它只能作为关键字参数。
如果你不传递a
,这个函数会失败:
>>> kw_only('x') Traceback (most recent call last): File "<stdin>", line 1, in <module> TypeError: kw_only() missing 1 required keyword-only argument: 'a'
如果你将a
作为位置参数传递,也会失败:
>>> kw_only('x', 'y', 'a') Traceback (most recent call last): File "<stdin>", line 1, in <module> TypeError: kw_only() takes from 1 to 2 positional arguments but 3 were given
但是你可以将a
和b
作为关键字参数传递:
>>> kw_only('x', a='a', b='b') x defaultkw a b
有这么多的选项,可能很难选择一个,但是如果你把位置参数看作是一个有序列表,关键字参数看作是一种字典,你会发现正确的布局往往会自然而然地形成。如果你需要要求调用者指定一个参数,那就把它设为必需的;如果有一个合理的默认值,那就把它设为关键字参数。根据需要提供哪些值,以及哪些可以保持默认值,选择如何调用方法通常会自行解决。关键字参数相对较少见,但是当使用情况出现时,它们可以使 API 更加优雅。
需要注意的一点是,关键字参数的默认值是在函数首次解释时进行评估的,而不是在调用时进行的。这意味着我们不能有动态生成的默认值。例如,以下代码的行为不会完全符合预期:
number = 5 def funky_function(number=number): print(number) number=6 funky_function(8) funky_function() print(number)
如果我们运行这段代码,首先输出数字8
,但是后来对没有参数的调用输出数字5
。我们已经将变量设置为数字6
,这可以从输出的最后一行看出,但是当调用函数时,打印出的是数字5
;默认值是在函数定义时计算的,而不是在调用时。
这在空容器(如列表、集合和字典)中有些棘手。例如,通常会要求调用代码提供一个我们的函数将要操作的列表,但是列表是可选的。我们希望将一个空列表作为默认参数。我们不能这样做;它只会在代码首次构建时创建一个列表,如下所示:
//DON'T DO THIS >>> 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
实例,字典中包括默认值和我们使用关键字参数设置的值。
关键字参数语法可能是危险的,因为它可能违反“明确胜于隐式”的规则。在前面的例子中,可以向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")
这个命令将文件one
和two
移动到move_here
目录中,假设它们存在(函数中没有错误检查或异常处理,因此如果文件或目标目录不存在,它将失败)。移动将在没有任何输出的情况下发生,因为verbose
默认为False
。
如果我们想要看到输出,我们可以使用以下命令调用它:
>>> 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
该函数接受三个参数,其中一个具有默认值。但是当我们有一个包含三个参数的列表时,我们可以在函数调用内部使用*
运算符将其解压为三个参数。如果我们有一个参数字典,我们可以使用**
语法将其解压缩为一组关键字参数。
这在将从用户输入或外部来源(例如互联网页面或文本文件)收集的信息映射到函数或方法调用时最常用。
还记得我们之前的例子吗?它使用文本文件中的标题和行来创建包含联系信息的字典列表。我们可以使用关键字解压缩将这些字典传递给专门构建的Contact
对象上的__init__
方法,该对象接受相同的参数集。看看你是否可以调整示例使其正常工作。
这种解压缩语法也可以在函数调用之外的某些领域中使用。Options
类之前有一个__init__
方法,看起来像这样:
def __init__(self, **kwargs): self.options = dict(Options.default_options) self.options.update(kwargs)
更简洁的方法是解压缩这两个字典,如下所示:
def __init__(self, **kwargs): self.options = {**Options.default_options, **kwargs}
因为字典按从左到右的顺序解压缩,结果字典将包含所有默认选项,并且任何 kwarg 选项都将替换一些键。以下是一个示例:
>>> x = {'a': 1, 'b': 2} >>> y = {'b': 11, 'c': 3} >>> z = {**x, **y} >>> z {'a': 1, 'b': 11, 'c': 3}
Python 入门指南(六)(3)https://developer.aliyun.com/article/1507438