在这一章中,我们将完成面向过程的编程范式的学习。通过第2章中的选择和循环,我们已经开始用结构化的方法来封装程序了。在这一章中,我们将看到其他面向过程的封装方法,即函数和模块。函数和模块把成块的指令封装成可以重复调用的代码块,并借着函数名和模块名整理出一套接口,方便未来调用。

3.1 懒人炒菜机

1.函数是什么

函数(Function)这个名字会让人想起中学数学,所以会带来轻微的痛苦。在数学上,函数代表了集合之间的对应关系。譬如说,所有的汽车是一个集合,所有的方向盘也是一个集合。汽车集合和方向盘集合之间存在着对应关系,可以表达为一个函数。

我们再举一个数学上的例子。下面的平方函数,将一个自然数对应为这个自然数的平方:

广告:个人专属 VPN,独立 IP,无限流量,多机房切换,还可以屏蔽广告和恶意软件,每月最低仅 5 美元

fx)=x2x是一个自然数

换句话说,函数fx)定义了两组数字之间的对应关系:


x -> y
1     1
2     4
3     9
4     16
…

数学上的函数定义了静态的对应关系。从数据的角度来说,函数像是“大变活人”的魔法盒子,这个魔法盒子能把走进去的小猪变成小兔子(如图3-1所示)。对于刚才定义的函数fx),进去的是一个自然数,出来的是这个自然数的平方。借着函数,我们实现了数据转换。

阅读 ‧ 电子书库

图3-1 魔法盒子

函数的魔法转换并非凭空生成。对于编程中的函数,我们可以用一系列指令来说明函数是如何工作的。编程中的函数在实现数据转换的同时,还能借着指令,实现其他功能。所以,程序员还可以从程序封装的角度来理解函数。

对于程序员来说,函数是这样一种语法结构。它把一些指令封装在一起,形成一个组合拳。一旦定义好了函数,我们就可以通过对函数的调用,来启动这套组合拳。因此,函数是对封装理念的实践。输入数据被称为参数,参数能影响函数的行为。这就好比同样的组合拳可以有不同的力量级别。

这样,我们就有了三种看待函数的方式:集合的对应关系、数据的魔法盒子、语句的封装。编程教材一般会选择其一来说明函数是什么。这三种解释方式都正确,区别只是看待问题的角度。相互参照三种互通的解释方式,可以更充分地理解函数是什么。

2.定义函数

我们首先制作一个函数。制作函数的过程又称为定义函数(define function)。我们称这个函数为square_sum()。人如其名,这个函数的功能是计算两个数的平方和:


def square_sum(a,b):
    a = a**2
    b = b**2
    c = a + b
    return c

最先出现的是def这个关键字。这个关键字通知Python“这里要定义函数了”。关键字def后面跟着square_sum,即函数的名字。在函数名后面,还有一个括号,用来说明函数有哪些参数,即括号中的a和b。参数可以有多个,也可以完全没有。根据Python的语法规定,即使没有输入数据,函数后面的括号也要保留。

在定义函数时,我们用了a和b两个符号来指代输入数据。等到真正使用函数时,我们才会说明a和b具体是什么样的数字。所以,定义函数就像是练武术架式,真正调用函数时才借着真实的输入数据决定出手力度。参数在函数定义的内部起到了和变量类似的功能,可以用符号化的形式参与到任何一行指令中。由于函数定义中的参数是一个形式代表,并非真正数据,所以又称为形参(Parameter)。

在定义函数square_sum()时,我们用参数a和b完成了符号化的平方求和。而在函数的具体执行中,参数所代表的数据确实是作为一个变量存在的,我们将在后面详述这一点。

括号结束时,就来到了第一行的末尾。末尾有一个冒号,后面的四行都有缩进。联系在第2章中的学习,我们可以推测出这里的冒号和缩进表示了代码的隶属关系。因此,后面的四行有缩进的代码都是函数square_sum()的小弟。函数是对代码的封装。当函数被调用时,Python将执行从属于函数的语句,直到从属语句结束。对于square_sum()来说,它的前三行都是我们已经熟悉了的运算语句。最后一句是return。关键字return用于说明函数的返回值,即函数的输出数据。

作为函数的最后一句,函数执行到return时就会结束,不管它后面是否还有其他函数定义语句。如果把square_sum()改为下面的形式:


def square_sum(a,b):
    a = a**2
    b = b**2
    c = a + b
    return c
    print("am I alive?")

则函数执行时,只会执行到return c。后面一句print()虽然也归属于函数,却不会被执行。所以,return还起到了中止函数和制定返回值的功能。在Python的语法中,return并不是必需的。如果没有return, 或者return后面没有返回值时,则函数将返回None。None是Python中的空数据,用来表示什么都没有。关键字return也返回多个值。多个值跟在return后面,以逗号分隔。从效果上看,其等价于返回一个有多个数据的元组。


return a,b,c          # 相当于 return (a,b,c)

3.调用函数

上面我们看到怎样定义函数。定义函数就像打造了一把利器,但这件兵器必须使用起来,才能真正发挥作用。使用函数的过程叫作调用函数(Call Function)。在第1章中,我们已经见过如何调用print()函数:


print("Hello World!")

我们直接使用了函数名,在括号里加入具体的参数。此时的参数不再是定义函数时的符号,而是一个实际的数据—字符串"Hello World!"。所以,在函数调用时出现的参数称为实参(argument)。

函数print()返回值为None,所以我们并不关心这个返回值。但如果一个函数有其他返回值,那么我们可以获得这个返回值。一个常见的做法是把返回值赋予给变量,方便以后使用。下面程序中调用了square_sum()函数:


x =square_sum(3,4)
print(x)      # 结果为25

Python通过参数出现的先后位置,知道3对应的是函数定义中的第一个形参a, 4对应第二个形参b,然后把参数传递给函数square_sum()。函数square_sum()执行内部的语句,直到得出返回值25。返回值25赋予给了变量x,最后由print()打印出来。

函数调用的写法,其实与函数定义第一行def后面的内容相仿。只不过在调用函数时,我们把真实的数据填入到括号中,作为参数传递给函数。除具体的数据表达式外,参数还可以是程序中已经存在的变量,比如:


a = 5
b = 6
x = square_sum(a, b)
print(x)      # 结果为61

4.函数文档

函数可以封装代码,实现代码的复用。对于一些频繁调用的程序,如果能写成函数,再每次调用其功能,那么将减少重复编程的工作量。然而,函数多了也会有函数多的烦恼。一个问题常见就是,我们经常会忘记一个函数是用来做什么的。当然,我们可以找到定义函数的那些代码,一行一行地读下去,尝试了解自己或别人在编写这段程序时的意图。但这个过程听起来就让人痛苦。要想让未来的自己或他人避免类似的痛苦,就需要在写函数时加上清晰的说明文档,说明函数的功能和用法分别是什么。

我们可以用内置函数help()来找到某个函数的说明文档。以函数max()为例,用这个函数用来返回最大值。比如:


x = max(1, 4, 15, 8)
print(x)       # 结果为15

函数max()接收多个参数,再返回参数中最大的那一个。如果一时想不起来函数max()的功能和所带的参数,那么我们可以通过help()来求助。


>>> help(max)   # 以下为help()运行的结果,也就是max()的说明文档。
    
Help on built-in function max in module __builtin__:
    
max(...)
max(iterable[, key=func]) -> value
max(a, b, c, ...[, key=func]) -> value
    
    With a single iterable argument, return its largest item.
    With two or more arguments, return the largest argument.
(END)

可以看到,函数max()有两种调用方式。我们之前的调用是按照第二种方式。此外,说明文档还说明了函数max()的基本功能。

函数max()属于Python自身定义好的内置函数,所以已经提前准备好了说明文档。对于我们自定义的函数,还需要自己动手。这个过程并不复杂,下面给函数square_sum()加上简单的注释:


def square_sum(a,b):
    """return the square sum of two arguments"""
    a = a**2
    b = b**2
    c = a + b
    return c

在函数内容一开始的时候,增加了一个多行注释。这个多行注释同样有缩进。它将成为该函数的说明文档。如果我用函数help()来查看square_sum()的说明文档,则help()将返回我们定义函数时写下的内容:


>>>help(square_sum)
    
Help on function square_sum in module __main__:
    
square_sum(a, b)
   return the square sum of two arguments

通常来说,说明文档要写得尽可能详细一些,特别是人们关心的参数和返回值。

3.2 参数传递

1.基本传参

把数据用参数的形式输入到函数,被称为参数传递。如果只有一个参数,那么参数传递会变得很简单,只需把函数调用时输入的唯一一个数据对应为这个参数就可以了。如果有多个参数,那么在调用函数时,Python会根据位置来确认数据对应哪个参数,例如:


def print_arguments(a, b, c):
    """print arguments according to their sequence"""
    print(a, b, c)
    
print_arguments(1, 3, 5)  # 打印1、3、5
print_arguments(5, 3, 1)  # 打印5、3、1
print_arguments(3, 5, 1)  # 打印3、5、1

在程序的三次调用中,Python都是通过位置来确定实参与形参的对应关系的。

如果觉得位置传参比较死板,那么可以用关键字(Keyword)的方式来传递参数。在定义函数时,我们给了形参一个符号标记,即参数名。关键字传递是根据参数名来让数据与符号对应上。因此,如果在调用时使用关键字传递,那么不用遵守位置的对应关系。沿用上面的函数定义,改用参数传递的方式:


print_arguments(c=5,b=3,a=1)   # 打印1、3、5

从结果可以看出,Python不再使用位置来对应参数,而是利用了参数的名字来对应参数和数据。

位置传递与关键字传递可以混合使用,即一部分的参数传递根据位置,另一部分根据参数名。在调用函数时,所有的位置参数都要出现在关键字参数之前。因此,你可以用如下方式来调用:


print_arguments(1, c=5,b=3) # 打印1、3、5

但如果把位置参数1放在关键字参数c=5的后面,则Python将报错:


print_arguemnts(c=5, 1, b=3) # 程序报错

位置传递和关键字传递让数据与形参对应起来,因此数据的个数与形参的个数应该相同。但在函数定义时,我们可以设置某些形参的默认值。如果我们在调用时不提供这些形参的具体数据,那么它们将采用定义时的默认值,比如:


def f(a,b,c=10):
    return a+b+c
    
print(f(3,2,1))  # 参数c取传入的1。结果打印6
print(f(3,2))    # 参数c取默认值10。结果打印15

第一次调用函数时输入了3个数据,正好对应三个形参,因此形参c对应的数据是1。第二次调用函数时,我们只提供了3和2两个数据。函数根据位置,把3和2对应成形参a和b。到了形参c时,已经没有多余的数据,所以c将采用其默认值10。

2.包裹传参

以上传递参数的方式,都要求在定义函数时说明参数的个数。但有时在定义函数时,我们并不知道参数的个数。其原因有很多,有时是确实不知道参数的个数,需要在程序运行时才能知道。有时是希望函数定义的更加松散,以便于函数能运用于不同形式的调用。这时候,用包裹(packing)传参的方式来进行参数传递会非常有用。

和之前一样,包裹传参也有位置和关键字两种形式。下面是包裹位置传参的例子:


def package_position(*all_arguments):
    print(type(all_arguments))
    print(all_arguments)
    
package_position(1,4,6)
package_position(5,6,7,1,2,3)

两次调用,尽管参数个数不同,但都基于同一个package_position()定义。在调用package_position()时,所有的数据都根据先后顺序,收集到一个元组。在函数内部,我们可以通过元组来读取传入的数据。这就是包裹位置传参。为了提醒Python参数all_arguments是包裹位置传递所用的元组名,我们在定义package_position()时要在元组名all_arguments前加*号。

我们再来看看包裹关键字传递的例子。这一参数传递方法把传入的数据收集为一个词典:


def package_keyword(**all_arguments):
    print(type(all_arguments))
    print(all_arguments)
    
package_keyword(a=1,b=9)
package_keyword(m=2,n=1,c=11)

与上面一个例子类似,当函数调用时,所有参数会收集到一个数据容器里。只不过,在包裹关键字传递的时候,数据容器不再是一个元组,而是一个字典。每个关键字形式的参数调用,都会成为字典的一个元素。参数名成为元素的键,而数据成为元素的值。字典all_arguments收集了所有的参数,把数据传递给函数使用。为了提醒,参数all_arguments是包裹关键字传递所用的字典,因此在all_arguments前加**

包裹位置传参和包裹关键字传参还可以混合使用,比如:


def package_mix(*positions, **keywords):
    print(positions)
    print(keywords)
    
package_mix(1, 2, 3, a=7, b=8, c=9)

还可以更进一步,把包裹传参和基本传参混合使用。它们出现的先后顺序是:位置→关键字→包裹位置→包裹关键字。有了包裹传递,我们在定义函数时可以更灵活地表示数据。

3.解包裹

除了用于函数定义,***还可用于函数调用。这时候,两者是为了实现一种叫作解包裹(unpacking)的语法。解包裹允许我们把一个数据容器传递给函数,再自动地分解为各个参数。需要注意的是,包裹传参和解包裹并不是相反操作,而是两个相对独立的功能。下面是解包裹的一个例子:


def unpackage(a,b,c):
    print(a,b,c)
    
args = (1,3,4)
unpackage(*args)    # 结果为1 3 4

在这个例子中,unpackage()使用了基本的传参方法。函数有三个参数,按照位置传递。但在调用该函数时,我们用了解包裹的方式。可以看到,我们调用函数时传递的是一个元组。按照基本传参的方式,一个元组是无法和三个参数对应上的。但我们通过在args前加上*符号,来提醒Python,我想把元组拆成三个元素,每一个元素对应函数的一个位置参数。于是,元组的三个元素分别赋予了三个参数。

相应的,词典也可用于解包裹,使用相同的unpackage()定义:


args = {"a":1,"b":2,"c":3}
unpackage(**args)      # 打印1、2、3

然后在传递词典args时,让词典的每个键值对作为一个关键字传递给函数unpackage()。

解包裹用于函数调用。在调用函数时,几种参数的传递方式也可以混合。依然是相同的基本原则:位置→关键字→位置解包裹→关键字解包裹。

3.3 递归

1.高斯求和与数学归纳法

递归是函数调用其自身的操作。在讲解递归之前,先来回顾数学家高斯的一个小故事。据说有一次,老师惩罚全班同学,必须算出1到100的和才能回家。只有7岁的高斯想出了一个聪明的解决办法,后来这个方法被称为高斯求和公式。下面我们用编程的方法来解决高斯求和:


sum = 0
for i in range(1, 101):       # range()这样的写法表示从1开始,直到100
sum = sum + i
print(sum)     # 结果为5050

正如程序显示的,循环是解决问题的一个自然想法。但这并不是唯一的解决方案,我们还可以用下面的方式解题:


def gaussian_sum(n):
    if n == 1:
        return 1
    else:
        return n + gaussian_sum(n-1)
    
print(gaussian_sum(100))          # 结果为5050

上面的解法使用了递归(Recursion),即在一个函数定义中,调用了这个函数自身。为了保证计算机不陷入死循环,递归要求程序有一个能够达到的终止条件(Base Case)。递归的关键是说明紧邻的两个步骤之间的衔接条件。比如,我们已经知道了1到51的累加和,即gaussian_sum(51),那么1到52的累加和就可以很容易地求得:gaussian_sum(52) = gaussian_sum(51) + 52。

使用递归设计程序的时候,我们从最终结果入手,即要想求得gaussian_sum(100),计算机会把这个计算拆解为求得gaussian_sum(99)的运算,以及gaussian_sum(99)加上100的运算。以此类推,直到拆解为gaussian_sum(1)的运算,就触发终止条件,也就是if结构中n=1时,返回一个具体的数1。尽管整个递归过程很复杂,但在编写程序时,我们只需关注初始条件、终止条件及衔接,而无须关注具体的每一步。计算机会负责具体的执行。

递归源自数学归纳法。数学归纳法(Mathematical Induction)是一种数学证明方法,常用于证明命题(1)在自然数范围内成立。随着现代数学的发展,自然数范围内的证明实际上构成了许多其他领域,如数学分析和数论的基础,所以数学归纳法对于整个数学体系都至关重要。

数学归纳法本身非常简单。如果我们想要证明某个命题对于自然数n成立,那么:

第一步 证明命题对于n = 1成立。

第二步 假设命题对于n成立,n为任意自然数,则证明在此假设下,命题对于n+1成立。

命题得证

想一下上面的两个步骤。它们实际上意味着,命题对于n = 1成立→命题对于n = 2成立→命题对于n = 3成立……直到无穷。因此,命题对于任意自然数都成立。这就好像多米诺骨牌,我们确定n的倒下会导致n + 1的倒下,然后只要推倒第一块骨牌,就能保证任意骨牌的倒下。

2.函数栈

程序中的递归需要用到(Stack)这一数据结构。所谓数据结构,是计算机存储数据的组织方式。栈是数据结构的一种,可以有序地存储数据。

栈最显著的特征是“后进先出”(LIFO,Last In,First Out)。当我们往箱子里存放一叠书时,先存放的书在箱子底部,后存放的书放在箱子顶部。我们必须将后存放的书取出来,才能看到和拿出最开始存放的书。这就是“后进先出”。栈与这个装书的箱子类似,只能“后进先出”。每一本书,也就是栈的每个元素,称为一个(frame)。栈只支持两个操作:pop和 push。栈用弹出(pop)操作来取出栈顶元素,用推入(push)操作将一个新的元素存入栈顶。

正如我们前面所说的,为了计算gaussian_sum(100),我们需要先暂停gaussian_sum(100),开始gaussian_sum(99)的计算。为了计算gaussian_sum(99),需要先暂停gaussian_sum(99),调用gaussian_sum(98)……。在触发终止条件前,会有很多次未完成的函数调用。每次函数调用时,我们在栈中推入一个新的帧,用来保存这次函数调用的相关信息。栈不断增长,直到计算出gaussian_sum(1)后,我们又会恢复计算gaussian_sum(2)、gaussian_sum(3),……。由于栈“后进先出”的特点,所以每次只需弹出栈的帧,就正好是我们所需要的gaussian_sum(2)、gaussian_sum(3)……直到弹出藏在最底层的的帧gaussian_sum(100)。

所以,程序运行的过程,可以看作是一个先增长栈后消灭栈的过程。每次函数调用,都伴随着一个帧入栈。如果函数内部还有函数调用,那么又会多一个帧入栈。当函数返回时,相应的帧会出栈。等到程序的最后,栈清空,程序就完成了。

3.变量的作用域

有了函数栈的铺垫,变量的作用域就变得简单了。函数内部可以创建新变量,如下面的一个函数:


def internal_var(a, b):
    c = a + b
    return c
    
print(internal_var(2, 3))       # 结果为5

事实上,Python寻找变量的范围不止是当前帧。它还会寻找函数外部,也就是Python的主程序(2)中定义了的变量。因此,在一个函数内部,我们能“看到”函数外部已经存在的变量。比如下面的程序:


def inner_var():
    print(m)
    
m = 5
inner_var()     # 结果将打印5

当主程序中已经有了一个变量,函数调用内部可以通过赋值的方式再创建了一个同名变量。函数会优先使用自己函数帧中的那个变量。在下面的程序中,主程序和函数external_var()都有一个info变量。在函数external_var()内部,会优先使用函数内部的那个info:


def external_var():
    info = "Vamei's Python"
    print(info)    # 结果为"Vamei's Python"
    
info= "Hello World!"
external_var()
print(info)        # 结果为"Hello World!"

且函数内部使用的是自己内部的那一份,所以函数内部对info的操作不会影响到外部变量info。

函数的参数与函数内部变量类似。我们可以把参数理解为函数内部的变量。在函数调用时,会把数据赋值给这些变量。等到函数返回时,这些参数相关的变量会被清空。但也有特例,如下面的例子:


b = [1,2,3]
def change_list(b):
    b[0] = b[0] + 1
    return b
    
print(change_list(b))    # 打印[2, 2, 3]
print(b)                 # 打印[2, 2, 3]

我们将一个表传递给函数,函数进行操作后,函数外部的表b发生变化。当参数是一个数据容器时,函数内外部只存在一个数据容器,所以函数内部对该数据容器的操作,会影响到函数外部。这涉及到Python的一个微妙机制,我们会在第7章对此深入探索。现在需要记住的是,对于数据容器来说,函数内部的更改会影响到外部。

3.4 引入那把宝剑

1.引入模块

网上曾经流行一个技术讨论:“如何用编程语言杀死一条龙?”有很多有趣的答案,比如Java语言,是“赶到那里,找到巨龙,开发出一套由多个功能层组成的恶龙歼灭框架,写几篇关于这种框架的文章……但巨龙并没有被消灭掉。”这个回答其实是在取笑Java复杂的框架。C语言则是“赶到那里,对巨龙不屑一顾,举起剑,砍掉巨龙的头,找到公主……把公主晾在一边,去看看有没有最新提交的Linux内核代码。”这个答案则是夸奖C语言的强大,以及C语言社区对Linux内核的投入。至于Python语言,很简单:


importslay_dragon

了解Python模块的人会对这行代码微微一笑。在Python中,一个.py文件就构成一个模块。通过模块,你可以调用其他文件中的函数。而引入(import)模块,就是为了在新的程序中重复利用已有的Python程序。Python通过模块,让你可以调用其他文件中的函数。我们先写一个first.py文件,内容如下:


def laugh():
    print("HaHaHaHa")

再在同一目录下写一个second.py文件。在这段程序中引入first模块:


from first import laugh
for i in range(10):
    laugh()

借着import语句,我们可以在second.py中使用first.py中定义的laugh()函数。除了函数,我们还可以引入其他文件中包含的数据。比如我们在module_var.py中写入:


text = "Hello Vamei"

在import_demo.py中,我们引入这一变量:


from import_demo import text
print(text)     # 打印'Hello Vamei'

对于面向过程语言来说,模块是比函数更高一层的封装模式。程序可以以文件为单位实现复用。典型的面向过程语言,如C语言,有很完善的模块系统。把常见的功能编到模块中,方便未来使用,就成为所谓的(library)。由于Python的库非常丰富,所以很多工作都可以通过引用库,即借助前人的工作来完成。这也是Python要用import语句来杀龙的原因。

2.搜索路径

我们刚才在引入模块时,把库文件和应用文件放在了同一文件夹下。当在该文件夹下运行程序时,Python会自动在当前文件夹搜索它想要引入的模块。

但Python还会到其他的地方寻找库:

(1)标准库的安装路径

(2)操作系统环境变量PYTHONPATH所包含的路径

标准库是Python官方提供的库。Python会自动搜索标准库所在的路径。因此,Python总能正确地引入标准库中的模块。例如:


import time

如果你是自定义的模块,则可以放在自认为合适的地方,然后修改PYTHONPATH这个环境变量。当PYTHONPATH包含模块所在的路径时,Python便可以找到那个模块。修改PYTHONPATH的方式可参考本章附录A。

3.5 异常处理

1.恼人的bug

bug一定是程序员最痛恨的生物了。程序员眼中的bug,是指程序缺陷。这些程序缺陷会引发错误或者意想不到的后果。很多时候,程序bug可以事后修复。当然,也存在无法修复的教训。欧洲ARIANE 5火箭第一次发射时,在一分钟之内爆炸。事后调查原因,发现导航程序中的一个浮点数要转换成整数,但由于数值过大溢出。此外,英国直升机于1994年坠毁,29人死亡。调查显示,直升机的软件系统“充满缺陷”。而电影《2001太空漫游》中,超级计算机HAL杀死了几乎所有的宇航员,原因是HAL程序中的两个目标出现了冲突。

在英文中,bug是虫子的意思。工程师很早就开始用bug这个词来指代机械缺陷。而软件开发中使用bug这个词,还有一个小故事。曾经有一只蛾子飞进一台早期计算机,造成这台计算机出错。从那以后,bug就被用于指代程序缺陷。这只蛾子后来被贴在日志本上,至今还在美国国家历史博物馆展出。

很多程序缺陷都可以很早发现并改正。比如,下面程序错用了语法,在for的一行没有加引号。


for i in range(10)
    print(i)

Python不会运行这段程序。它会提醒你有语法错误:


SyntaxError: invalid syntax

下面的程序并没有语法上的错误,但在Python运行时,会发现引用的下标超出了列表元素的范围:


a = [1, 2, 3]
print(a[3])    

程序会中止报错:


IndexError: list index out of range

上面这种只有在运行时,编译器才会发现的错误被称为运行时错误(Runtime Error)。由于Python是动态语言,许多操作必须在运行时才会执行,比如确定变量的类型等。因此,Python要比静态语言更容易产生运行时错误。

还有一种错误,称为语义错误(Semantic Error)。编译器认为你的程序没有问题,可以正常运行。但当检查程序时,却发现程序并非你想做的。通常来说,这种错误最为隐蔽,也最难纠正。比如下面这个程序,目的是打印列表的第一个元素:


bundle = ["a", "b", "c"]
print(bundle[1])

程序并没有错误,正常打印。但你发现,打印出的是第二个元素"b",而不是第一个元素。这是因为Python列表的下标是从0开始的,所以引用第一个元素,下标应该是0而不是1。

2.Debug

修改程序缺陷的过程称为debug。计算机程序具有确定性,所以错误的产生总会有其根源。当然,有时花大量时间都不能debug一段程序,确实会产生强烈的挫败感,甚至认为自己不适合做程序开发。还有的人怒摔键盘,认为电脑在玩自己。就我个人的观察来说,再优秀的程序员,在写程序时也总会产生bug。只不过,优秀的程序员在debug的过程中更心平气和,不会因为bug而质疑自己。他们甚至会把debug的过程当作一种训练,通过更好地理解错误根源来让自己的计算机知识更上一层楼。

其实,debug有点像做侦探。搜集蛛丝马迹的证据,排除清白的嫌疑人,最后留下真凶。收集证据的方法有很多,也有许多现成的工具。对于初学者来说,不需要花太多的时间在这些工具上。在程序内部插入简单的print()函数,就可以查看变量的状态以及运行进度。有时,还可以将某个指令替换成其他形式,看看程序结果有何变化,从而验证自己的假设。当其他可能性都排除了,那么剩下的就是导致错误的真正的原因。

从另一个方面来看,debug也是写程序的一个自然部分。有一种开发程序的方式,就是测试驱动开发(Test-Driven Development,TDD)。对于Python这样一种便捷的动态语言来说,很适合先写一个小型的程序,实现特定的功能。然后,在小程序的基础上,渐进地修改,让程序不断进化,最后满足复杂的需求。整个过程中,你不断增加功能,也不断改正某些错误。重要的是,你一直在动手编程。Python作者本人就很喜欢这种编程方式。因此,debug其实是你写出完美程序的一个必要步骤。

3.异常处理

对于运行时可能产生的错误,我们可以提前在程序中处理。这样做有两个可能的目的:一个是让程序中止前进行更多的操作,比如提供更多的关于错误的信息。另一个则是让程序在犯错后依然能运行下去。

异常处理还可以提高程序的容错性。下面的一段程序就用到了异常处理:


while True:
    inputStr = input("Please input a number:")   # 等待输入
    try:
        num = float(inputStr)
        print("Input number:", num)
        print("result:", 10/num)
    exceptValueError:
        print("Illegal input.Try Again.")
    exceptZeroDivisionError:
        print("Illegal devision by zero.Try Again.")

需要异常处理的程序包裹在try结构中。而except说明了当特定错误发生时,程序应该如何应对。程序中,input()是一个内置函数,用来接收命令行的输入。而float()函数则用于把其他类型的数据转换为浮点数。如果输入的是一个字符串,如"p",则将无法转换成浮点数,并触发ValueError,而相应的except就会运行隶属于它的程序。如果输入的是0,那么除法的分母为0,将触发ZeroDivisionError。这两种错误都由预设的程序处理,所以程序运行不会中止。

如果没有发生异常,比如输入5.0,那么try部分正常运行,except部分被跳过。异常处理完整的语法形式为:


try:
     ...
except exception1:
     ...
except exception2:
    ...
else:
...
finally:
...

如果try中有异常发生时,将执行异常的归属,执行except。异常层层比较,看是否是exception1、exception2……直到找到其归属,执行相应的except中的语句。如果try中没有异常,那么except部分将跳过,执行else中的语句。

finally是无论是否有异常,最后都要做的一些事情。

如果except后面没有任何参数,那么表示所有的exception都交给这段程序处理,比如:


while True:
    inputStr = input("Please input a number:")
    try:
        num = float(inputStr)
        print("Input number:", num)
        print("result:", 10/num)
    except:
        print("Something Wrong.Try Again.")

如果无法将异常交给合适的对象,那么异常将继续向上层抛出,直到被捕捉或者造成主程序报错,比如下面的程序:


def test_func():
    try:
        m = 1/0
    exceptValueError:
        print("Catch ValueError in the sub-function")
    
try:
    test_func()
exceptZeroDivisionError:
    print("Catch error in the main program")

子程序的try...except...结构无法处理相应的除以0的错误,所以错误被抛给上层的主程序。

使用raise关键字,我们也可以在程序中主动抛出异常。比如:


raiseZeroDivisionError()

附录A 搜索路径的设置

Python引入模块时,会到搜索路径寻找相应的模块。如果引入失败,则有可能是搜索路径设置不正确。我们可以按照下面的办法来设置搜索路径。

在Python内部,可以用下面的方法来查询搜索路径:


>>>import sys
>>>print(sys.path)

可以看到,sys.path是一个列表。列表中的每个元素都是一个会被搜索的路径。我们可以通过增加或删除这个列表中的元素,来控制Python的搜索路径。

上面的更改方法是动态的,所以每次写程序时都要添加相关的改变。我们也可以设置PYTHONPATH环境变量,来静态改变Python搜索路径。在Linux系统下,可以在home文件夹下的.bashrc文件中添加下面一行,来改变PYTHONPATH:


export PYTHONPATH=/home/vamei/mylib:$PYTHONPATH

这一行的含义是在原有的PYTHONPATH基础上,加上/home/vamei/mylib。在Mac下需要修改的文件是home文件夹下的.bash_profile,修改方法和Linux类似。

在Windows下也可以设置PYTHONPATH。右击“计算机”,在菜单中选择属性。这时会出现一个“系统”窗口。单击“高级系统设置”,会出现一个叫“系统属性”的窗口。选择环境变量,在其中添加PYTHONPATH的新变量,然后设置这个变量的值,即想要搜索的路径。

附录B 安装第三方模块

除标准库中的模外,还有很多第三方贡献的Python模块。安装这些模块最常用的方式是使用pip。在安装Python时,pip也会安装在你的计算机中。如果想安装第三方模块,如numpy,那么可以使用下面的方式安装:


$pip installnumpy

如果使用了virtualenv,那么每个虚拟环境都会提供一个对应改虚拟环境Python版本的pip。在某个环境下使用pip,模块会安装到该虚拟环境中。如果你切换虚拟,那么所使用的模块和模块的版本都会随之变化,从而避免了模块与Python版本不符的尴尬。

在EPD Python和Anaconda下,还提供了额外的安装第三方模块的工具,可前往官网查阅使用方法。可以利用下面命令,来找到安装的所有模块,以及模块的版本:


$pip freeze

附录C 代码规范

对于本章中出现的函数和模块,我在命名时全部使用的是小写字母。单词之间用下画线连接。以上用法也与PEP8中对函数和模块的规定相符。本章在讲解“异常处理”时,异常都是如ValueError这样的类。关于类的代码规范将在下一章讲解。

————————————————————

(1) 命题是对某个现象的描述。

(2) 所谓的主程序,其实就是一个.py程序构成的模块。我将在下一节讲解模块。这里暂时不严格地称为主程序。