附录 E Python陷阱

同你学习的其他语言一样,Python 也有它的怪癖和特点。其中一些特点是脚本语言共有的,如果你有脚本语言的经验,可能不会感到惊讶。其他的特点是 Python 独有的。我们总结了其中一些特点,但并不完全,所以你可以自己去了解更多。我们希望本附录可以帮助你调试代码,也能让你了解 Python 行为方式的原因。

E.1 空白

可能你已经注意到,Python 使用空白作为代码结构的一部分。空白被用来缩进函数、方法和类;去执行 if-else 语句;创建持续的代码行。在 Python 中,空白是一种特殊的操作符,能帮助转化 Python 代码为可执行的代码。

下面是 Python 文件中空白的一些最佳实践。

  • 不要使用 tab,使用空格。

  • 对于每一个缩进块,使用 4 个空格。

  • 为悬挂式缩进选择一种好的缩进策略(可以通过一个分隔符、一个额外的缩进或一个单个缩进对齐,但是必需选用可读性和实用性最好的方式;参见 PEP-8,https://www.python.org/dev/peps/pep-0008/#indentation)。

附录 E Python陷阱 - 图1 PEP-8(或 Python 增强方案 #8)是一个 Python 风格指南,给出了缩进的最佳实践,并针对如何命名变量、代码行换行、格式化代码提出了建议,让代码可读、易于使用、易于分享。

如果你的代码没有正确地缩进,并且 Python 不能解析你的文件,你会得到一个 IndentationError。错误信息会告诉你哪一行代码没有正确缩进。在你最喜欢的文本编辑器中设置 Python 的语法提示器是相当简单的,以便在你工作时自动检查你的代码。举个例子,对于 Atom,有一个很棒的 PEP-8 提示器(https://atom.io/packages/linter-python-pep8)。

E.2 可怕的GIL

GIL(Global Interpreter Lock,全局解释器锁)是 Python 解释器用于一次只用一个线程执行代码的一种机制。这意味着当你运行 Python 脚本时,即使上在一台多进程机器上,你的代码也会线性执行。这个设计最初的目的是让 Python 可以通过 C 代码快速地运行,但是仍然是线程安全的。

GIL 给 Python 带来的限制意味着在标准解释器中,Python 从来不会真正地并行化。这对于一些高 I/O 的应用程序,或者严重依赖多重处理的应用程序来说,是一个劣势。1 有些 Python 库通过使用多重处理或异步服务 2,规避了这些问题,但是它们没有改变 GIL 仍然存在的事实。

1关于 GIL 可视化的更多信息,查看 David Beazley 的“一个可缩放、可交互的 Python 线程可视化”(http://www.dabeaz.com/GIL/gilvis/)。

2关于这些包的功能,查看 Jeff Knupp 关于如何减轻 GIL 问题影响的文章(https://www.jeffknupp.com/blog/2013/06/30/pythons-hardest-problem-revisited/)。

即便如此,有很多 Python 核心开发者意识到由 GIL 带来的问题,还有它的好处。在 GIL 成为开发痛点的情况下,通常有不错的应对方案,而且根据你的需要,还有用 C 以外的其他语言编写的其他解释器可用。如果你发现 GIL 成为了代码中的一个问题,很可能你可以重新架构你的代码,或利用一个不同的代码基(例如 Node.js)来满足你的需求。

E.3 ===is,以及何时只是复制

在 Python 中,看似相似的函数间有一些重大区别。我们已经了解了一些,但是让我们重新看一下一些代码和输出(使用 IPython):

  1. In [1]: a = 1
  2. In [2]: 1 == 1
  3. Out[2]: True
  4. In [3]: 1 is 1
  5. Out[3]: True
  6. In [4]: a is 1
  7. Out[4]: True
  8. In [5]: b = []
  9. In [6]: [] == []
  10. Out[6]: True
  11. In [7]: [] is []
  12. Out[7]: False
  13. In [8]: b is []
  14. Out[8]: False

❶ 设置变量 a 为 1。

❷ 检查 1 是否等于 1。

❸ 检查 1 是否与 1 是相同的对象。

❹ 检查 a 是否与 1 是相同的对象。

如果在 IPython 中执行这些代码(这样你可以看到类似于这里展示的输出),你会注意到一些有趣的、可能意外的结果。对于一个整数,我们看到很容易通过多种方式来确定相等。然而对于列表对象,我们发现 is 与其他比较操作符表现得不同。在 Python 中,内存管理操作不同于其他语言。在 Sreejith Kesavan 的博客上(http://foobarnbaz.com/2012/07/08/understanding-python-variables/),有一篇使用可视化技术的文章,讨论了 Python 如何管理内存中的对象。

为了从另外一个视角观察,让我们看一下对象内存的位置:

  1. In [9]: a = 1
  2. In [10]: id(a)
  3. Out[10]: 14119256
  4. In [11]: b = a
  5. In [12]: id(b)
  6. Out[12]: 14119256
  7. In [13]: a = 2
  8. In [14]: id(a)
  9. Out[14]: 14119232
  10. In [15]: c = []
  11. In [16]: id(c)
  12. Out[16]: 140491313323544
  13. In [17]: b = c
  14. In [18]: id(b)
  15. Out[18]: 140491313323544
  16. In [19]: c.append(45)
  17. In [20]: id(c)
  18. Out[20]: 140491313323544

❶ 将 a 赋值给 b

❷ 当在这里使用 id 方法时,我们发现 ba 都使用了内存中相同的位置,也就是说,它们在内存中是相同的对象。

❸ 当在此时调用 id 方法时,我们发现 a 在内存中拥有了新的地址。这个地址现在保存着值 2

❹ 在列表中,可以看到我们在赋值后,列表拥有与赋值对象相同的 id

❺ 当改变这个列表时,我们发现我们不能改变内存中的位置。Python 列表表现得与整数和字符串略有不同。

这里,我们的目的不是让你对 Python 中的内存分配有深入的理解,而是意识到我们可能不会总是去思考我们到底赋值了什么。在处理列表和字典时,我们需要知道和理解的是,在我们将它赋值为一个新变量的时候,新的变量和旧的变量仍然是内存中的相同对象。如果我们改变其中一个,也改变了另一个。如果只想要改变其中一个或另一个,或者需要创建一个新对象作为对象的副本,需要使用 copy 方法。

让我们通过最后一个示例来解释 copy 与赋值:

  1. In [21]: a = {}
  2. In [22]: id(a)
  3. Out[22]: 140491293143120
  4. In [23]: b = a
  5. In [24]: id(b)
  6. Out[24]: 140491293143120
  7. In [25]: a['test'] = 1
  8. In [26]: b
  9. Out[26]: {'test': 1}
  10. In [27]: c = b.copy()
  11. In [28]: id(c)
  12. Out[28]: 140491293140144
  13. In [29]: c['test_2'] = 2
  14. In [30]: c
  15. Out[30]: {'test': 1, 'test_2': 2}
  16. In [31]: b
  17. Out[31]: {'test': 1}

❶ 在这行代码中,我们看到,当我们修改 a 时,同样修改了 b,因为它们存储在内存中相同的位置。

❷ 使用 copy 方法我们创建了一个新的变量,c,这是第一个字典的副本。

❸ 这行代码中,我们看到 copy 创建了一个新的对象。它有一个新的 id

❹ 在修改了 c 之后,我们看到它现在保存着两个键和值。

❺ 即使在 c 修改之后,我们看到 b 仍然是相同的。

在最后这个示例中,很显然,如果你真的想要一个字典或列表的副本,需要使用 copy 方法。如果你想要相同的对象,那么可以使用 =。类似地,如果你想要知道两个对象是不是“相等的”,可以使用 ==,但是如果你想知道它们是否是相同的对象,则使用 is

E.4 默认函数参数

有时你会想要传递默认变量到你的 Python 函数和方法中。为此,你需要充分理解 Python 何时以何种方式调用这些方法。让我们看一下:

  1. def add_one(default_list=[]):
  2. default_list.append(1)
  3. return default_list

现在,让我们通过 IPython 研究:

  1. In [2]: add_one()
  2. Out [2]: [1]
  3. In [3]: add_one()
  4. Out [3]: [1, 1]

你可能希望每一个函数调用会返回一个新的列表,只包含一个元素,1。相反,两个调用修改相同的列表对象。而实际上,默认参数在脚本第一次解释的时候被声明。如果每次你都想要一个新的列表,可以像下面一样重写函数。

  1. def add_one(default_list=None):
  2. if default_list is None:
  3. default_list = []
  4. default_list.append(1)
  5. return default_list

现在我们的代码表现得同我们期望的一样:

  1. In [6]: add_one()
  2. Out [6]: [1]
  3. In [7]: add_one()
  4. Out [7]: [1]
  5. In [8]: add_one(default_list=[3])
  6. Out [8]: [3, 1]

现在你对内存管理和默认变量有了一些了解,你可以使用你的知识来确定何时检查,以及何时在函数与可执行代码中赋值。深入理解了 Python 何时以何种方式定义对象,我们可以确保这些“陷阱”类型不会给我们的代码添加 bug。

E.5 Python作用域与内置函数:变量名称的重要性

在 Python 中,作用域的执行与你的预期有些许不同。如果你在函数作用域中定义一个变量,这个变量不被函数之外所知。让我们看一下:

  1. In [10]: def foo():
  2. ....: x = "test"
  3. In [11]: x
  4. .---------------------------------------------------------------------------
  5. NameError Traceback (most recent call last)
  6. <ipython-input-94-009520053b00> in <module>()
  7. ----> 1 x
  8. NameError: name 'x' is not defined

然而,如果之前定义了 x,我们会得到旧的定义:

  1. In [12]: x = 1
  2. In [13]: foo()
  3. In [14]: x
  4. Out [14]: 1

这些与内置函数和方法相关。如果你不小心重写了它们,从那一刻之后你都不能再使用它们了。所以,如果你重写特殊的词列表(list)或日期(date),拥有这些名字的内置函数不会在剩余的代码中正常执行(或者从那一刻之后):

  1. In [17]: from datetime import date
  2. In [19]: date(2015, 2, 5)
  3. Out[19]: datetime.date(2015, 2, 5)
  4. In [20]: date = 'my date obj'
  5. In [21]: date(2015, 2, 5)
  6. .---------------------------------------------------------------------------
  7. TypeError Traceback (most recent call last)
  8. <ipython-input-105-7f129d4341d0> in <module>()
  9. ----> 1 date(2015, 2, 5)
  10. TypeError: 'str' object is not callable

正如你所见,使用共享名称的变量(或与任何其他标准 Python 命名空间或你使用的任何其他库共享名称)可能造成调试的噩梦。如果你在代码中使用特殊的名称,并且意识到固有的变量或模块名称,就不会花几个小时调试命名空间问题。

E.6 定义对象与修改对象

在 Python 中,定义一个新的对象与修改一个老对象,执行的方式略有区别。假设你有一个函数,为一个整数加 1:

  1. def add_one_int():
  2. x += 1
  3. return x

如果你尝试运行这段函数,应该会收到一个错误:UnboundLocalError: local variable 'x' referenced before assignment。然而,如果你在函数中定义了 x,会看到不同的结果:

  1. def add_one_int():
  2. x = 0
  3. x += 1
  4. return x

这段代码有些复杂(为什么我们不能直接返回 1 ?),但是这里的重点是,我们在修改变量之前需要先声明变量,即使我们使用了一个看起来像赋值的修改(+=)。在处理像列表和字典这样的对象工作时留心这一点是特别重要的(因为我们知道修改一个对象会对存储在相同内存位置的其他对象产生副作用)。

需要记住的是,在你想要修改一个对象和想要创建或返回一个新对象时,永远要保持清晰和明确。你命名变量的方式,以及编写与实现函数的方式,是编写清晰与行为可预测的脚本的关键。

E.7 修改不可变对象

想要修改或改变不可变对象时,你需要创建新的对象。Python 不会允许你修改不可变对象,例如元组。在我们讨论 Python 内存管理时,你已经知道一些对象保存在相同的空间中。不可变对象不能被改变,它们总是被重新赋值。让我们看一下:

  1. In [1]: my_tuple = (1,)
  2. In [2]: new_tuple = my_tuple
  3. In [3]: my_tuple
  4. Out[3]: (1,)
  5. In [4]: new_tuple
  6. Out[4]: (1,)
  7. In [5]: my_tuple += (4, 5)
  8. In [6]: new_tuple
  9. Out[6]: (1,)
  10. In [7]: my_tuple
  11. Out[7]: (1, 4, 5)

可以看到,我们尝试使用 += 操作符去修改原始的元组,并且我们能够成功地做到这一点。然而,我们得到的是一个包含原始元组和追加了元组(4, 5)的新对象。我们最终没有改变 new_tuple 变量,因为我们只是将内存中的一个新地址赋给新的对象。如果你在查看 += 操作之前和之后查看内存地址,你会看到它的改变。

关于不可变对象需要记住的重点是,当改变它们的时候,它们不会使用内存中相同的地址;如果你修改它们,你实际上在创建全新的对象。如果你使用一个拥有不可变对象的类的方法或属性,尤其要记住这一点,因为你要确保自己知道何时在修改它们,何时在创建新的不可变对象。

E.8 类型检查

Python 允许简单的类型转换,这意味着你可以将字符串转换为整数或将列表转换为元组,等等。但是这些动态类型意味着可能会引发问题,特别是在大型的代码仓库中,或在你使用新的库时。常见问题是一些特定的函数、类或方法对应着一些特定的对象类型,而你传递了错误的类型。

随着你的代码变得更高级和复杂,问题会变得越来越难办。由于你的代码更加抽象,你会将所有的代码保存在变量中。如果一个函数或方法返回一个不符预期的类型(例如 None 而不是列表),这个对象可能被传递到另一个函数中——可能是一个不接受 None 类型的函数,之后抛出一个错误。很可能错误被捕获了,但代码会认为异常是因为另外的问题触发的,并且继续执行。这会很快地脱离你的控制,并且变成一个相当难以调试的问题。

对于如何处理这些问题,最好的建议是编写非常精准又清晰的代码。你应该积极测试你的代码(确保没有 bug),持续关注你的脚本,并注意任何反常的行为,以此确保函数永远返回期待的内容。你还需要添加日志来帮助确认对象包含的内容。除此之外,清楚你捕获的异常,而不只是捕获所有的异常,这会帮助你更容易地找到和修复问题。

最后,有时 Python 会实现 PEP-484(https://www.python.org/dev/peps/pep-0484/),它包含了类型提示,允许你检查传递的变量和代码,以自我检查这些问题。这在未来 Python 3 发布之前可能不会被合并,但是好消息是,这已经在进行当中,你可以期待在未来看到更多的有关类型检查的结构。

E.9 捕获多个异常

随着代码的发展,你会想要在同一行代码中捕获多个异常。举个例子,你可能想要捕获一个 TypeError,同时还有 AttributeError。如果你以为传递的是一个字典,而实际上传递的是一个列表,可能就是这种情况。它可能有一些相同的属性,但是不是所有属性。如果你需要在一行中捕获多个类型的错误,就必需在元组中编写异常,让我们看一下:

  1. my_dict = {'foo': {}, 'bar': None, 'baz': []}
  2. for k, v in my_dict.items():
  3. try:
  4. v.items()
  5. except (TypeError, AttributeError) as e:
  6. print "We had an issue!"
  7. print e

你应该会看到下面的输出(很可能呈现顺序不同):

  1. We had an issue!
  2. 'list' object has no attribute 'items'
  3. We had an issue!
  4. 'NoneType' object has no attribute 'items'

我们的异常成功地捕获了两个错误并执行了异常代码块。正如你所见,意识到你需要捕获的错误的类型,并且理解语法(将其放到元组中)对你的代码来说是必需的。如果你简单地列出它们(通过一个列表或只是通过逗号分隔),你的代码可能不会正常地执行,而且你不会捕获到这两个异常。

E.10 调试的力量

随着你成为一名更加高级的开发者和数据处理者,你会遇到更多的问题和错误来调试。我们希望可以告诉你它会变得更简单,但是在它变简单之前,你的调试过程会更加集中和严谨。这是因为你会用到更高级的代码和库,处理更加困难的问题。

即便如此,你拥有很多技术和工具,可以帮助你脱离困境。你可以在 IPython 中执行代码,在开发过程中得到更多反馈。你可以添加日志到脚本中,以更好地理解发生了什么。如果解析网页时遇到问题,你可以让抓取器截屏,并将它们保存到文件中。你可以在 IPython notebook 中与其他人分享代码,或者在其他许多有帮助的站点分享代码,以得到反馈。

Python 中同样有一些很棒的调试工具,包括 pdbhttps://docs.python.org/2/library/pdb.html),它允许你逐句执行代码(或模块中的其他代码),并且在所有的错误前后,精确地看到每个对象保存的内容。YouTube 上有一个很棒的关于 pdb 的快速介绍(https://www.youtube.com/watch?v=bZZTeKPRSLQ),展示了一些在代码中使用 pdb 的方式。

除此之外,你需要阅读并编写文档和测试。在本书中我们已经介绍了一些基础,但是我们强烈建议你将本书作为一个起点,并在将来进一步研究文档和测试。Ned Batchelder 最近关于上手测试的 PyCon 讲座(https://www.youtube.com/watch?v=FxSsnHeWQBY)是一个好的起点。Jacob Kaplan-Moss 在 PyCon 2011 上也做了一个很棒的关于文档的讲座(https://www.youtube.com/watch?v=z3fRu9pkuXE)。通过阅读和编写文档,以及编写和执行测试,你可以确保没有因为错误的信息而将错误引入代码,或没有因为未做测试而遗留错误。

我们希望本书是这些概念的优秀入门介绍,但是我们鼓励你继续阅读和开发,通过寻找更多的 Python 学习资源,成为一名更出色的 Python 开发者。