【python】读书笔记之编写高效函数(十)

十 编写高效的函数

  函数就像是程序中的程序,通过拆分函数可以将代码分解成更小的单元。它能让我们不必编写重复的代码,减少错误的发生。但编写高效的函数要求我们在命名、大小、参数和复杂性等方面做出很多决策。本章将讲解编写函数的不同方法,分析各种取舍的利弊,深入探讨如何在函数的大小之间进行权衡,参数数量如何影响函数的复杂度,以及如何使用运算符***编写可变参数函数。本章还将讨论函数式编程范式以及按这种范式编写函数有何益处。

10.1 函数名

  函数名称应该遵循一般标识符遵循的惯例,正如第4章所述。它通常包括一个动词,因为函数经常被用来执行某些动作。它也可以包含一个名词,用来描述被操作的事物,比如refreshConnection()setPassword()extract_version(),这些名字说明了函数的作用和目的。

  对于类和模块中的方法而言,名称可能不需要名词。SatelliteConnection中的reset()方法和webbrowser模块中的open()函数都已经提供了必要的信息,能让人明白reset的对象是卫星连接,open的对象是网页浏览器。

  尽量使用长的、具有描述性的名字,而不是缩写或者太短的名字。一个数学家也许能够立刻知道名为gcd()的函数会返回两个数字的最大公分母,但其他人会觉得getGreatestCommonDenominator()更容易理解。记住,不要使用Python内置的任何函数名或模块名,例如allanydateemailfileformathashidinputlistminmaxobjectopenrandomsetstrsumtesttype

10.2 函数大小的权衡

  有些程序员说,函数应该尽可能简短,不要超过屏幕能容纳的长度。与长达几百行的函数相比,只有十几行的函数确实比较容易理解,但将大函数拆分成多个小函数也有缺点。

  让我们先看看小函数的优点:

  • 函数的代码更容易理解;
  • 函数可能需要较少的参数;
  • 函数不太可能有副作用,如10.4.1节所述;
  • 函数更容易测试和调试;
  • 函数引发的不同种类的异常数量要少。

  但小函数也有缺点:

  • 编写简短的函数往往意味着程序中会有更多的函数;
  • 拥有更多的函数意味着程序更加复杂;
  • 拥有更多的函数也意味着必须想出更多的具有描述性的、准确的名称,这是一个难题;
  • 使用更多的函数需要写更多的文档进行说明;
  • 函数之间的关系会更复杂。

  有些人把“越短越好”的准则发挥到了极致,他们声称所有的函数最多只能有三四行代码。这太疯狂了。

10.3 函数的形参和实参

  函数的形参是def语句括号中的变量名称,实参则是函数调用括号中的数值。函数的参数越多,代码的可配置性和通用性就越强,但更多的参数也意味着函数更复杂。

  一个合适的准则是保持0~3个参数,参数超过6个可能就偏多了。当函数过于复杂时,最好考虑将其拆分成参数较少的多个小函数。

10.3.1 默认参数

  降低参数复杂性的一个方法是为函数提供默认参数。默认参数是指在函数调用时如果没有指定参数,会用来代替参数的默认值。将大多数函数调用时使用的参数值作为默认参数可以避免在函数调用时重复输入。

  默认参数的设定位置是在def语句中的参数名称和等号后。例如,在下面的introduction()函数中,如果函数调用时没有指定greeting参数的值,它的值就是默认参数值'Hello'

1
2
3
4
5
6
7
>>> def introduction(name, greeting='Hello'):
... print(greeting + ', ' + name)
...
>>> introduction('Alice')
Hello, Alice
>>> introduction('Hiro', 'Ohiyo gozaimasu')
Ohiyo gozaimasu, Hiro

  在调用introduction()时,如果不指定第2个参数,那么函数会默认使用字符串'Hello'。注意,带有默认值的参数需要排列在其他没有默认值的参数之后。

10.3.2 使用***向函数传参

  可以使用***语法(通常读作star和star star)向函数传递一组参数。*语法允许你将一个可迭代对象(比如列表或元组)中的项作为参数逐个传入,**语法允许你将映射对象(如字典)中的键−值对作为参数逐个传入。

10.3.3 使用*创建可变参数函数

  在def语句中使用*语法可以创建可变参数函数,它可以接受不定数量的位置参数。举例来说,print()就是一个可变参数函数,因为你可以向它传递任意数量的字符串,比如print('Hello!')print('My name is',name)。注意,10.3.2节是在函数调用中使用*语法,而本节是在函数定义中使用*语法。

来看一个示例,我们创建一个product()函数,它接受任意数量的参数,需要返回它们的乘积:

1
2
3
4
5
6
7
8
9
10
>>> def product(*args):
... result = 1
... for num in args:
... result *= num
... return result
...
>>> product(3, 3)
9
>>> product(2, 1, 2, 3)
12

在函数内部,args只是一个包含所有位置参数的普通Python元组。从技术角度讲,这个参数可以叫任何名字,只要以*开头即可,但惯例是将其命名为args

  什么时候使用*语法是需要思考的,毕竟创建可变参数函数还有另一个替代方案,即接受一个列表类型(或者其他可迭代数据类型)作为单一参数,列表内部包含数量不定的项。内置的sum()函数就是这样一个例子:

1
2
>>> sum([2, 1, 2, 3])
8

  sum()函数接受一个可迭代参数,传递多个参数时会出现异常:

1
2
3
4
>>> sum(2, 1, 2, 3)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: sum() takes at most 2 arguments (4 given)

  而内置函数min()max()(分别用来寻找多个值中的最小值和最大值)既可以接受一个可迭代参数,也可以接受多个独立的参数:

1
2
3
4
5
6
7
8
>>> min([2, 1, 3, 5, 8])
1
>>> min(2, 1, 3, 5, 8)
1
>>> max([2, 1, 3, 5, 8])
8
>>> max(2, 1, 3, 5, 8)
8

这些函数都接受不定数量的参数,为什么它们的参数设计成不同的模式?什么时候应该把函数设计成只接受一个可迭代参数,什么时候又该使用*语法接受多个独立参数呢?

  如何设计参数取决于我们预测程序员会如何使用我们的代码。print()函数之所以需要多个参数,是因为程序员经常向它传递一连串的字符串或包含字符串的变量,比如print('My name is', name)。把这些字符串归纳成一个列表,再将列表传递给print()的做法并不常见。print()已经被设计成在接受列表作为参数时完整地打印该列表的值,所以不能把它设计成逐个打印列表中的单个值。

  sum()函数没理由接受独立的参数,因为Python提供的+运算符可以达到同样的目的。你可以直接写2 + 4 + 8这样的代码,而不必写sum(2, 4, 8)。不定数量的参数只能作为列表传递给sum()是合理的。

  min()函数和max()函数允许两种风格的传参。如果只传递了一个参数,该函数会假定它是一个待检查的列表或元组;如果传递了多个参数,则假定它们是待检查的值。这两个函数既需要用于程序运行时处理值的列表,如函数调用min(allExpenses),也需要处理程序员挑选的多个参数,比如max(0, someNumber)。所以这些函数被设计成接受两种参数。下面的myMinFunction()是我对min()函数的另一种实现,它展示了如何同时处理两种风格的传参:

1
2
3
4
5
6
7
8
9
10
11
12
13
def myMinFunction(*args):
if len(args) == 1:
values = args[0] ❶
else:
values = args ❷

if len(values) == 0:
raise ValueError('myMinFunction() args is an empty sequence') ❸

for i, value in enumerate(values): ❹
if i == 0 or value < smallestValue:
smallestValue = value
return smallestValue

myMinFunction()使用*语法接受元组形式且数量不定的参数。如果这个元组只有一个值,那么我们假定这个值是待检查的值的序列❶。否则,假定args是待检查的元组❷。无论何种情况,values变量都将包含一个值序列,供后续代码检查。与真实的min()函数一样,如果调用者没有传递任何参数或者传递了空序列,函数就会抛出ValueError❸。剩余代码的作用是遍历序列并返回找到的最小值❹。简单地说,myMinFunction()只接受列表或元组这两类序列,而不接受任何可迭代的值。

  你可能会疑惑为什么我们不总是将函数设计为接受两种传递不定参数的方式。我的回答是,函数应该尽量简单。除非两种调用方式都很常见,否则应该只支持一种而放弃另一种。如果函数通常接受的是程序运行时创建的数据结构,那么最好设计成接受单个参数。如果通常接受的是程序员在编写代码时指定的参数,那么最好使用*语法接受不定数量的参数。

10.3.4 使用**创建可变参数函数

  **语法也可用于创建可变参数函数。def语句中的*语法表示不定数量的位置参数,而**语法表示不定数量的可选关键字参数。如果不使用**语法定义接受多个关键字参数(其中有多个参数是可选的)的函数,就很难编写def语句。假设有一个formMolecule()函数,它接受已经发现的118种化学元素作为参数:

1
>>> def formMolecule(hydrogen, helium, lithium, beryllium, boron, --snip--

  如果指定hydrogen的参数为2,oxygen的参数为1以返回water,按照这种写法会比较麻烦,可读性差,因为其他的无关元素必须被设置为0:

1
2
>>> formMolecule(2, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 --snip--
'water'

  使用命名的关键字参数可以更容易管理函数,每个参数都可以设置默认值,而不必在函数调用中传递参数。

注意 尽管术语“实参”和“形参”的定义很清楚,但程序员更倾向于将“关键字形参”和“关键字实参”统称为“关键字参数”。

10.3.5 使用***创建包装函数

  在def语句中,***语法的一个常见用途是创建包装函数。包装函数用来将接受的参数传递给另一个函数并返回该函数的结果。使用***语法可以向被包装的函数转发任何参数。比如,创建一个printLowercase()函数来包装内置的print()函数。printLowercase()函数依靠print()完成实际工作,但会先将字符串参数转换为小写形式:

1
2
3
4
5
6
7
8
9
10
11
>>> def printLower(*args, **kwargs): ❶
... args = list(args) ❷
... for i, value in enumerate(args):
... args[i] = str(value).lower()
... return print(*args, **kwargs) ❸
...
>>> name = 'Albert'
>>> printLower('Hello,', name)
hello, albert
>>> printLower('DOG', 'CAT', 'MOOSE', sep=', ')
dog, cat, moose

  printLower()函数通过*语法接受args参数对应的元组❶,其中包含了不定数量的位置参数。**语法则将所有关键字参数整理成一个字典分配给kwargs参数。如果一个函数同时使用*args**kwargs,那么*args参数必须位于**kwargs参数之前。我们创建的函数需要首先修改一些参数,再将参数传递给被包装的print()函数,所以args元组可以转换为列表形式❷。

  在将args中的字符串改为小写后,使用***语法将args中的项和kwargs中的键−值对作为不同参数逐个传递给print()❸。printLower()会将print()的返回值作为自己的返回值。这些步骤有效地包装了print()函数。

10.4 函数式编程

  函数式编程是一种编程范式,它强调在不修改全局变量和任何外界状态(如硬盘上的文件、互联网连接或数据库)的情况下编写函数进行计算。Erlang、Lisp、Haskell等编程语言在很大程度上是围绕着函数式编程的概念设计的。尽管Python并不完全遵循函数式编程范式,但也有一些函数式编程的特性。Python程序能使用的主要特性有:无副作用的函数、高阶函数和lambda函数。

10.4.1 副作用

  副作用是指函数对自身代码和局部变量之外的其他部分所做的任何改变。为了说明白这一点,我们创建一个subtract()函数,实现Python减法运算符的功能:

1
2
3
4
5
>>> def subtract(number1, number2):
... return number1 - number2
...
>>> subtract(123, 987)
-864

这个subtract()函数没有副作用。换言之,它不会影响程序中任何该函数代码之外的部分。从程序或者计算机的状态中没办法推测出subtract()是被调用了1次、2次还是100万次。无副作用的函数是可以修改其内部的局部变量的,因为这些变化与程序中的其他部分是隔离的。

  假设有一个addToTotal()函数,它的功能是将数字参数添加到名为TOTAL的全局变量中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
>>> TOTAL = 0
>>> def addToTotal(amount):
... global TOTAL
... TOTAL += amount
... return TOTAL
...
>>> addToTotal(10)
10
>>> addToTotal(10)
20
>>> addToTotal(9999)
10019
>>> TOTAL
10019

addToTotal()函数有一个副作用,它修改了存在于函数之外的元素,即TOTAL这个全局变量。副作用不仅仅指对全局变量的修改,还包括更新或删除文件、在屏幕上显示文本、打开数据库连接、服务器鉴权或者对函数本身以外做的任何修改。函数调用在返回后留下的任何痕迹都是副作用。

  副作用也可以包括对函数外使用的可变对象进行的原地改变。比如,下面的removeLastCatFromList()函数原地修改了列表参数:

1
2
3
4
5
6
7
8
>>> def removeLastCatFromList(petSpecies):
... if len(petSpecies) > 0 and petSpecies[-1] == 'cat':
... petSpecies.pop()
...
>>> myPets = ['dog', 'cat', 'bird', 'cat']
>>> removeLastCatFromList(myPets)
>>> myPets
['dog', 'cat', 'bird']

在这个例子中,myPets变量和petSpecies参数持有对同一个列表的引用。在函数中对列表对象所做的任何原地修改也会存在于函数外,所以这种修改会产生副作用。

  一个相关概念是确定性函数,指在给定相同参数的情况下总是返回相同值的函数。比如subtract(123, 987)函数调用总是返回-864,Python内置的round()函数在传递3.14作为参数时总是返回3

  非确定性函数则在传递相同参数时不会总是返回相同的值。例如,调用random.randint(1, 10)会返回一个1和10之间的随机整数。time.time()函数虽然没有参数,但它的返回值取决于调用函数时所在计算机的时钟设置。时钟是一种外部资源,跟参数一样都属于函数的输入。依赖于函数外部资源(包括全局变量、硬盘上的文件、数据库和互联网连接)的函数,都被认为是非确定性函数。

  确定性函数的一个好处是它们的值可以被缓存。如果subtract()能够记住第一次调用时的返回值,那就没必要重复计算123和987的差值。因此,确定性函数允许我们牺牲空间换取时间,即通过使用内存空间缓存之前的结果来缩短函数运行时间。

  无副作用的确定性函数被称为纯函数。函数式程序员尽量在程序中只编写纯函数。除了上文提到的,纯函数还有以下好处:

  • 适合单元测试,因为不需要设置任何外部资源;
  • 通过相同参数调用纯函数,很容易复现纯函数中的错误;
  • 纯函数内调用其他纯函数,仍然保持为纯函数;
  • 在多线程程序中,纯函数式线程是安全的,可以安全地同时运行(多线程不在本书讨论范畴内);
  • 对纯函数的多次调用可以同时在并行的CPU核或者在多线程程序上运行,因为它们不依赖于对其运行顺序有要求的外部资源。

  你可以在Python中编写纯函数,而且应该尽量这样做。在Python中编写纯函数仅是一个习惯做法,没有任何设置让Python解释器强制要求程序员编写纯函数。编写纯函数的最常见的方法是避免在函数内部使用全局变量,并确保不与文件、互联网、系统时钟、随机数或其他外部资源交互。

10.4.2 高阶函数

  高阶函数可以接受函数作为参数或者使用参数作为返回值。例如,定义一个名为callItTwice()的函数,它将两次调用给定的函数:

1
2
3
4
5
6
7
>>> def callItTwice(func, *args, **kwargs):
... func(*args, **kwargs)
... func(*args, **kwargs)
...
>>> callItTwice(print, 'Hello, world!')
Hello, world!
Hello, world!

callItTwice()可以接受任何传递进来的函数。在Python中,函数是头等对象,这意味着它具备其他任何对象都有的功能:可以把函数存储在变量中,作为参数传递,或者把它作为返回值使用。

10.4.3 lambda函数

  lambda函数也被称为匿名函数或者无名函数,是没有名字的简化版函数,其代码仅包含一条返回语句。在将函数作为参数传递给其他函数时,我们经常会用到lambda函数。

  比如,我们可以创建一个常规函数,它接受由矩形的宽高组成的列表,具体来说,矩形规格为4乘10:

1
2
3
4
5
6
>>> def rectanglePerimeter(rect):
... return (rect[0] * 2) + (rect[1] * 2)
...
>>> myRectangle = [4, 10]
>>> rectanglePerimeter(myRectangle)
28

等效的lambda函数是这样的:

1
lambda rect: (rect[0] * 2) + (rect[1] * 2)

  在Python中定义lambda函数,需要以lambda关键字开头,后面是一个以逗号分隔的参数列表(如果有参数的话),紧接着是一个冒号,最后是一个作为返回值的表达式。由于函数是头等对象,因此你可以把lambda函数赋值给一个变量,相当于def语句的快捷副本:

1
2
3
>>> rectanglePerimeter = lambda rect: (rect[0] * 2) + (rect[1] * 2)
>>> rectanglePerimeter([4, 10])
28

这个lambda函数被赋值给了名为rectanglePerimeter的变量,本质上是提供了一个rectanglePerimeter()函数。如你所见,由lambda创建的函数和由def语句创建的函数是一样的。

注意 在实际的代码中,应该使用def语句,而非将lambda函数赋值给常量。lambda函数的正确用法仅是用来创建匿名函数。

  lambda函数的语法便于将小函数指定为其他函数调用的参数。比如,sorted()函数有一个名为key的关键字参数,需要指定一个函数作为实参。当传入key时,sorted()函数将会根据函数的返回值而非项本身的值对列表中的项进行排序。在下面这个示例中,我们传递给sorted()函数一个lambda函数,函数的功能是返回给定矩形的周长。这样sorted()函数将基于每个列表项[width, height]所计算得到的周长进行排序:

1
2
3
>>> rects = [[10, 2], [3, 6], [2, 4], [3, 9], [10, 7], [9, 9]]
>>> sorted(rects, key=lambda rect: (rect[0] * 2) + (rect[1] * 2))
[[2, 4], [3, 6], [10, 2], [3, 9], [10, 7], [9, 9]]

在这个示例中,该函数不是对数值[10, 2][3, 6]进行排序,而是根据返回的周长整数值24和18进行排序。lambda表达式是一种便捷的语法缩写形式:你可以指定一个小的lambda函数(只有一行长),而非使用def语句定义一个命名函数。

10.4.4 在列表推导式中进行映射和过滤

  map()函数和filter()函数是Python早期版本中常见的高阶函数,二者可以转换和过滤列表,通常会结合lambda函数使用。映射(map)可以根据原列表的值创建新列表,过滤(filter)则可以创建一个只包含原列表中符合某种标准的值的新列表。

  如果你想将整数列表[8, 16,18,19,12,1,6,7]转换为字符串类型的新列表,那么可以把这个列表和lambda n: str(n)传递给map()函数:

1
2
3
>>> mapObj = map(lambda n: str(n), [8, 16, 18, 19, 12, 1, 6, 7])
>>> list(mapObj)
['8', '16', '18', '19', '12', '1', '6', '7']

map()函数返回一个map对象,将其传递给list()函数即可得到列表。映射后的列表包含与原列表中整数值相对应的字符串值。filter()函数与之类似,但其中作为参数的lambda函数的作用是决定列表中的哪些项会被保留(当lambda函数返回True时),哪些会被过滤(当lambda函数返回False时)。例如,我们可以通过lambda n: n % 2 == 0过滤掉数组中的奇数:

1
2
3
>>> filterObj = filter(lambda n: n % 2 == 0, [8, 16, 18, 19, 12, 1, 6, 7])
>>> list(filterObj)
[8, 16, 18, 12, 6]

filter()函数返回一个过滤器对象,将其传递给list()函数,即可得到仅包含偶数的列表。

  但使用map()函数和filter()函数来创建映射列表和过滤列表已经是Python的过时做法了。更好的方法是使用列表推导式创建。列表推导式不仅不必编写lambda函数,还比map()filter()更快。这里给出一个等效于map()函数的列表推导式示例:

1
2
>>> [str(n) for n in [8, 16, 18, 19, 12, 1, 6, 7]]
['8', '16', '18', '19', '12', '1', '6', '7']

注意,列表推导式的str(n)部分与lambda n: str(n)相似。

  在此,我们给出一个等效于filter()函数的列表推导式示例:

1
2
>>> [n for n in [8, 16, 18, 19, 12, 1, 6, 7]  if n % 2 == 0]
[8, 16, 18, 12, 6]

注意,列表推导式的if n % 2 == 0部分与lambda n: n % 2 == 0相似。

  许多语言有“函数是头等对象”的概念,也有诸如映射函数和过滤函数的高阶函数。

10.5 返回值的数据类型应该不变

  Python是动态数据类型语言,这意味着Python中的函数和方法可以自由地返回任何数据类型的值。但为了让函数具备更好的可预测性,应该尽量仅返回单一数据类型的值。

  比如这里有一个函数,它随机地返回整数值或者字符串值:

1
2
3
4
5
6
>>> import random
>>> def returnsTwoTypes():
... if random.randint(1, 2) == 1:
... return 42
... else:
... return 'forty two'

  在编写该函数的调用代码时,很可能忘记需要处理多种可能的数据类型。在下面这个例子中,假设我们调用returnsTwoTypes()并希望把它返回的数字转换为十六进制数:

1
2
3
>>> hexNum = hex(returnsTwoTypes())
>>> hexNum
'0x2a'

Python的内置函数hex()接受一个整数,返回的是该整数对应的十六进制数的字符串。当returnsTwoTypes()返回整数时,这段代码能够正常执行,没什么问题。但当returnsTwoTypes()返回字符串时,就会抛出异常:

1
2
3
4
>>> hexNum = hex(returnsTwoTypes())
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'str' object cannot be interpreted as an integer

  当然,我们应该始终记得处理任何一种数据类型的返回值,但在现实中经常会忘记。为了避免此类错误,应该尽量使函数的返回值只有一种数据类型。这不是一个死规定,在不得已的情况下,函数可以返回不同数据类型的值。但返回的数据类型越少,函数就越简单,也越不易出错。

  有一种情况要特别注意——除非函数的返回值总是None,否则不要在某些情况下返回NoneNone值是NoneType数据类型的唯一值。人们很容易通过返回None来说明函数发生了错误(10.6节将讨论返回错误码),但正确的做法是尽量只在函数无法返回有意义的值时才返回None

  通过返回None表示错误通常是造成不易捕获的'NoneType' object has no attribute这一异常的根源:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
>>> import random
>>> def sometimesReturnsNone():
... if random.randint(1, 2) == 1:
... return 'Hello!'
... else:
... return None
...
>>> returnVal = sometimesReturnsNone()
>>> returnVal.upper()
'HELLO!'
>>> returnVal = sometimesReturnsNone()
>>> returnVal.upper()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'NoneType' object has no attribute 'upper'

这段错误信息非常模糊,需要花些精力才能定位到通常会返回预期结果的函数上,而问题出在这个函数在发生错误时返回了None。问题发生的原因是sometimesReturnsNone()返回None,然后我们将其赋值给了returnVal变量。但是,错误信息会让你误以为问题发生在对upper()方法的调用中。

  在2009年的一次会议上,计算机科学家Tony Hoare为他在1965年发明了null引用(跟Python中的None值类似)而道歉。他说:“我把null引用称为自己的十亿美元错误……我没能抵制诱惑,加入了null引用,仅仅是因为它实现起来非常容易。但是,它导致了无数的错误、漏洞和系统崩溃,可能在之后的40年中造成了十亿美元的损失。”

10.6 抛出异常和返回错误码

  在Python中,“异常”和“错误”这两个词的含义相差无几,都是指程序中的异常情况,表明程序存在问题。在20世纪八九十年代,随着C++和Java的出现,“异常”成为流行的编程语言特性,它们取代了错误码。错误码是从函数中返回的值,说明代码有问题。使用“异常”一词的好处是,函数返回值只与函数的目的有关,而不必同时用来表明存在错误。

  错误码有时也会导致程序问题。比如,Python的find()通常会返回子串的索引,在找不到时则返回-1作为错误码。但-1也可以用来表示字符串末尾的索引,无意中使用-1作为错误码可能会引发错误。在交互式shell中输入以下内容:

1
2
3
4
>>> print('Letters after b in "Albert":', 'Albert'['Albert'.find('b') + 1:])
Letters after b in "Albert": ert
>>> print('Letters after x in "Albert":', 'Albert'['Albert'.find('x') + 1:])
Letters after x in "Albert": Albert

代码中的Albert'.find('x')被计算为错误码-1,这导致表达式'Albert'['Albert'.find('x') + 1:]被推导为'Albert'[-1 + 1: ],接着推导为Albert'[0:],最终等于Albert。很显然,这并不符合预期。调用index()而非find(),就像'Albert'['Albert'.index('x') + 1:]这样,会导致异常,使不可忽略的问题暴露出来。

  字符串的index()方法在找不到子串时会抛出ValueError异常。如果不处理这个异常,程序就会崩溃,所以最好不要忽略错误。

  当异常表示一个实际错误时,异常类的名称往往以Error结尾,比如ValueErrorNameErrorSyntaxError。表示在特殊情况下不一定是错误的异常类有StopIterationKeyboardInterruptSystemExit

十一 注释、文档字符串和类型提示

代码的注释与文档的重要性不亚于代码本身,因为软件永远不会彻底完成,你总是需要修改,要么添加新功能,要么修复错误。如果你对代码不够了解,就无法进行修改,所以代码的可读性很重要。正如计算机科学家Harold Abelson、Gerald Jay Sussman和Julie Sussman曾写的那样:“代码是用来让人读的,只是顺便让机器执行而已。”

  注释文档字符串类型提示****1有助于维护代码的可读性。注释是写在代码中、会被计算机忽略的简短解释。注释的作用是为除编写者之外的人提供有价值的说明、警告和提醒,有时候甚至会对写这段代码的人起到同样的作用。几乎每个程序员都曾暗自吐槽:“到底是谁写的这堆乱七八糟的东西?”结果发现答案竟然是“自己”。

11.1 注释

和大多数编程语言一样,Python支持单行注释和多行注释。以#开始,直到行尾的所有文本都是单行注释。尽管Python没有专门的多行注释语法,但使用三引号的多行字符串可以用于多行注释。毕竟,一个字符串值不会导致Python解释器做任何事情。

11.1.1 注释风格

来看看一些遵循了优秀注释风格的实践:

1
2
3
4
5
6
7
8
9
10
11
# 这是对下面一行代码的注释: ❶
someCode()

# 这是一个更长的块注释,它分散在多行,并使用 ❷
# 多个单行注释
# ❸
# 它们被称为块注释

if someCondition:
# 这是关于其他代码的注释: ❹
someOtherCode() # 这是一个单行注释 ❺

  注释通常应该独立成行,而不是放在代码行的末尾。多数情况下,它们应该是大小写正确且带有标点符号的完整句子,而非短语或者单词❶,除非受限于代码行长的限制。多行的注释❷可以连续使用多个单行注释,也叫作块注释。空白单行注释可以用于划分块注释中的段落❸。注释的缩进水平应该跟被注释的代码一致❹。跟在代码行内的注释被称为“内联注释”❺,这种情况下,代码和注释之间应该至少保留两个空格。

11.1.2 内联注释

内联注释的一个常见且适宜的用途是解释变量的作用,或为其提供其他背景信息。这些内联注释写在创建变量的赋值语句后:

1
2
3
month = 2 # 月份的取值范围从0(1月)到11(12月)
catWeight = 4.9 # 重量的单位是千克
website = 'ituring.cn' # 字符串不要以"https://"开头

  除非是通过类型提示的形式,否则内联注释不应该指定变量的数据类型,因为显然可以从赋值语句中得知这一点,11.3.4节也将有相关描述。

11.1.3 说明性的注释

一般来说,注释应该解释为什么代码要这样写,而不是解释代码做了什么或怎么做的。即使满足了良好的代码风格和第3、4章提及的有用的命名约束,代码也不能很好地解释最初编写者的意图。即使是你自己写的代码,几周后也可能忘记其中的细节。你应该写翔实的代码注释,而不是让以后的你“骂”过去的自己。

  比如,这里有一个没意义的注释,它解释了代码做了什么(这是显而易见的),但并没有说明代码的动机:

1
>>> currentWeekWages *= 1.5 # 将currentWeekWages乘以1.5

  这条注释还不如没有。从代码中就能看出变量currentWeekWages被乘以1.5,直接删除这行注释会让代码更简洁。下面的注释要好得多:

1
>>> currentWeekWages *= 1.5 # 是工资的1.5倍

  这行注释解释了代码背后的意图,而不是重述代码要做什么。无论代码写得多好,也无法提供这样的背景信息。

11.1.4 总结性的注释

  注释不仅仅可以用于说明程序员的意图,使用简短的注释总结多行代码还可以使阅读者不看代码就能对它的作用有大概的认识。程序员经常用空行划分代码的“段落”,而总结性的注释通常在这些段落的起始行。不同于解释单行代码的单行注释,总结性注释在更高的抽象层次上起到解释代码的作用。

  比如,通过阅读以下4行代码,可以得知它们将playerTurn变量设置为代表对面玩家的值。简洁的注释可以使读者不必阅读和推敲代码就能理解代码的目的:

1
2
3
4
5
# 轮到对手:
if playerTurn == PLAYER_X:
playerTurn = PLAYER_O
elif playerTurn == PLAYER_O:
playerTurn = PLAYER_X

  在代码中放置这些总结性注释可以增强代码的可读性。程序员可以借由它们跳到感兴趣的地方做深入了解。总结性注释也可以防止程序员对代码的作用产生误解。一个简洁的总结性注释可以确保开发人员正确理解代码的工作原理。

11.1.5 “经验之谈”的注释

  之前在软件公司工作时,我有一次被要求适配一个图形库,使其支持包含数百万数据点的图表实时更新。我们当时使用的库可以实时更新图表,也可以支持有数百万数据点的图表,但不能同时做到两者。一开始,我以为用几天时间就能完成这项任务。但是到了第3周,我仍然感觉还要过几天才能完成。每天,我都觉得解决方案已经近在眼前了,但直到第5周,我才弄出了一个能用得上的原型。

  在整个过程中,我了解了大量的图形库工作原理、其能力及限制。我花了几小时把这些细节写成了一整页的注释,并把它放在了代码中。我知道任何一个对代码进行后续修改的人都会像我一样,遇到看似简单实际上棘手的问题,而我写的这份文档将节省他们数周的工作量。

  我将此类注释称为“经验之谈”的注释,它们可能长达几段,以至于在代码文件中看起来很突兀。但它们包含的信息对于任何需要维护这些代码的人而言都是宝藏。不要害怕在代码文件中写大段的用于解释某些工作原理的详细注释。对于程序员而言,很多细节是未知的,可能被误解或者被忽略。如果开发人员不需要这些注释,那跳过它们就好,而需要它们的开发人员会谢天谢地。请记住,“经验之谈”的注释跟上面两类注释不一样,它不同于模块或者函数文档(这是文档字符串要做的),它也不是针对软件用户的教程或操作指南,而是提供给开发人员的。

  我的“经验之谈”注释与开源图形库有关,可能会对其他人有帮助,所以我花了些时间将其整理为一条答案发布到公共问答网站上了,以便有类似问题的人可以找到。

11.1.8 代码标签和TODO注释

  程序员有时候会留下简短的注释,提醒自己还有哪些工作要做,通常是以代码标签的形式:以全大写字母标签开头,后面是简短描述的注释。理想情况下,你会使用项目管理工具来追踪这类问题,而不只是写在代码中。但对于没有使用这些工具的小型个人项目而言,少量的TODO注释可以起到提醒的作用。请看下面这个示例:

1
_chargeIonFluxStream() # TODO:排查为什么每周二都会失败

  可以使用以下标签以起到不同类型的提醒作用。

  TODO:提示需要完成的工作。

  FIXME:提示这部分代码还不能正常工作。

  HACK:提示这部分代码可以工作,但可能有些勉强,需要做出改进。

  XXX:通常用于提示高度严重的问题。

  你应该在这些总是大写的标签后加上对手头任务或问题更加具体的描述。稍后,可以在源代码中搜索这些标签,找到需要修正的代码。缺点是它们很容易被遗忘,除非你正好在阅读它们所处的代码段落。代码标签不应该取代正式的问题跟踪工具或者错误报告工具。如果你确实想在代码中使用代码标签,我建议把这个工作处理得简单一些:只使用TODO,放弃别的标签。

11.2 文档字符串

  文档字符串是出现在模块的.py源代码文件顶部或者在类或def语句之后的多行注释。它们提供关于被定义的模块、类、函数或方法的文档。自动文档生成工具可以使用这些文档字符串生成外部文档,比如帮助文档或网页。

 文档字符串必须使用三引号的多行注释,不能使用以#开头的单行注释。文档字符串应该始终使用3个双引号,而非3个单引号进行包裹。例如,这里是流行的requests模块中session.py文件的一部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# -*- coding: utf-8 -*- ❶

""" ❷
requests.session
~~~~~~~~~~~~~~~~

This module provides a Session object to manage and persist settings across requests (cookies, auth,
proxies).
"""
import os
import sys
--snip—
class Session(SessionRedirectMixin):
"""A Requests session. ❸

Provides cookie persistence, connection-pooling, and configuration.

Basic Usage::

>>> import requests
>>> s = requests.Session()
>>> s.get('https://httpbin.org/get')
<Response [200]>
--snip--

def get(self, url, **kwargs):
r"""Sends a GET request. Returns :class:`Response` object. ❹

:param url: URL for the new :class:`Request` object.
:param \*\*kwargs: Optional arguments that request takes.
:rtype: requests.Response
"""
--snip--

  session.py文件包括该模块本身的文档字符串❷、Session类❸及其get()方法❹的文档字符串。注意,尽管模块的文档字符串应该是模块中出现的第一个字符串,但还是应该跟在神奇的注释之后,比如shebang行或者编码定义行❶。

一般而言,文档字符串应该包含一个用于概述模块、类或者函数的行,后面有一个空行,空行后再提供更详细的信息。对于函数和方法,可以包含关于参数、返回值、副作用的信息。我们编写的文档说明不是给软件的使用者看的,而是给程序员看的。因此,它们应该包含技术信息,而非使用教程。

  文档字符串的另一个重要优点是它们将文档集成到了源代码中。分开编写文档和代码时,很容易直接忘记编写文档这回事。但由于文档字符串被放置在模块、类和函数的顶部,因此这些信息很容易被注意到,也方便更新。

  当代码还未写完时,你不一定能写出用来描述它的文档字符串。这种情况下,可以在文档字符串中加入一个TODO注释,提醒之后填补剩余的细节。比如,下面这个虚构的reverseCatPolarity()函数有一个不太好的文档字符串,它呈现了显而易见的事:

1
2
3
4
5
def reverseCatPolarity(catId, catQuantumPhase, catVoltage):
"""Reverses the polarity of a cat.

TODO Finish this docstring."""
--snip--

  由于每个类、函数和方法都要求有文档字符串,因此你可能想尽可能少写文档,先推进整体工作进度。如果没有TODO注释,很容易忘记需要后期重写这个文档字符串。

  PEP 257包含了更多关于文档字符串的说明,可以在Python官网上查阅。

11.3 类型提示

  许多编程语言是静态类型,也就是说,程序员必须在代码中声明所有变量、参数、返回值的数据类型。它的作用是允许解释器或编译器在程序运行前检查代码是否正确使用了所有对象。Python是动态类型:变量、参数和返回值可以是任何数据类型,甚至可以在程序运行时改变数据类型。动态语言通常更容易编程,因为它们不需要遵循很多限制,但动态语言缺乏静态语言所具有的避免运行时错误的优势。比如,写了一行Python代码round('42'),你可能没注意到把字符串传递给了只接受int参数或float参数的函数,直到运行代码出错时才意识到这一点。当你赋了错误类型的值,或传递了错误类型的参数时,静态类型的语言会在运行前发出警告。

  Python通过类型提示提供了可选的静态类型支持。在下面的示例中,类型提示用粗体标注:

1
2
3
4
5
6
7
8
9
10
def describeNumber(number: int) -> str:
if number % 2 == 1:
return 'An odd number. '
elif number == 42:
return 'The answer. '
else:
return 'Yes, that is a number. '

myLuckyNumber: int = 42
print(describeNumber(myLuckyNumber))

  正如你看到的,类型提示使用冒号来分隔参数和变量的名称与类型。对于返回值,类型提示使用箭头(->)分隔def语句的闭合括号和类型。describeNumber()的类型提示显示它的number参数需要整数值,返回值是字符串。

  不必为程序中的每一条数据都加上类型提示。可以采用渐进式类型化方法,只对某些变量、参数和返回值设置类型提示,这是动态类型的灵活性和静态类型的安全性之间的一个折中。但程序中的类型提示越多,静态代码分析工具就能有更多的信息发现程序中的潜在错误。

  注意在前面的例子中,指定类型的名称与int()str()构造函数的名称一致。在Python中,类、类型和数据类型的含义相同。对于任何由类构成的实例,都应该使用类的名称作为类型:

1
2
3
4
5
6
7
8
9
import datetime
noon: datetime.time = datetime.time(12, 0, 0) ❶

class CatTail:
def __init__(self, length: int, color: str) -> None:
self.length = length
self.color = color

zophieTail: CatTail = CatTail(29, 'grey') ❷

  noon变量的类型提示是datetime.time❶,因为它是一个时间对象(在datetime模块中定义)。同样,zophieTail对象的类型提示是CatTail❷,因为它是我们用类语句创建的CatTail类的一个对象。类型提示适用于指定类型的所有子类。例如,一个具有类型提示dict的变量可以被设置为任何字典类型的值,也可以被设置为collections.OrderedDict类型或者collections.defaultdict类型的值,因为这些类是dict的子类。

11.3.2 为多种类型设置类型提示

  Python的变量、参数和返回值可以有多种可能的数据类型。对于这种情况,可以从内置的typing模块导入Union以指定多类型的类型提示。在Union类名称后面的中括号内指定类型范围:

1
2
3
4
from typing import Union
spam: Union[int, str, float] = 42
spam = 'hello'
spam = 3.14

  在这个示例中,类型提示Union[int, str, float]指定spam可以被设置为整数、字符串或浮点数。注意,最好使用from typing import *X*的形式,而非import typing的形式,这样在进行类型提示时就不必到处写成冗长的typing.*X*

  在指定变量或返回值有多种数据类型时,如果想在除普通类型之外还包括NoneType,也就是None值的类型,需要在中括号内添加None,而非NoneType。(从技术上讲,NoneTypeintstr不同,它不是内置标识符。)

  更好的方式是从typing模块中引入Optional,使用Optional[str]的写法替代Union[str, None]。这种类型提示意味着函数或方法除了返回预期类型的值,还可以返回None。这里有一个示例:

typing模块对于每种容器类型都有单独的类型别名。以下列出了Python中常见的容器类型的类型别名:

  • List指列表(list)数据类型;
  • Tuple指元组(tuple)数据类型;
  • Dict指字典(dict)数据类型;
  • Set指集合(set)数据类型;
  • FrozenSet指不可变集合(frozenset)数据类型;
  • Sequence指列表、元组或任何其他序列数据类型;
  • Mapping指字典、集合、不可变集合或者任何其他映射数据类型;
  • ByteStringbytesbytearraymemoryview类型。

  你可以在Python官网上找到这些类型的完整列表。


【python】读书笔记之编写高效函数(十)
http://example.com/2024/01/10/618python读书笔记之编写高效函数(十)/
作者
Wangxiaowang
发布于
2024年1月10日
许可协议