且构网

分享程序员开发的那些事...
且构网 - 分享程序员编程开发的那些事

《从问题到程序:用Python学编程和计算》——2.9 计算的抽象和函数

更新时间:2022-10-03 13:36:09

本节书摘来自华章计算机《从问题到程序:用Python学编程和计算》一书中的第2章,第2.9节,作者 裘宗燕,更多章节内容可以访问云栖社区“华章计算机”公众号查看。

2.9 计算的抽象和函数

前面两节介绍了Python语言的所有控制结构。下面先对它们做一些概括和总结,而后介绍控制结构之上的另一类编程机制:函数定义。

2.9.1 计算的控制和抽象

前面介绍了Python语言的三种控制结构,再加上顺序执行,总共形成了三种基本的计算流程模式,分别是顺序、选择和重复。Python的一些语言结构分别对应于这三种模式。图2.2画出了相应计算流程的图示,这种图也称为流程图。


《从问题到程序:用Python学编程和计算》——2.9 计算的抽象和函数

顺序计算模式就是做完一个操作之后做下一个操作,如图2.2a所示。图中矩形块表示操作,矩形块之间的箭头表示执行的流向。在Python里,位于同一层次的一系列语句描述这种计算模式。注意,由于一系列操作也可以抽象地看作一个组合操作(Python里有相应写法,见本章最后的“语言细节”一节),如果把图中从操作2到操作n看作一个操作(如图中虚线框所示),这里的一系列操作也可以看作两个操作的组合。

选择计算模式根据特定条件,从两个不同操作中选择一个执行,如图2.2b。其中的菱形框表示条件检查,其两个出口分别标明真和假,表示条件判断后的两个可能流向。Python的if语句实现这种计算流程。应该注意,这样一个复合操作本身也可以抽象地看作一个操作(如虚线框所示),可以放在任何计算流程中作为组成部分。Python里的if结构也是语句,可以出现在程序里任何要求语句的位置,正反映了上面的认识。还应注意,图示中的操作并不限于一个语句,允许是任意复杂的组合操作(复杂的语句结构)。

重复计算模式要求根据某种控制方式重复执行操作,如图2.2c所示,具体控制方式视不同情况而定。Python的for和while语句实现重复计算,它们分别采用迭代器或者逻辑条件控制继续循环体的重复执行或者结束。由于Python有break语句,因此实际可以形成的执行流程比这个图示更复杂一些,但大体情况类似。同样,一个循环结构也可以作为单元出现在任何其他模式中。Python的for和while也是语句,可以作为其他结构的组成部分,形成复杂的嵌套结构。

上述讨论中反复说到的抽象和分解的观点,在1.3.3节里讨论过这个问题。在设计一个程序时,经常需要根据问题的情况,将其划分为顺序的一系列较小计算片段(顺序计算模式),每个片段看作一个抽象的操作。对每个较小片段,又可能需要按某种模式进一步分解,采用Python的if、for或者while结构进一步分解。这样分解下去,直至计算中的条件和操作都能用Python语言的基本功能描述。

上面这种想法非常重要,是开发复杂程序的基本技术。但这样做还是有缺点:随着一步步的分解,早前的抽象部分被一层层展开,程序也会变得越来越长,越来越复杂。各层次的结构搅在一起,程序的可读性和易修改性也会随之逐步恶化。对于开发者而言,直观的感觉就是这个程序越来越复杂、越来越把握不住、越来越难搞了。

今天的复杂程序可能包含多至百万、千万或者上亿行代码。把这么多代码写成一段连续的程序,任何人都无法把握其行为。要克服由程序的复杂性带来的问题,就需要维持程序中的抽象。就像人们在数学和其他科学研究中定义概念一样,编程也不能一直在语言的基本层面上进行,必须不断引入新的高级概念,包装起计算中的复杂性。Python中处理这一问题的最基本机制就是下一小节介绍的函数,其他机制将在后面介绍。

2.9.2 计算的抽象:函数

函数是Python语言的一种重要编程机制,用于将一段计算包装起来,然后就可以以非常方便的方式使用,可以使用一次,也可以使用任意多次。

值的抽象和计算的抽象

编程语言里的变量是最基本抽象机制,用于给对象命名。算出一个所需要的值,可能要做很多工作,如果不给它命名,用过就丢了,再次使用就必须重新计算,至少要花费一些代价,程序也会变得更长。把计算出的值(对象)赋给变量,就为其建立了一个抽象。后面再需要这个值就不必重新计算,只需要写出相应的变量名,就可以方便地使用了。这是建立值抽象的第一层意义:一次计算,可以任意多次使用。

实际上,为值建立抽象(赋给变量)还有另一层重要意义。举例说,程序里通过一段计算得到了值25.4,实际上是一个三角形的面积。把这个值赋给变量area之后,在写下面的程序时,写程序的人就可以基于“面积”这个概念思考问题,而不是基于那段很复杂的计算,或者基于形式上没有反映任何意义的25.4。这个例子也说明变量名的重要作用:合适的名字能成为很好的概念提示,减轻人们在编程中的思维负担。如果到处使用毫无意义的x1、a2或者0、1等,很快就会把自己搞糊涂。古人也知道“名不正言不顺”。给每个变量取一个好名字,是写出好程序的重要一环,绝不可轻视。

然而,需要建立抽象,希望给予适当命名,希望能方便地重复使用的不仅是计算出的值。如前所述,我们还可能希望为(实现一段计算的)一段代码建立抽象,通过命名方便地重复使用。应该注意,计算的抽象与值的抽象性质不同。举个例子,根据前面的编程经验,如果能把计算边长分别为5、7和11的三角形面积的代码封装起来,重复使用也就是重新计算这个三角形,意义比较有限。如果能建立一个计算抽象,它能对任给的三边长计算三角形面积,其作用就很大了。这样一个计算抽象能解决一个计算问题的所有实例。

Python函数定义的意义就是能建立起一般计算过程的抽象:写一次代码,任意多次重复使用,包括对不同的数据做同样计算。基于一段代码定义一个具有计算功能的实体并给予命名,就是定义函数抽象。定义好的函数可以通过名字以简单的方式使用。

前面我们已经看到了函数的意义,使用过许多内置函数和math程序包的函数。一个函数可以完成某种特定计算,其具体的计算过程可能复杂,但使用非常方便。例如,math库里的函数能完成各种数学函数计算。我们不知道其中采用了什么方法,实现有多复杂。但是,只需要调用适当的函数,正确提供参数,就能得到所需的结果。此外,在一个程序(或者一个表达式、一个语句)里,可以任意多次调用同一个数学函数,或调用多个不同的数学函数。由于Python系统提供了这些函数,我们就不需要自己费力去实现了。

易见,函数的特点是一次定义,任意使用。开发math函数库包(以及内置函数等)的人们做了一次,所有使用Python的人都可以用,从中获益。Python语言提供的标准库中有许多有用功能。还有许多人或组织开发了许多有用的第三方Python程序包,提供了许多有用功能。有关情况可查库文档,搜索Python主页或者互联网。

自定义函数的意义

但是,无论Python系统及其标准库提供了多少函数,都不可能满足所有人的所有需要,其他人已经开发出来的功能也未必合用。为了编程的需要,Python允许我们自己定义函数,这样定义的函数称为自定义函数。实际上,网上可以找到的很多公开资源,也就是其他人用Python语言开发的模块,其中一部分就是他们定义的各种函数。

自定义函数真的很有价值吗?作为例子,还是考虑前面基于三边求三角形面积的问题。前面的程序执行一次,能完成一个特定三角形的计算,输出的结果只能供人看,无法简单地继续使用。而如果有一个函数triangle(a, b, c)计算三角形面积,就可以用它写出很多有实际意义的有用代码,例如:

x = triangle(6, 8, 11 # 记录三角形面积,后面使用
y = triangle(3, 5, 6) * 3 # 一个三角柱体的体积
z = triangle(13, 15, 16) - triangle(6, 8, 11) # 一个空心三角形的面积
... ... # 等等

这样的例子无穷无尽,而这只是一个自定义函数。

函数定义

函数定义也是Python的一种复合语句,其执行效果就是定义好一个函数。一个函数定义(语句)包装起一段代码,建立起一个函数抽象,并为其命名。

函数定义的语法是:

《从问题到程序:用Python学编程和计算》——2.9 计算的抽象和函数

其中函数名是作为被定义函数的名字的标识符,参数列表是列出的0个或多个名字,多个名字之间用逗号分隔(这是最简单的情况,更复杂的情况在第5章介绍)。这里列出的名字称为所定义函数的形式参数(简称形参),整个圆括号及其包起的部分称为函数的形式参数表。函数定义的最后是一个语句组,称为函数体。函数体之前的部分称为函数头部。

Python语言把函数定义也看作一种复合语句,其语义(其执行)就是根据形式参数表和函数体建立一个函数对象,并将其约束到给定的函数名。此后就可以通过函数名使用所定义的函数了。自定义函数同样通过函数调用的方式使用,其形式前面已介绍:

《从问题到程序:用Python学编程和计算》——2.9 计算的抽象和函数

这里的实际参数是表达式,需要与函数定义的形参匹配,最基本的情况就是一个实参对应一个形参(更复杂的情况留待后面章节讨论)。

函数体是个语句组,函数被调用时,就会执行这个语句组。体中的语句执行完毕,一次函数调用就结束了。但这里还有一个问题:许多函数需要有返回值,编程语言必须提供描述函数返回值的机制。Python语言为此提供了一种语句:函数体里的return语句有特殊作用,用于描述函数的返回值。在进一步介绍之前先看一个例子:

【例】三角形面积的函数及其使用
根据上面讨论可以写出下面代码:

from math import sqrt

def triangle(a, b, c):
    s = (a + b + c) / 2
    area = sqrt(s * (s - a) * (s - b) * (s - c))
    return area

x = triangle(6, 8, 11)
y = triangle(12, 17, 19) - triangle(4, 7, 9)
z = triangle(3, 9, 7) * 4
print("Area of a triangle:", x)
print("Area of a triangle with a hole:", y)
print("Volume of a triangle prism:", z)

这里的def语句定义了一个名字为triangle的函数,函数体照搬前面的代码,其中从形参出发计算出三角形的面积,最后增加了一个return语句。

随后的三个语句里都调用刚刚定义的函数,它们分别计算出一些值。调用自定义函数的过程就是把实参的值送给形参,然后执行函数体包装的语句。执行中遇到return语句时函数结束,求出return所带表达式的值作为函数的结果返回。

现在详细解释return语句。这种语句只能出现在函数里,有两种形式:

《从问题到程序:用Python学编程和计算》——2.9 计算的抽象和函数

无论何时执行到一个return语句,当前函数总是立即结束。如果语句包含表达式部分,在函数结束前先算出这个表达式的值,函数结束后以这个值作为函数的返回值。如果语句不包含表达式部分,函数结束返回特殊值None,看作不返回值。

None是Python语言的一个特殊对象,用于表示不存在有意义的值的情况。Python解释器在很多时候也用到这个值。例如赋值语句不像普通表达式,在交互方式下执行不会显示出任何结果。Python就让它的值取None,这个值不显示。再如:

>>> None
>>>

执行一个函数调用时的详细情况如下:

1)从左到右逐个求值实际参数(表达式),得到的值赋给函数定义中对应的形参;
2)执行函数体里的语句;

3)执行中遇return语句时函数的执行立即结束。如果这个return有表达式部分,就求值其表达式作为返回值,没有表达式时返回值为None;

4)所有语句都执行完时函数也结束,返回值为None。

2.9.3 函数定义和使用实例

本小节给出几个函数定义实例。

求三角形面积的函数(修改)

在前面函数定义里,用一个赋值语句把求出的三角形面积赋给变量area,紧随其后的return语句返回area的值。可以看到,这个变量并没有其他作用,只是临时记录一下函数的返回值。对于这种情况,完全可以不引进新变量,直接把计算返回值的表达式写在return语句里,这里允许写任意复杂的表达式:

def triangle(a, b, c):
    s = (a + b + c) / 2
    return sqrt(s * (s - a) * (s - b) * (s - c))

这个定义比前一个略显紧凑,计算效率略高。这种做法值得效仿。

前面说过,并不是任意数都是某个三角形的三条边。在定义计算三角形面积的函数时,也应该考虑这个问题。现在遇到了一个麻烦:计算三角形面积的函数需要返回一个值,如果参数不符合函数要求,返回值怎么办?数学函数库的方法是报错,自定义函数也可以报错,有关情况后面讨论。现在考虑一个简单的方法,让函数返回一个特殊值。

浮点数计算得不到结果,说明计算中遇到了问题,无法得到可以用浮点数表示的值。在Python里,这种值可以用float("nan")表示,其中的nan是Not A Number的缩写(的小写)形式,表示得到的不是一个float能表示的数值。

利用上述机制定义的函数如下:

def triangle(a, b, c):
    if a > 0 and b > 0 and c > 0 and \
       a+b > c and a+c > b and b+c > a:
        s = (a + b + c) / 2
        return sqrt(s * (s - a) * (s - b) * (s - c))
    else:
        return float("nan")

注意,由于描述if条件的表达式很长,这里同样使用续行。

调用这种函数时有一个麻烦:它可能返回正常结果,也可能返回非正常的特殊值。如果能保证送给函数的参数都合适,例如在调用函数之前检查实参,就不需要担心第二种情况。计算中出错是经常需要考虑的情况,Python有专门机制处理这方面问题。

计算并输出三角形面积的函数

有时我们定义函数不是为了计算出一个值,而是希望实现一些操作效果。例如,内置函数print并没完成什么有意义的计算,但也很有用。有时我们也需要定义特殊的输出函数,例如完成某些计算然后输出结果的函数。

现在考虑定义一个计算三角形面积并且输出这个面积的函数。根据已有知识,这个函数可以如下定义:

from math import sqrt

def print_triangle(a, b, c):
    if a > 0 and b > 0 and c > 0 and \
       a+b > c and a+c > b and b+c > a:
        s = (a + b + c) / 2
        area = sqrt(s * (s - a) * (s - b) * (s - c))
        print("Area of triangle:", area)
    else:
        print(a, b, c, "do not form a triangle.")

print_triangle(6, 8, 11)
print_triangle(4, 7, 9)
print_triangle(12, 17, 19)

这里还包含了几个使用函数的语句,执行这个脚本时就能看到输出。

自定义输出函数是一类常见的不返回值的函数,后面还会看到其他情况。

计算阶乘的函数

把计算中的关键部分提取出来定义为函数,可以使程序的结构更清晰,意义也更容易理解。例如,下面是另一个阶乘计算器脚本,这里定义了一个函数完成阶乘计算,随后的代码实现循环控制,其中调用前面定义的函数:

def fact(n):
    prod = 1
    for k in range(2, n+1):
        prod = prod * k
    return prod

while True:
    n = int(input("Next int:"))
    if n < 0:
        break
    print("Factorial of", n, "is", fact(n))

print("Bye!")

与前面实现同样功能的程序相比,这个程序多了几行。把阶乘计算的部分独立出来,使之脱离复杂的全局控制结构,这个新写法使程序清晰得多。可见,虽然有时定义的函数在程序里只使用一次,但做出这个函数定义也很值得。

函数可以包装任意复杂的计算,在函数体的实现中可以使用任何语句,有关语句可以任意地嵌套。此外,return语句不一定出现在函数的最后,可以写在函数里的任何地方,包括出现在循环或者其他结构中。