4.4 函数:结构化编程的基础

函数提供了程序代码打包和重用的有效途径,已经在3中解释过。例如,假设我们发现我们经常要从 HTML 文件读取文本。这包括以下几个步骤,打开文件,将它读入,规范化空白符号,剥离 HTML 标记。我们可以将这些步骤收集到一个函数中,并给它一个名字,如get_text(),如4.2所示。

  1. import re
  2. def get_text(file):
  3. """Read text from a file, normalizing whitespace and stripping HTML markup."""
  4. text = open(file).read()
  5. text = re.sub(r'<.*?>', ' ', text)
  6. text = re.sub('\s+', ' ', text)
  7. return text

现在,任何时候我们想从一个 HTML 文件得到干净的文字,都可以用文件的名字作为唯一的参数调用get_text()。它会返回一个字符串,我们可以将它指定给一个变量,例如:contents = get_text("test.html")。每次我们要使用这一系列的步骤,只需要调用这个函数。

使用函数可以为我们的程序节约空间。更重要的是,我们为函数选择名称可以提高程序 可读性 。在上面的例子中,只要我们的程序需要从文件读取干净的文本,我们不必弄乱这四行代码的程序,只需要调用get_text()。这种命名方式有助于提供一些“语义解释”——它可以帮助我们的程序的读者理解程序的“意思”。

请注意,上面的函数定义包含一个字符串。函数定义内的第一个字符串被称为文档字符串。它不仅为阅读代码的人记录函数的功能,从文件加载这段代码的程序员也能够访问:

  1. | >>> help(get_text)
  2. | Help on function get_text in module __main__:
  3. |
  4. | get(text)
  5. | Read text from a file, normalizing whitespace and stripping HTML markup.

我们首先定义函数的两个参数,msgnum [1]。然后调用函数,并传递给它两个参数,monty3 [2];这些参数填补了参数提供的“占位符”,为函数体中出现的msgnum提供值。

我们看到在下面的例子中不需要有任何参数:

  1. >>> def monty():
  2. ... return "Monty Python"
  3. >>> monty()
  4. 'Monty Python'

函数通常会通过return语句将其结果返回给调用它的程序,正如我们刚才看到的。对于调用程序,它看起来就像函数调用已被函数结果替代,例如:

  1. >>> repeat(monty(), 3)
  2. 'Monty Python Monty Python Monty Python'
  3. >>> repeat('Monty Python', 3)
  4. 'Monty Python Monty Python Monty Python'

一个 Python 函数并不是一定需要有一个 return 语句。有些函数做它们的工作的同时会附带输出结果、修改文件或者更新参数的内容。(这种函数在其他一些编程语言中被称为“过程”)。

考虑以下三个排序函数。第三个是危险的,因为程序员可能没有意识到它已经修改了给它的输入。一般情况下,函数应该修改参数的内容(my_sort1())或返回一个值(my_sort2()),而不是两个都做(my_sort3())。

  1. >>> def my_sort1(mylist): # good: modifies its argument, no return value
  2. ... mylist.sort()
  3. >>> def my_sort2(mylist): # good: doesn't touch its argument, returns value
  4. ... return sorted(mylist)
  5. >>> def my_sort3(mylist): # bad: modifies its argument and also returns it
  6. ... mylist.sort()
  7. ... return mylist

参数传递

早在4.1节中,你就已经看到了赋值操作,而一个结构化对象的值是该对象的引用。函数也是一样的。Python 按它的值来解释函数的参数(这被称为按值调用)。在下面的代码中,set_up()有两个参数,都在函数内部被修改。我们一开始将一个空字符串分配给w,将一个空列表分配给p。调用该函数后,w没有变,而p改变了:

  1. >>> def set_up(word, properties):
  2. ... word = 'lolcat'
  3. ... properties.append('noun')
  4. ... properties = 5
  5. ...
  6. >>> w = ''
  7. >>> p = []
  8. >>> set_up(w, p)
  9. >>> w
  10. ''
  11. >>> p
  12. ['noun']

请注意,w没有被函数改变。当我们调用set_up(w, p)时,w(空字符串)的值被分配到一个新的变量word。在函数内部word值被修改。然而,这种变化并没有传播给w。这个参数传递过程与下面的赋值序列是一样的:

  1. >>> w = ''
  2. >>> word = w
  3. >>> word = 'lolcat'
  4. >>> w
  5. ''

让我们来看看列表p上发生了什么。当我们调用set_up(w, p)p的值(一个空列表的引用)被分配到一个新的本地变量properties,所以现在这两个变量引用相同的内存位置。函数修改properties,而这种变化也反映在p值上,正如我们所看到的。函数也分配给 properties 一个新的值(数字5);这并不能修改该内存位置上的内容,而是创建了一个新的局部变量。这种行为就好像是我们做了下列赋值序列:

  1. >>> p = []
  2. >>> properties = p
  3. >>> properties.append('noun')
  4. >>> properties = 5
  5. >>> p
  6. ['noun']

因此,要理解 Python 按值传递参数,只要了解它是如何赋值的就足够了。记住,你可以使用id()函数和is操作符来检查每个语句执行之后你对对象标识符的理解。

变量的作用域

函数定义为变量创建了一个新的局部的作用域。当你在函数体内部分配一个新的变量时,这个名字只在该函数内部被定义。函数体外或者在其它函数体内,这个名字是不可见的。这一行为意味着你可以选择变量名而不必担心它与你的其他函数定义中使用的名称冲突。

当你在一个函数体内部使用一个现有的名字时,Python 解释器先尝试按照函数本地的名字来解释。如果没有发现,解释器检查它是否是一个模块内的全局名称。最后,如果没有成功,解释器会检查是否是 Python 内置的名字。这就是所谓的名称解析的 LGB 规则:本地(local),全局(global),然后内置(built-in)。

小心!

一个函数可以使用global声明创建一个新的全局变量。然而,这种做法应尽可能避免。在函数内部定义全局变量会导致上下文依赖性而限制函数的可移植性(或重用性)。一般来说,你应该使用参数作为函数的输入,返回值作为函数的输出。

参数类型检查

我们写程序时,Python 不会强迫我们声明变量的类型,这允许我们定义参数类型灵活的函数。例如,我们可能希望一个标注只是一个词序列,而不管这个序列被表示为一个列表、元组(或是迭代器,一种新的序列类型,超出了当前的讨论范围)。

然而,我们常常想写一些能被他人利用的程序,并希望以一种防守的风格,当函数没有被正确调用时提供有益的警告。下面的tag()函数的作者假设其参数将始终是一个字符串。

  1. >>> def tag(word):
  2. ... if word in ['a', 'the', 'all']:
  3. ... return 'det'
  4. ... else:
  5. ... return 'noun'
  6. ...
  7. >>> tag('the')
  8. 'det'
  9. >>> tag('knight')
  10. 'noun'
  11. >>> tag(["'Tis", 'but', 'a', 'scratch']) ![[1]](/projects/nlp-py-2e-zh/Images/ffa808c97c7034af1bc2806ed7224203.jpg)
  12. 'noun'

该函数对参数'the''knight'返回合理的值,传递给它一个列表[1],看看会发生什么——它没有抱怨,虽然它返回的结果显然是不正确的。此函数的作者可以采取一些额外的步骤来确保tag()函数的参数word是一个字符串。一种直白的做法是使用if not type(word) is str检查参数的类型,如果word不是一个字符串,简单地返回 Python 特殊的空值None。这是一个略微的改善,因为该函数在检查参数类型,并试图对错误的输入返回一个“特殊的”诊断结果。然而,它也是危险的,因为调用程序可能不会检测None是故意设定的“特殊”值,这种诊断的返回值可能被传播到程序的其他部分产生不可预测的后果。如果这个词是一个 Unicode 字符串这种方法也会失败。因为它的类型是unicode而不是str。这里有一个更好的解决方案,使用assert语句和 Python 的basestring的类型一起,它是unicodestr的共同类型。

  1. >>> def tag(word):
  2. ... assert isinstance(word, basestring), "argument to tag() must be a string"
  3. ... if word in ['a', 'the', 'all']:
  4. ... return 'det'
  5. ... else:
  6. ... return 'noun'

如果assert语句失败,它会产生一个不可忽视的错误而停止程序执行。此外,该错误信息是容易理解的。程序中添加断言能帮助你找到逻辑错误,是一种防御性编程。一个更根本的方法是在本节后面描述的使用文档字符串为每个函数记录参数的文档。

功能分解

结构良好的程序通常都广泛使用函数。当一个程序代码块增长到超过 10-20 行,如果将代码分成一个或多个函数,每一个有明确的目的,这将对可读性有很大的帮助。这类似于好文章被划分成段,每段话表示一个主要思想。

函数提供了一种重要的抽象。它们让我们将多个动作组合成一个单一的复杂的行动,并给它关联一个名称。(比较我们组合动作 go 和 bring back 为一个单一的更复杂的动作 fetch。)当我们使用函数时,主程序可以在一个更高的抽象水平编写,使其结构更透明,例如

  1. >>> data = load_corpus()
  2. >>> results = analyze(data)
  3. >>> present(results)

适当使用函数使程序更具可读性和可维护性。另外,重新实现一个函数已成为可能——使用更高效的代码替换函数体——不需要关心程序的其余部分。

思考4.3freq_words函数。它更新一个作为参数传递进来的频率分布的内容,并输出前 n 个最频繁的词的列表。

  1. from urllib import request
  2. from bs4 import BeautifulSoup
  3. def freq_words(url, freqdist, n):
  4. html = request.urlopen(url).read().decode('utf8')
  5. raw = BeautifulSoup(html).get_text()
  6. for word in word_tokenize(raw):
  7. freqdist[word.lower()] += 1
  8. result = []
  9. for word, count in freqdist.most_common(n):
  10. result = result + [word]
  11. print(result)

这个函数有几个问题。该函数有两个副作用:它修改了第二个参数的内容,并输出它已计算的结果的经过选择的子集。如果我们在函数内部初始化FreqDist()对象(在它被处理的同一个地方),并且去掉选择集而将结果显示给调用程序的话,函数会更容易理解和更容易在其他地方重用。考虑到它的任务是找出频繁的一个词,它应该只应该返回一个列表,而不是整个频率分布。在4.4中,我们重构此函数,并通过去掉freqdist参数简化其接口。

  1. from urllib import request
  2. from bs4 import BeautifulSoup
  3. def freq_words(url, n):
  4. html = request.urlopen(url).read().decode('utf8')
  5. text = BeautifulSoup(html).get_text()
  6. freqdist = nltk.FreqDist(word.lower() for word in word_tokenize(text))
  7. return [word for (word, _) in fd.most_common(n)]

freq_words函数的可读性和可用性得到改进。

注意

我们将_用作变量名。这是对任何其他变量没有什么不同,除了它向读者发出信号,我们没有使用它保存的信息。

编写函数的文档

如果我们已经将工作分解成函数分解的很好了,那么应该很容易使用通俗易懂的语言描述每个函数的目的,并且在函数的定义顶部的文档字符串中提供这些描述。这个说明不应该解释函数是如何实现的;实际上,应该能够不改变这个说明,使用不同的方法,重新实现这个函数。

对于最简单的函数,一个单行的文档字符串通常就足够了(见4.2)。你应该提供一个在一行中包含一个完整的句子的三重引号引起来的字符串。对于不寻常的函数,你还是应该在第一行提供一个一句话总结,因为很多的文档字符串处理工具会索引这个字符串。它后面应该有一个空行,然后是更详细的功能说明(见http://www.python.org/dev/peps/pep-0257/的文档字符串约定的更多信息)。

文档字符串可以包括一个 doctest 块,说明使用的函数和预期的输出。这些都可以使用 Python 的docutils模块自动测试。文档字符串应当记录函数的每个参数的类型和返回类型。至少可以用纯文本来做这些。然而,请注意,NLTK 使用 Sphinx 标记语言来记录参数。这种格式可以自动转换成富结构化的 API 文档(见http://nltk.org/),并包含某些“字段”的特殊处理,例如param,允许清楚地记录函数的输入和输出。4.5演示了一个完整的文档字符串。

  1. def accuracy(reference, test):
  2. """
  3. Calculate the fraction of test items that equal the corresponding reference items.
  4. Given a list of reference values and a corresponding list of test values,
  5. return the fraction of corresponding values that are equal.
  6. In particular, return the fraction of indexes
  7. {0<i<=len(test)} such that C{test[i] == reference[i]}.
  8. >>> accuracy(['ADJ', 'N', 'V', 'N'], ['N', 'N', 'V', 'ADJ'])
  9. 0.5
  10. :param reference: An ordered list of reference values
  11. :type reference: list
  12. :param test: A list of values to compare against the corresponding
  13. reference values
  14. :type test: list
  15. :return: the accuracy score
  16. :rtype: float
  17. :raises ValueError: If reference and length do not have the same length
  18. """
  19. if len(reference) != len(test):
  20. raise ValueError("Lists must have the same length.")
  21. num_correct = 0
  22. for x, y in zip(reference, test):
  23. if x == y:
  24. num_correct += 1
  25. return float(num_correct) / len(reference)