4.6 程序开发

编程是一种技能,需要获得几年的各种编程语言和任务的经验。关键的高层次能力是 算法设计 及其在 结构化编程 中的实现。关键的低层次的能力包括熟悉语言的语法结构,以及排除故障的程序(不能表现预期的行为的程序)的各种诊断方法的知识。

本节描述一个程序模块的内部结构,以及如何组织一个多模块的程序。然后描述程序开发过程中出现的各种错误,你可以做些什么来解决这些问题,更好的是,从一开始就避免它们。

Python 模块的结构

程序模块的目的是把逻辑上相关的定义和函数结合在一起,以方便重用和更高层次的抽象。Python 模块只是一些单独的.py文件。例如,如果你在处理一种特定的语料格式,读取和写入这种格式的函数可以放在一起。这两种格式所使用的常量,如字段分隔符或一个EXTN = ".inf"文件扩展名,可以共享。如果要更新格式,你就会知道只有一个文件需要改变。类似地,一个模块可以包含用于创建和操纵一种特定的数据结构如语法树的代码,或执行特定的处理任务如绘制语料统计图表的代码。

当你开始编写 Python 模块,有一些例子来模拟是有益的。你可以使用变量__file__定位你的系统中任一 NLTK 模块的代码,例如:

  1. >>> nltk.metrics.distance.__file__
  2. '/usr/lib/python2.5/site-packages/nltk/metrics/distance.pyc'

这将返回模块已编译.pyc文件的位置,在你的机器上你可能看到的位置不同。你需要打开的文件是对应的.py源文件,它和.pyc文件放在同一目录下。另外,你可以在网站上查看该模块的最新版本http://code.google.com/p/nltk/source/browse/trunk/nltk/nltk/metrics/distance.py

与其他 NLTK 的模块一样,distance.py以一组注释行开始,包括一行模块标题和作者信息。(由于代码会被发布,也包括代码可用的 URL、版权声明和许可信息。)接下来是模块级的文档字符串,三重引号的多行字符串,其中包括当有人输入help(nltk.metrics.distance)将被输出的关于模块的信息。

  1. # Natural Language Toolkit: Distance Metrics
  2. #
  3. # Copyright (C) 2001-2013 NLTK Project
  4. # Author: Edward Loper <edloper@gmail.com>
  5. # Steven Bird <stevenbird1@gmail.com>
  6. # Tom Lippincott <tom@cs.columbia.edu>
  7. # URL: <http://nltk.org/>
  8. # For license information, see LICENSE.TXT
  9. #
  10. """
  11. Distance Metrics.
  12. Compute the distance between two items (usually strings).
  13. As metrics, they must satisfy the following three requirements:
  14. 1\. d(a, a) = 0
  15. 2\. d(a, b) >= 0
  16. 3\. d(a, c) <= d(a, b) + d(b, c)
  17. """

多模块程序

一些程序汇集多种任务,例如从语料库加载数据、对数据进行一些分析、然后将其可视化。我们可能已经有了稳定的模块来加载数据和实现数据可视化。我们的工作可能会涉及到那些分析任务的编码,只是从现有的模块调用一些函数。4.7描述了这种情景。

Images/multi-module.png

图 4.7:一个多模块程序的结构:主程序my_program.py从其他两个模块导入函数;独特的分析任务在主程序本地进行,而一般的载入和可视化任务被分离开以便可以重用和抽象。

通过将我们的工作分成几个模块和使用import语句访问别处定义的函数,我们可以保持各个模块简单,易于维护。这种做法也将导致越来越多的模块的集合,使我们有可能建立复杂的涉及模块间层次结构的系统。设计这样的系统是一个复杂的软件工程任务,这超出了本书的范围。

错误源头

掌握编程技术取决于当程序不按预期运作时各种解决问题的技能的总结。一些琐碎的东西,如放错位置的符号,可能导致程序的行为异常。我们把这些叫做“bugs”,因为它们与它们所导致的损害相比较小。它们不知不觉的潜入我们的代码,只有在很久以后,我们在一些新的数据上运行程序时才会发现它们的存在。有时,一个错误的修复仅仅是暴露出另一个,于是我们得到了鲜明的印象,bug 在移动。我们唯一的安慰是 bugs 是自发的而不是程序员的错误。

繁琐浮躁不谈,调试代码是很难的,因为有那么多的方式出现故障。我们对输入数据、算法甚至编程语言的理解可能是错误的。让我们分别来看看每种情况的例子。

首先,输入的数据可能包含一些意想不到的字符。例如,WordNet 的同义词集名称的形式是tree.n.01,由句号分割成 3 个部分。最初 NLTK 的 WordNet 模块使用split('.')分解这些名称。然而,当有人试图寻找词 PhD 时,这种方法就不能用了,它的同义集名称是ph.d..n.01,包含 4 个逗号而不是预期的 2 个。解决的办法是使用rsplit('.', 2)利用最右边的句号最多分割两次,留下字符串ph.d.不变。虽然在模块发布之前已经测试过,但就在几个星期前有人检测到这个问题(见http://code.google.com/p/nltk/issues/detail?id=297)。

第二,提供的函数可能不会像预期的那样运作。例如,在测试 NLTK 中的 WordNet 接口时,一名作者注意到没有同义词集定义了反义词,而底层数据库提供了大量的反义词的信息。这看着像是 WordNet 接口中的一个错误,结果却是对 WordNet 本身的误解:反义词在词条中定义,而不是在义词集中。唯一的“bug”是对接口的一个误解(参见http://code.google.com/p/nltk/issues/detail?id=98)。

第三,我们对 Python 语义的理解可能出错。很容易做出关于两个操作符的相对范围的错误的假设。例如,"%s.%s.%02d" % "ph.d.", "n", 1产生一个运行时错误TypeError: not enough arguments for format string。这是因为百分号操作符优先级高于逗号运算符。解决办法是添加括号强制限定所需的范围。作为另一个例子,假设我们定义一个函数来收集一个文本中给定长度的所有词符。该函数有文本和词长作为参数,还有一个额外的参数,允许指定结果的初始值作为参数:

  1. >>> def find_words(text, wordlength, result=[]):
  2. ... for word in text:
  3. ... if len(word) == wordlength:
  4. ... result.append(word)
  5. ... return result
  6. >>> find_words(['omg', 'teh', 'lolcat', 'sitted', 'on', 'teh', 'mat'], 3) ![[1]](/projects/nlp-py-2e-zh/Images/ffa808c97c7034af1bc2806ed7224203.jpg)
  7. ['omg', 'teh', 'teh', 'mat']
  8. >>> find_words(['omg', 'teh', 'lolcat', 'sitted', 'on', 'teh', 'mat'], 2, ['ur']) ![[2]](/projects/nlp-py-2e-zh/Images/aa68e0e8f4d58caa31e5542dabe4ddc2.jpg)
  9. ['ur', 'on']
  10. >>> find_words(['omg', 'teh', 'lolcat', 'sitted', 'on', 'teh', 'mat'], 3) ![[3]](/projects/nlp-py-2e-zh/Images/496754d8cdb6262f8f72e1f066bab359.jpg)
  11. ['omg', 'teh', 'teh', 'mat', 'omg', 'teh', 'teh', 'mat']

我们第一次调用find_words()[1],我们得到所有预期的三个字母的词。第二次,我们为 result 指定一个初始值,一个单元素列表['ur'],如预期,结果中有这个词连同我们的文本中的其他双字母的词。现在,我们再次使用[1]中相同的参数调用find_words()[3],但我们得到了不同的结果!我们每次不使用第三个参数调用find_words(),结果都只会延长前次调用的结果,而不是以在函数定义中指定的空链表 result 开始。程序的行为并不如预期,因为我们错误地认为在函数被调用时会创建默认值。然而,它只创建了一次,在 Python 解释器加载这个函数时。这一个列表对象会被使用,只要没有给函数提供明确的值。

调试技术

由于大多数代码错误是因为程序员的不正确的假设,你检测 bug 要做的第一件事是检查你的假设。通过给程序添加print语句定位问题,显示重要的变量的值,并显示程序的进展程度。

如果程序产生一个“异常”——运行时错误——解释器会输出一个堆栈跟踪,精确定位错误发生时程序执行的位置。如果程序取决于输入数据,尽量将它减少到能产生错误的最小尺寸。

一旦你已经将问题定位在一个特定的函数或一行代码,你需要弄清楚是什么出了错误。使用交互式命令行重现错误发生时的情况往往是有益的。定义一些变量,然后复制粘贴可能出错的代码行到会话中,看看会发生什么。检查你对代码的理解,通过阅读一些文档和测试与你正在试图做的事情相同的其他代码示例。尝试将你的代码解释给别人听,也许他们会看出出错的地方。

Python 提供了一个调试器,它允许你监视程序的执行,指定程序暂停运行的行号(即断点),逐步调试代码段和检查变量的值。你可以如下方式在你的代码中调用调试器:

  1. >>> import pdb
  2. >>> import mymodule
  3. >>> pdb.run('mymodule.myfunction()')

它会给出一个提示(Pdb),你可以在那里输入指令给调试器。输入help来查看命令的完整列表。输入step(或只输入s)将执行当前行然后停止。如果当前行调用一个函数,它将进入这个函数并停止在第一行。输入next(或只输入n)是类似的,但它会在当前函数中的下一行停止执行。break(或b)命令可用于创建或列出断点。输入continue(或c)会继续执行直到遇到下一个断点。输入任何变量的名称可以检查它的值。

我们可以使用 Python 调试器来查找find_words() 函数的问题。请记住问题是在第二次调用函数时产生的。我们一开始将不使用调试器而调用该函数first-run_,使用可能的最小输入。第二次我们使用调试器调用它second-run_。.. doctest-ignore:

  1. >>> import pdb
  2. >>> find_words(['cat'], 3) # [_first-run]
  3. ['cat']
  4. >>> pdb.run("find_words(['dog'], 3)") # [_second-run]
  5. > <string>(1)<module>()
  6. (Pdb) step
  7. --Call--
  8. > <stdin>(1)find_words()
  9. (Pdb) args
  10. text = ['dog']
  11. wordlength = 3
  12. result = ['cat']

防御性编程

为了避免一些调试的痛苦,养成防御性的编程习惯是有益的。不要写 20 行程序然后测试它,而是自下而上的打造一些明确可以运作的小的程序片。每次你将这些程序片组合成更大的单位都要仔细的看它是否能如预期的运作。考虑在你的代码中添加assert语句,指定变量的属性,例如assert(isinstance(text, list))。如果text的值在你的代码被用在一些较大的环境中时变为了一个字符串,将产生一个AssertionError,于是你会立即得到问题的通知。

一旦你觉得你发现了错误,作为一个假设查看你的解决方案。在重新运行该程序之前尝试预测你修正错误的影响。如果 bug 不能被修正,不要陷入盲目修改代码希望它会奇迹般地重新开始运作的陷阱。相反,每一次修改都要尝试阐明错误是什么和为什么这样修改会解决这个问题的假设。如果这个问题没有解决就撤消这次修改。

当你开发你的程序时,扩展其功能,并修复所有 bug,维护一套测试用例是有益的。这被称为回归测试,因为它是用来检测代码“回归”的地方——修改代码后会带来一个意想不到的副作用是以前能运作的程序不运作了的地方。Python 以doctest模块的形式提供了一个简单的回归测试框架。这个模块搜索一个代码或文档文件查找类似与交互式 Python 会话这样的文本块,这种形式你已经在这本书中看到了很多次。它执行找到的 Python 命令,测试其输出是否与原始文件中所提供的输出匹配。每当有不匹配时,它会报告预期值和实际值。有关详情,请查询在 documentation at http://docs.python.org/library/doctest.html上的doctest文档。除了回归测试它的值,doctest模块有助于确保你的软件文档与你的代码保持同步。

也许最重要的防御性编程策略是要清楚的表述你的代码,选择有意义的变量和函数名,并通过将代码分解成拥有良好文档的接口的函数和模块尽可能的简化代码。