第7章 使用函数组织代码
到目前为止,本书的示例程序都使用 main()实现所有功能。对小型应用程序来说,这完全可行,但随着程序越来越大、越来越复杂,除非使用函数,否则main()将越来越长。
函数让您能够划分和组织程序的执行逻辑。通过使用函数,可将应用程序的内容划分成依次调用的逻辑块。
函数是子程序,可接受参数并返回值,要让函数执行其任务,必须调用它。在本章中,您将学习:
• 为何需要编写函数;
• 函数原型和函数定义;
• 给函数传递参数以及从函数返回值;
• 重载函数;
• 递归函数;
• C++11 lambda函数。
7.1 为何需要函数
假设要编写一个应用程序,让用户输入圆的半径并计算其周长和面积。为此,一种方式是将所有逻辑都放在 main()中。另一种方式是将该应用程序划分成逻辑块,具体地说是两个逻辑块,它们分别根据半径计算面积和周长,如程序清单7.1所示。
程序清单7.1 两个根据半径分别计算圆的面积和周长的函数
输出:
分析:
乍一看,这不过是换汤不换药,但您将意识到,通过将计算面积和周长的代码放到不同的函数中,将有助于提高可重用性,因为可根据需要重复地调用这些函数。这里的 main()(它本身也是一个函数)相当简洁,它将工作委托给Area和Circumference等函数去完成,并在第16和19行分别调用它们。
该程序演示了使用函数进行编程涉及的如下内容。
• 第6和7行声明了函数原型,这样在main()中使用Area和Circumference时,编译器知道它们是什么。
• 在main()中,第16和19行调用了函数Area()和Circumference()。
• 第25~28行定义了函数Area(),而第30~33行定义了函数Circumference()。
再来看一下程序清单7.1的第6和7行:
图7.1说明了函数原型的组成部分。
图7.1 函数原型的组成部分
函数原型指出了函数的名称(Area)、函数接受的参数列表(一个名为InputRadius的double参数)以及返回值的类型(double)。
如果没有函数原型,编译器遇到main()中的第16和19行时,将不知道Area和Circumference是什么。函数原型告诉编译器,Area和Circumference是函数,它们接受一个类型为double的参数,并返回一个类型为double的值。这样,编译器将意识到这些语句是合法的,而链接器负责将函数调用与实现关联起来,并确保程序执行时将触发它们。
函数可接受用逗号分隔的多个参数,但只能有一种返回类型。
编写不需要返回任何值的函数时,可将其返回类型指定为void。
函数的最基本部分——实现———被称为函数定义。下面来分析函数Area的定义:
函数定义总是由一个语句块组成。除非返回类型被声明为 void,否则函数必须包含一条 return 语句。就这里而言,函数Area需要返回一个值,因为其返回类型并不是void。语句块是包含在左大括号和右大括号({})内的语句,在函数被调用时执行。Area()使用输入参数InputRadius来计算圆的面积,该参数包含调用者以实参方式传递的半径。
如果函数声明中包含形参(parameter),调用函数时必须提供实参(argument),它们是函数的形参列表要求的值。下面来分析程序清单7.1中对函数Area的调用:
其中Area(Radius)是函数调用,而Radius是传递给函数Area的实参。执行到Area(Radius)处时,将跳转到函数Area处,使用传递给它的半径计算圆的面积。函数Area执行完毕时,将返回一个double值。然后,通过cout语句将这个返回的double值显示到屏幕上。
假设要编写一个计算圆柱面积的程序,如图7.2所示。
图7.2 圆柱
计算圆柱面积的公式如下:
计算圆柱面积时,需要两个变量——半径和高度。编写计算圆柱表面积的函数时,至少需要在函数声明的形参列表中指定两个形参。为此,需要用逗号分隔形参,如程序清单7.2所示。
程序清单7.2 接受两个参数以计算圆柱表面积的函数
输出:
分析:
第6行是函数SurfaceArea的声明,其中包含两个用逗号分隔的形参——Radius和Height,它们的类型都是double。第22~26行是函数SurfaceArea的定义,即实现。正如您看到的,使用输入参数Radius和Height计算了表面积,将其存储在Area中,再将Area返回给调用者。
函数形参类似于局部变量,它们只在当前函数内部可用。因此,在程序清单 7.2 中,函数SurfaceArea的形参Radius和Height只在函数SurfaceArea内部可用,而在该函数外部不可用。
如果将显示Hello World的工作委托给一个函数,且该函数不做别的,则它就不需要任何参数(因为它除了显示Hello World外,什么也不做),也无需返回任何值(因为您不指望这样的函数能提供在其他地方有用的东西)。程序清单7.3演示了一个这样的函数。
程序清单7.3 没有参数和返回值的函数
输出:
分析:
注意到第3行的函数原型将函数SayHello的返回类型声明为void,即不返回任何值。因此,在第11~14行的函数定义中,没有return语句。在main()中,第7行的函数调用没有将返回值赋给任何变量,也没有将其用于任何表达式中,因为该函数不返回任何值。
在本书前面的示例中,您都将Pi声明为常量,没有给用户提供修改它的机会。然而,用户可能希望其精度更高或更低。如果编写一个函数,在用户没有提供的情况下,将Pi设置为您选择的值呢?
为解决这种问题,一种方式是给函数Area()新增一个表示Pi的参数,并将其默认值设置为您选择的值。对程序清单7.1所示的函数Area()做这样的修改后,结果将如下:
请注意第二个参数Pi及其默认值3.14。对调用者来说,这个参数是可选的,因此仍可使用下面的语法来调用Area:
这里省略了第二个参数,因此它将使用默认值3.14。然而,如果用户提供了Pi值,您就可在调用Area时指定它,如下所示:
程序清单7.4演示了如何编写参数包含默认值的函数,这种默认值可被用户提供的值覆盖。
程序清单7.4 计算圆面积的函数,其第二个参数为Pi,该参数的默认值为3.14
输出:
再次运行的输出:
分析:
从上述输出可知,两次运行时用户输入的半径相同,都是1。然而,第二次运行时,用户修改了Pi的精度,因此计算得到的面积稍有不同。两次运行时调用的是同一个函数,如第22和25行所示。第 25行调用 Area时没有指定第二个参数 Pi,因此将使用默认值 3.14,这是在第 4行的声明中指定的。
可以给多个参数指定默认值,但这些参数必须位于参数列表的末尾。
在有些情况下,可让函数调用它自己,这样的函数称为递归函数。递归函数必须有明确的退出条件,满足这种条件后,函数将返回,而不再调用自己。
如果没有退出条件或存在 bug,递归函数可能不断调用自己,直到栈溢出后才停止,导致应用程序崩溃。
计算斐波纳契数列时,递归函数很有用,如程序清单7.5所示。该数列的开头两个数为0和1:
随后的每个数都是前两个数之和。计算第n个数(n>1)的公式如下:
因此斐波纳契数列如下:
程序清单7.5 使用递归函数计算斐波纳契数列中的数字
输出:
分析:
函数GetFibNumber是在第3~9行定义的,这是一个递归函数,因为它在第8行调用了自己。请注意第 5 和 6 行的退出条件:如果索引小于 2,该函数将不再递归。鉴于该函数调用自己时降低了FibIndex的值,因此递归到一定程度后将满足递归条件,从而停止递归。
在函数定义中,并非只能有一条 return 语句。您可以在函数的任何地方返回,如果愿意,还可包含多条return语句,如程序清单7.6所示。这可能是糟糕的编程方式,也可能不是,这取决于应用程序的逻辑和需求。
程序清单7.6 在同一个函数中使用多条return语句
输出:
再次执行的输出:
分析:
函数QueryAndCalculate包含多条return语句:一条位于第17行,另一条位于第20行。这个函数询问用户是否也想计算周长,如果用户按n表示no,程序将使用return语句退出;否则将接着计算周长,然后返回。
在同一个函数中使用多条return语句要谨慎。相对于有多个返回点的函数,从顶部开始并在末尾返回的函数要容易理解得多。
在程序清单7.6中,使用了多条return语句。这很容易避免,只需修改if条件,使其检查用户输入的是否是‘y’(Yes)即可:
7.2 使用函数处理不同类型的数据
并非只能每次给函数传递一个值,还可将数组传递给函数。您可创建两个名称和返回类型相同,但参数不同的函数。您可创建这样的函数,即其参数不是在函数内部创建和销毁的;为此可使用在函数退出后还可用的引用,这样可在函数中操纵更多数据或参数。在本节中,您将学习将数组传递给函数、函数重载以及按引用给函数传递参数。
名称和返回类型相同,但参数不同的函数被称为重载函数。在应用程序中,如果需要使用不同的参数调用具有特定名称和返回类型的函数,重载函数将很有用。假设您需要编写一个应用程序,它计算圆和圆柱的面积。计算圆面积的函数需要一个参数——半径,而计算圆柱面积的函数除需要圆柱的半径外,还需要圆柱的高度。这两个函数需要返回的数据类型相同,都是面积。C++让您能够定义两个重载的函数,它们都叫Area,都返回double,但一个接受半径作为参数,另一个接受半径和高度作为参数,如程序清单7.7所示。
程序清单7.7 使用一个重载函数来计算圆或圆柱的面积
输出:
再次执行的输出:
分析:
第5和6行声明了两个重载的Area函数的原型,一个接受一个参数——圆半径,另一个接受两个参数——圆柱的半径和高度。这两个函数同名,都叫 Area,返回类型相同,都是 double,但参数不同,因此它们是重载的。第34~44行是这两个重载的函数的定义,一个根据半径计算圆的面积,另一个根据半径和高度计算圆柱的面积。有趣的是,由于圆柱的面积由两个圆(顶圆和底圆)的面积和侧面的面积组成,因此用于圆柱的重载版本可重用用于圆的版本,如第43行所示。
显示一个整数的函数类似于下面这样:
显示整型数组的函数的原型稍微不同:
第一个参数告诉函数,输入的数据是一个数组,而第二个参数指出了数组的长度,以免您使用数组时跨越边界,如程序清单7.8所示。
程序清单7.8 接受数组作为参数的函数
输出:
分析:
这里有两个重载的函数,它们都叫DisplayArray:一个显示int数组的元素,另一个显示char数组的元素。在第22和25行,分别使用int数组和char数组调用了这两个函数。注意到第24行声明并初始化char数组时,故意在末尾添加了空字符(这是一种最佳实践,也是一种良好的习惯),虽然在这个应用程序中,没有在 cout语句中将该数组用作字符串(cout << MyStatement;)。
再来看一下程序清单7.1中根据半径计算圆面积的函数:
其中,参数InputRadius包含的值是在main()中调用函数时复制给它的:
这意味着函数调用不会影响main()中的变量Radius,因为Area()使用的是Radius包含的值的拷贝。有时候,您可能希望函数修改的变量在其外部(如调用函数)中也可用,为此,可将形参的类型声明为引用。下面的Area()函数计算面积,并以参数的方式按引用返回它:
注意到该Area()函数接受两个参数。别遗漏了第二个形参Result旁边的&,它告诉编译器,不要将第二个实参复制给函数,而将指向该实参的引用传递给函数。返回类型变成了 void,因为该函数不再通过返回值提供计算得到的面积,而按引用以输出参数的方式提供它。程序清单 7.9 演示了如何按引用返回值,该程序计算圆的面积。
程序清单7.9 以引用参数(而不是返回值)的方式提供圆的面积
输出:
分析:
注意到第18行调用函数Area时提供了两个参数,其中第二个参数将包含结果。由于Area的第二个参数是按引用传递的,因此 Area()中第 8 行使用的变量 Result,与 main()中第 17 行声明的 double AreaFetched指向同一个内存单元。因此,在main()中,可以使用Area()中第8行计算得到的结果——第20行将其显示到屏幕上。
使用return语句时,函数只能返回一个值。因此,如果函数需要执行影响众多值的操作,且需要在调用者中使用这些值,则按引用传递参数是让函数将修改结果提供给调用模块的方式之一。
7.3 微处理器如何处理函数调用
在微处理器级,函数调用是如何实现的呢?虽然确切地了解这一点不是非常重要,但您可能发现它很有趣。了解这一点有助于明白C++为何支持本节后面将介绍的内联函数。
函数调用意味着微处理器跳转到属于被调用函数的下一条指令处执行。执行完函数的指令后,将返回到最初离开的地方。为实现这种逻辑,编译器将函数调用转换为一条供微处理器执行的CALL指令,该指令指出了接下来要获取的指令所在的地址,该地址归函数所有。编译函数本身时,编译器将return语句转换为一条供微处理器执行的RET指令。
遇到CALL指令时,微处理器将调用函数后将执行的指令的位置保存到栈中,再跳转到CALL指令包含的内存单元处。
理解栈
栈是一种后进先出的内存结构,很像堆叠在一起的盘子,您从顶部取盘子,这个盘子是最后堆叠上去的。将数据加入栈被称为压入操作;从栈中取出数据被称为弹出操作。栈增大时,栈指针将不断递增,始终指向栈顶,如图7.3所示。
图7.3 包含三个整数的栈的可视化表示
栈的性质使其非常适合用于处理函数调用。函数被调用时,所有局部变量都在栈中实例化,即被压入栈中。函数执行完毕时,这些局部变量都从栈中弹出,栈指针返回到原来的地方。
该内存单元包含属于函数的指令。微处理器执行它们,直到到达RET语句(与您编写的return语句对应的微处理器代码)。RET 语句导致微处理器从栈中弹出执行 CALL 指令时存储的地址。该地址包含调用函数中接下来要执行的语句的位置。这样,微处理器将返回到调用函数,从离开的地方继续执行。
常规函数调用被转换为CALL指令,这会导致栈操作、微处理器跳转到函数处执行等。听起来在幕后发生了很多事情,但在大多数情况下速度都很快。然而,如果函数非常简单,类似于下面这样又如何呢?
相对于实际执行GetPi()所需的时间,执行函数调用的开销可能非常高。这就是C++编译器允许程序员将这样的函数声明为内联的原因。程序员使用关键字 inline 发出请求,要求在函数被调用时就地展开它们:
同样,只执行将数字翻倍等简单操作的函数也非常适合声明为内联的。程序清单7.10演示了一种这样的情形。
程序清单7.10 将把整数翻倍的函数声明为内联的
输出:
分析:
第4行使用了关键字inline。编译器通常将该关键字视为请求,请求将函数DoubleNum的内容直接放到调用它的地方(第16行),以提高代码的执行速度。
将函数声明为内联的会导致代码急剧膨胀,在声明为内联的函数做了大量复杂处理时尤其如此。应尽可能少用关键字 inline,仅当函数非常简单,需要降低其开销时(如前面所示),才应使用该关键字。
大多数较新的C++编译器都提供了各种性能优化选项。有些提供了优化大小或速度的选项,如Microsoft C++编译器。为内存弥足金贵的设备和外设开发软件时,优化代码的大小至关重要。优化代码大小时,编译器可能拒绝众多的内联请求,因为这会让代码急剧膨胀。
优化速度时,编译器通常会寻找并利用合理的内联机会,为您完成内联工作,即便您没有显式地请求这样做。
C++11
本节旨在简要地介绍一个对初学者来说不那么容易理解的概念,请快速浏览,尽力学习这个概念,即便不能完全掌握,也不用失望,第22章将深入讨论lambda函数。
如果您在编程时经常使用STL算法对包含在STL容器(如std::vector)中的数据进行排序或处理,lambda 函数将很有用。通常,排序算法要求您提供一个二元谓词(它被实现为类中的一个运算符),这导致编码工作非常烦琐。遵守C++11标准的编译器让您能够编写lambda函数,从而极大地简化代码,如程序清单7.11所示。
程序清单7.11 使用lambda函数对数组中的元素进行排序并显示它们
输出:
分析:
第15~19行将几个整数压入到一个动态数组中,这个动态数组是使用C++标准模板库中的std::vector表示的。函数DisplayNums使用STL算法遍历数组的每个元素,并显示其值。为此,它在第8行使用了一个lambda函数,而不是冗长的一元函数谓词。第25行使用std::sort时,也以lambda函数的方式提供了一个二元谓词(第26行),这个函数在第二个数比第一个数小时返回true,这相当于将集合按升序排列。
lambda函数的语法如下:
7.4 总结
在本章中,您学习了模块化编程的基本知识。您了解到,使用函数可改善代码的结构,还有助于重用您编写的算法。您了解到,函数可接受参数并返回值;参数可以有调用者可覆盖的默认值,还可按引用传递参数。您学习了如何将数组传递给函数,还学习了如何编写名称和返回类型相同,但参数列表不同的重载函数。
最后,简要地介绍了lambda函数是什么。lambad函数是C++11新增的,有望改变C++应用程序的编写方式,尤其是使用STL时。
7.5 问与答
问:如果递归函数不终止,结果将如何?
答:程序将不断执行下去。程序不断执行下去也许不是坏事,因为while(true)和for(;;)循环也会导致这种后果。然而,递归函数调用将占用越来越多的栈空间,而栈空间有限,终将耗尽。最终,应用程序将因栈溢出而崩溃。
问:既然将函数声明为内联的可提高执行速度,为何不将所有函数都声明为内联的?
答:这样看情况而定。然而,如果将在多个地方使用的函数声明为内联的,将在调用它的每个地方放置其内容,这将导致代码急剧膨胀。另外,根据性能设置,大多数较新的编译器都能判断应内联哪些函数,进而为程序员这样做。
问:可给函数的所有参数都提供默认值吗?
答:绝对可以,在合理的情况下也推荐这样做。
问:我有两个函数,它们都叫Area,其中一个接受半径作为参数,另一个接受高度作为参数,但我希望一个返回float值,另一个返回double值。这可行吗?
答:重载函数时,函数必须同名,且返回类型相同。就这里的情形而言,编译器将报错,因为它要求这两个函数的名称不同。
7.6 作业
作业包括测验和练习,前者帮助读者加深对所学知识的理解,后者提供了使用新学知识的机会。请尽量先完成测验和练习题,然后再对照附录 D 的答案。在继续学习下一章前,请务必弄懂这些答案。
1.在函数原型中声明的变量的作用域是什么?
2.传递给下述函数的值有何特征?
3.调用自己的函数被称为什么?
4.我声明了两个函数,它们的名称和返回类型相同,但参数列表不同,这被称为什么?
5.栈指针指向栈的顶部、中间还是底部?
1.编写两个重载的函数,它们分别使用下述公式计算球和圆柱体的体积:
2.编写一个函数,它将一个double数组作为参数。
3.查错:下述代码有什么错误?
4.查错:下述函数声明有什么错误?
5.编写一个返回类型为 void 的函数,在提供了半径的情况下,它能帮助调用者计算圆的周长和面积。