预计阅读本页时间:-
在这一章中,我们将完成面向过程的编程范式的学习。通过第2章中的选择和循环,我们已经开始用结构化的方法来封装程序了。在这一章中,我们将看到其他面向过程的封装方法,即函数和模块。函数和模块把成块的指令封装成可以重复调用的代码块,并借着函数名和模块名整理出一套接口,方便未来调用。
3.1 懒人炒菜机
1.函数是什么
函数(Function)这个名字会让人想起中学数学,所以会带来轻微的痛苦。在数学上,函数代表了集合之间的对应关系。譬如说,所有的汽车是一个集合,所有的方向盘也是一个集合。汽车集合和方向盘集合之间存在着对应关系,可以表达为一个函数。
我们再举一个数学上的例子。下面的平方函数,将一个自然数对应为这个自然数的平方:
广告:个人专属 VPN,独立 IP,无限流量,多机房切换,还可以屏蔽广告和恶意软件,每月最低仅 5 美元
f(x)=x2,x是一个自然数
换句话说,函数f(x)定义了两组数字之间的对应关系:
x -> y 1 1 2 4 3 9 4 16 …
数学上的函数定义了静态的对应关系。从数据的角度来说,函数像是“大变活人”的魔法盒子,这个魔法盒子能把走进去的小猪变成小兔子(如图3-1所示)。对于刚才定义的函数f(x),进去的是一个自然数,出来的是这个自然数的平方。借着函数,我们实现了数据转换。

图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程序构成的模块。我将在下一节讲解模块。这里暂时不严格地称为主程序。