6 函数和递归入门
1 C++的程序组件
C++程序通常是由程序员编写的新函数和类,以及再C++标准库中“预先打包”的函数和类组合而成。
程序员自己编写的函数称为用户自定义函数(user-defined function)。函数体中的语句只需要书写一次,就能够在程序的多个地方复用,并且对于其他函数来说这些函数体的内容是不可见的。
函数是通过函数调用而执行的,当被调用函数完成任务后,它要么返回一次结果,要么简单地把控制权还给调用者。
2 数学库函数
像main函数之类的函数不是任何类的成员,这种函数称为全局函数。与类成员函数一样,全局函数的函数原型要放在头文件中,这样那些包含该头文件的任何程序就能够复用这个全局函数,并且可以连接到该函数的目标代码。
例如sqrt
函数有一个double
数据类型的实参,并返回double
数据类型的结果。调用sqrt
函数之前无需创建任何对象。<cmath>
头文件中的所有函数都是全局函数。因此调用这些函数时只需要简单地指定函数名,后跟一对包含函数实参的圆括号便可。
如果调用sqrt
时实参是个负数,那么该函数把名为errno
的全局变量设置为常量值EDOM
。变量errno
和常量EDOM
定义在头文件<cerrno>
中。
函数 | 描述 | 示例 |
---|---|---|
ceil(x) | x取整为不小于x的最小整数 | ceil(9.2) 为10.0 ceil(-9.8) 为-9.0 |
cos(x) | x(弧度)的三角余弦 | cos(0.0) 为1.0 |
exp(x) | 指数函数 e x e^x ex | exp(1.0) 为2.718282 exp(2.0) 为7.389056 |
fabs(x) | x的绝对值 | fabs(5.1) 为5.1 fabs(0.0) 为0.0 fabs(-8.76) 为8.76 |
floor(x) | x取整为不大于x的最大整数 | floor(9.2) 为9.0 floor(-9.8) 为-10.0 |
fmod(x,y) | x/y的浮点数余数 | fmod(2.6,1.2) 为0.2 |
log(x) | x(底数为e)的自然对数 | log(2.718282) 为1.0 log(7.389056) 为2.0 |
log10(x) | x(底数为10)的对数 | log10(10.0) 为1.0 log10(100.0) 为2.0 |
pow(x,y) | x的y次幂( x y x^y xy) | pow(2,7) 为128 pow(9,.5) 为3 |
sin(x) | (弧度)的三角正弦 | sin(0.0) 为0 |
sqrt(x) | x的平方根(其中x是一个非负值) | sqrt(9.0) 为3.0 |
tan(x) | x(弧度)的三角正切 | tan(0.0) 为0 |
3 具有多个形参的函数定义
函数的形参列表及对应的实参列表中,多个形参或实参之间用逗号分隔,例如:
int maximum(int,int,int) const;
maximumGrade = maximum(grade1,grade2,grade3);
Remark:
有时候函数的实参是比较复杂的表达式。例如是调用其他函数的表达式。在这种情况下,编译器对函数实参的求值顺序会影响若干实参的值。如果编译器之间的求值顺序不同,那么传递给函数的实参值也可能不同,从而产生逻辑错误。
但用以分隔实参的逗号不是逗号运算符(逗号运算符保证它的操作数从左到右求值),函数实参的求值顺序不是由C++标准指定。因此,不同的编译器就会有不同的求函数实参值的顺序。但是,C++标准的确保证在被调用函数执行之前,函数调用中的所有实参都要求值。
最保险的做法是在调用函数之前使用单独的赋值语句对实参进行求值。
返回值为
void
类型的函数调用不能放在赋值表达式的右边。
形参列表中的每个形参都要一个显式的类型说明,如:
double x,double y
而不是:
double x,y
4 函数原型和实参类型的强制转换
函数原型(也称为函数声明)告诉编译器函数的名称、函数返回数据的类型、函数预期接受的形参个数以及形参的类型和顺序。
如果函数在调用前就已定义,那么函数的定义也可以作为函数的原型,这样单独的函数原型就没必要了。若函数在定义的前面被调用,并且没由相应的函数原型,那么就会出现编译错误。
函数签名
函数原型的函数名和实参类型部分被称为函数签名(function signature),或者简称签名。函数签名并不指定函数的返回类型。同一作用域的函数必须有不同的签名。
实参类型强制转换
函数原型的一个重要特性是实参类型强制转换(argument coercion),即把实参类型强制转换为由形参声明所指定的适当类型。
如程序调用一个函数时可以使用整型实参,即使该函数原型指定的是double
数据类型的形参,函数还是会正常执行。
实参类型升级规则和隐式类型转换
有时,函数实参类型同函数原型中的形参类型并不完全一致,编译器会在调用函数前把实参转换成适当的类型。这种转换遵守C++的升级规则(promotion rule)。升级规则指出了编译器能够在基本数据类型之间进行的隐式类型转换。这种类型转换要考虑数据损失。
升级规则应用到包含两种或者多种数据类型的表达式中,这种表达式也称为混合类型表达式。其中每个值的类型会升级为此表达式中的最高类型。
5 实例:随机数生成
rand
函数(函数原型在<cstdlib>
中(生成0
∼
\sim
∼ RAND_MAX(定义在<cstdlib>
头文件中的符号常量)之间的一个无符号整数。
为了生成0 ∼ \sim ∼ 5的整型值,我们可以用这个语句:
rand() % 6
这种方法称为比例缩放(scaling),数字6称为比例缩放因子。
rand
函数实际上生成的是伪随机数,每次执行时产生的序列都是重复的。为了使其随机化,我们可以通过C++标准库函数srand
来实现。
srand
函数接收一个unsigned
整形实参,为rand
函数设置产生随机数时的随机数种子。
为了在随机化时不用每次都输入种子,可以使用以下语句:
srand(static_cast<unsigned int>(time(0)));
这条语句使计算机通过读取自己的时钟来获取种子值。time
函数(函数原型在<ctime>
头文件中)通常返回的是从格林尼治标准时间(GMT)1970年1月1日0时起到现在的秒数。这个值(数据类型是time_t
)被转换成unsigned int
类型的整数,并用作随机数生成器的种子。
6 实例:博彩游戏和枚举类型简介
掷双骰(craps)游戏规则:
代码实现为:
#include <iostream>
#include <cstdlib>
#include <ctime>
using namespace std;
unsigned int rollDice();
int main()
{
enum Status { CONTINUE, WON, LOST };
// randomize random number generator using current time
srand(static_cast<unsigned int>(time(0)));
unsigned int myPoint = 0;
Status gameStatus = CONTINUE;
unsigned int sumOfDice = rollDice();
// determine game status and point (if needed) based on first roll
switch (sumOfDice)
{
case 7:
case 11:
gameStatus = WON;
break;
case 2:
case 3:
case 12:
gameStatus = LOST;
break;
default:
gameStatus = CONTINUE;
myPoint = sumOfDice;
cout << "Point is " << myPoint << endl;
break;
}
while (CONTINUE == gameStatus)
{
sumOfDice = rollDice();
if (sumOfDice == myPoint)
{
gameStatus = WON;
}
else
{
if (sumOfDice == 7)
{
gameStatus = LOST;
}
}
}
if (WONgameStatus == gameStatus)
{
cout << "Player wins" << endl;
}
else
{
cout << "Player loses" << endl;
}
return 0;
}
unsigned int rollDice()
{
unsigned int die1 = 1 + rand() % 6;
unsigned int die2 = 1 + rand() % 6;
unsigned int sum = die1 + die2;
cout << "Player rolled " << die1 << " + " << die2 << " = " << sum << endl;
return sum;
}
枚举类型Status
枚举类型以关键字enum开头,后跟类型的名字(在本例中是Status)和一组由标识符表示的整数常量。枚举常量的默认值是0并且顺序增1,当然也可自行指定起始值。在前面的枚举类型中,常量CONTINUE的值是0,WON的值是1,LOST的值是2。在一个枚举中的标识符必须是唯一的,但是不同的枚举常量可以取相同的整数值。
作为用户自定义类型名的标识符其第一个字母最好是大写。枚举常量的名字最好只使用大写字母。
将等同于枚举常量的整数值赋值给枚举类型的变量是一种编译错误。
另一个常见的枚举类型是:
enum Months {JAN = 1,FEB,MAR,APR,MAY,JUN,JULY,JUL,AUG,SEP,OCT,NOV,DEC};
因为第一个值显式地设置为1,因此后面的值从1开始递增。
多个枚举类型可能包含相同的标识符,因此在同一个程序中使用这些枚举类型会导致命名冲突和逻辑错误。为消除此类问题,C++11中引入了所谓的作用域限定的枚举类型(scoped enum),这种类型用关键字enum class
(或同义词enum struct
)来声明。例如我们将上面例子中的枚举Status定义成:
enum class Status { CONTINUE, WON, LOST };
现在,若要引用一个作用于限定的枚举常量,就必须像Status::CONTINUE
一样写。
一个枚举类型中常量表示为整数。在默认情况下,无作用域限定的枚举类型所隐含的这种整型类型取决于它的枚举常量值,即该整型类型应保证足以保存指定的常量值。不过在默认情况下,作用域限定的枚举类型所隐含的整型类型是int
。
C++允许指定枚举类型所隐含的整型类型,如:
enum class Status : unsigned int { CONTINUE, WON, LOST };
7 C++11的随机数
在本节中我们将使用默认的随机数生成引擎default_random_engine
和默认的配置uniform_int_distribution
,后者在指定的值的范围内均匀地分布伪随机整数。默认的范围是从0到计算机系统平台所支持的最大的int
类型值。
#include <iostream>
#include <iomanip>
#include <random>
#include<ctime>
using namespace std;
int main()
{
default_random_engine engine(static_cast<unsigned int>(time(0))); //创建一个名为engine的default_random_engine对象
uniform_int_distribution<unsigned int> randomInt(1, 6); //创建一个名为randomInt的uniform_int_distribution对象,来产生1到6范围内的unsigned int类型的值
for (unsigned int counter = 1; counter <= 10; ++counter)
{
cout << setw(10) << randomInt(engine);
if (counter % 5 == 0)
{
cout << endl;
}
}
}
第9行的<unsigned int>
表明uniform_int_distribution
是一个类模板。
8 存储类别和存储器
C++提供了5个存储类别说明符:auto
、register
、extern
、mutable
、static
,它们决定了变量的存储期。本节讨论register
、extern
和 static
。存储类别说明符mutable
是专门和类一起使用的,而标识符thread_local
在多线程应用中使用。
存储期
标识符的存储期决定了标识符在内存中存在的时间。有些标识符存在的时间较短,有些标识符可以重复地创建和销毁,还有些标识符在整个程序执行过程中一直存在。存储类别说明符可以划分为四个存储期:自动存储期、静态存储期、动态存储期和线程存储期。这一节首先讨论两种存储期:静态的(static)和自动的(automatic)。
作用域
标识符的作用域是指标识符在程序中可以被引用的范围。有些标识符在整个程序中都能被引用,有些只能限于在程序的某个部分引用。
链接
标识符的链接决定了标识符是只在声明它的源文件中可以识别,还是在经编译后链接在一起的多个文件中可以识别。标识符的存储类别说明符用于确定存储类别和链接。
局部变量和自动存储期
具有自动存储期的变量包括:
- 声明在函数中的局部变量
- 函数的形参
- 用
register
声明的局部变量或函数形参
这样的变量在程序执行到定义它们的语句块时被创建,在语句块活动的时候它们是存在的,而当程序退出语句块时它们被销毁。自动变量只存在于其定义所在的函数体中最接近它的花括号对内,或者当它是函数形参时,则存在于整个函数体中。局部变量默认情况下具有自动存储期。在本书之后的章节中,将把具有自动存储期的变量简称为自动变量。
寄存器变量
程序的机器语言版本中的数据一般都是加载到寄存器中进行计算和其他处理的。
编译器或许会忽略掉register
声明。例如,可能没有足够数量的寄存器供编译器使用。下面的定义“建议”unsigned int
类型的变量counter能被放在计算机的一个寄存器中;不管编译器是否这样做,counter都将被初始化为1:
register unsigned int counter = 1;
关键字register
只能与局部变量和函数的形参一起使用。
如果将频繁使用的变量,例如计数器或者总和,保存在硬件寄存器中,那么重复地把变量从内存加载到寄存器及把结果返回到内存而引起的开销就可以消除了。
register
常常是不必要的。如今优化的编译器能够识别频繁使用的变量,并且不需要程序员进行register
的声明就会自行决定把这些变量放到寄存器中。
静态存储期
关键字extern
和static
为函数和具有静态存储期的变量声明标识符。具有静态存储期的变量从程序开始执行的时刻起直至程序执行结束,一直存在于内存中。在遇到这样的变量声明时,便对它进行一次性初始化。对于函数而言,在程序开始执行时函数名存在。然而,即使函数名和静态存储期变量在程序一开始执行时就存在,也并不意味着这些标识符在整个程序中都能使用。存储期和作用域(名字可以使用的地方)是独立的问题。
静态局部变量
使用关键字static
声明的局部变量仅被其声明所在的函数所知。但是,与自动变量不同的是,static
局部变量在函数返回到它的调用者后仍保留着它们的值。下次再调用函数时,static
局部变量包含的是该函数最后一次执行得到的值。下面的语句将局部变量count声明为static并且初始化为1:
static unsigned int count 1;
如果程序员没有显式地初始化具有静态存储期的数值变量,那么它们被默认地初始化为0。
9 作用域规则
语句块作用域
在一个语句块中声明的标识符具有语句块作用域(block scope)。该作用域开始于标识符的声明处,结束于标识符声明所在语句块的结束右花括号处。
局部变量具有语句块作用域,函数形参同样具有语句块作用域。任何语句块都能包含变量声明。当语句块是嵌套的并且外层语句块中的一个标识符与内层语句块中的一个标识符具有相同的名字时,外层语句块的标识符处于“隐藏”状态,直到内层语句块的执行结束为止。内层语句块看到的是它自己的局部标识符的值,而不是包含它的语句块中同名标识符的值。声明为static
的局部变量仍然具有语句块作用域,虽然它们从程序开始执行时就一直存在。存储期并不影响标识符的作用域。
函数作用域
标签,也就是像start:
之类的后跟一个冒号的标识符,或者是switch
语句中的case
标签,是唯一具有函数作用域(function scope)的标识符。标签可以用在它们出现的函数内的任何地方,但是不能在函数体之外被引用。
全局命名空间作用域
声明于任何函数或者类之外的标识符具有全局命名空间作用域(global namespace scope)。这种标识符对于从其声明处开始直到文件结尾处为止出现的所有函数而言都是“已知”的,即可访问的。位于函数之外的全局变量、函数定义和函数原型都具有全局命名空间作用域。
函数原型作用域
具有函数原型作用域(function-prototype scope)的唯一标识符是那些用在函数原型形参列表中的标识符。如前所述,函数原型的形参列表不需要形参名,只需要它们的类型。函数原型的形参列表中出现的名字会被编译器所忽略。用在函数原型中的标识符可以在程序中的任何地方无歧义地复用。
10 函数调用堆栈和活动记录
堆栈(stack)被称为是后进先出(last-in, first-out, LIFO)的数据结构(即相关数据元素的集合),也就是说,最后压到堆栈中的元素最先从堆栈中弹出。
函数调用堆栈
函数调用堆栈,有时也称为程序执行堆栈。这种数据结构(在“后台”工作)用于支持函数的调用和返回机制。堆栈也支持每个被调用函数的自动
变量的创建、维护和销毁。
堆栈结构
被调用的每个函数最终都必须把控制权返回给调用它的函数。因此,必须用某种方法记录每个函数把控制权返回给调用它的函数时所需要的返回地址。函数调用堆栈是处理这些信息的理想数据结构。每次当一个函数调用另一个函数时,一个数据项会压人到堆栈中。这个数据项称为一个堆栈结构(stack frame)或者一条活动记录(activation record),包含了被调用函数返回到调用函数所需的返回地址,还包含我们即将讨论的一些附加信息。如果被调用的函数返回,而不是在返回前调用其他函数。那么这个函数调用的堆栈结构就会弹出,并且控制权会转到该结构中的返回地址处。
自动变量和堆栈结构
堆栈结构还有另一个重要的责任。大多数函数都有自动变量–形参和函数中声明的任何局部变量。自动变量在函数执行时应当存在。如果函数调用其他函数,它们需要处于活动状态。但是当被调用函数返回到调用者时,被调用函数的自动变量需要“销毁”被调用函数的堆栈结构是保留被调用函数自动变量的理想地方。只要被调用函数还在执行,对应的堆栈结构就一直存在。当被调用函数返回,并且不再需要它的局部自动变量时,它的堆栈结构从堆栈中弹出,然后程序就不再知道这些局部自动变量了。
堆栈溢出
计算机的内存大小是有限的,因此只能有一定量的内存可用于保存函数调用堆栈的活动记录如果有太多的函数调用发生,以至于不能把相应的活动记录保存到函数调用堆栈中,那么就会发生致命的堆栈溢出错误。
11 内联函数
C++提供了内联函数(inline function)来减少函数调用的开销。在函数定义中把限定符inline
放在函数的返回类型的前面,可“建议”编译器在适当的时候在该函数被调用的每个地方生成函数体代码的副本,以避免函数调用。这往往会使程序变得较大。编译器可以忽略inline
限定符,并且除非对极小的函数,通常都会这样做。可复用的内联函数一般放在头文件中,这样的话,它们的定义可以被包含在使用它们的每一个源文件中。
对内联函数所做的任何修改,都要求该函数的所有客户重新进行编译。
编译器可以对那些没有显式地用inline
关键字的函数进行代码的内联。今天的优化编译器是如此的先进,所以程序员最好把是否内联的选择权交给编译器。
12 引用和引用形参
在许多编程语言中都有按值传递(pass-by-value)和按引用传递(pass-by-reference)这两种函数形参传递方式。
当实参用按值传递的方式传递时,会(在函数调用堆栈上)产生一份实参值的副本,然后将副本传递给被调用的函数。对于副本的修改不会影响调用函数中原始变量的值。这样可防止对正确可靠的软件系统的开发产生很大阻碍的副作用。但缺点是,如果有一个大的数据项需要传递,那么复制这些数据就需要花费大量的时间和内存空间。
引用形参
利用按引用传递,调用者使被调用函数可以直接访问调用者的数据,并且可以修改这些数据。从性能的角度按引用传递而言非常不错,因为它可以消除按值传递复制大量数据所产生的开销,但按引用传递可以削弱安全性,因为被调用函数可能破坏调用者的数据。
引用形参是函数调用中相应实参的别名。如:
void sqare(int &); //函数原型
void sqare(int &numberRef) //函数头部
{
//blabla...
}
为了传递大型对象,应使用一个常量引用形参来模拟按值传递的外观和安全性,并且避免传递大型对象的副本的开销。
在函数内引用作为别名
引用还可以在函数中用作其他变量的别名。
int count = 1;
int &cRef = count;
++cRef;
通过使用变量count
的别名cRef
来自增变量count
。引用变量必须在它们的声明中完成初始化并且不能再指定为其他变量的别名。一旦一个引用被声明为另一个变量的别名,在别名(即引用)上执行的所有操作实际上作用在原始变量上。别名只是简单地作为原始变量的另一个名字。除非是对常量的引用,否则引用实参必须是左值(例如,变量名),而不是常量或者右值表达式(例如计算结果)。
从函数返回引用
函数可以返回引用,但是这种方法可能存在危险。当返回一个在被调用函数中声明的变量的引用时,这个变量应该在函数中声明为static
。否则,该引用指的是在函数执行结束时被销毁的自动变量。试图访问这样的变量会产生不确定的行为。对一个未定义的变量的引用称为虚悬引用(dangling reference)。
13 默认实参
如果重复调用函数时对某个特定的形参一直采用相同的实参,在这种情况下,可以对这样的形参指定默认实参(default argument),即传递给该形参的一个默认值。当程序在函数调用中对干默具有认实参的形参省略了其对应的实参时,编译器会重写这个函数调用并目插入那个实参的默认值。
默认实参必须是函数形参列表中最靠右边(尾部)的实参。例如 int box(int length , int width = 1);
中的width
。
当调用具有两个或者更多个默认实参的函数时,如果省略的实参不是实参列表中最靠右边的实参,那么该实参右边的所有实参也必须被省略。例如int box(int length = 1, int width, int height = 1);
调用时box(10,5)
本想省略第二个实参,所以第二、三个实参都应被省略,即box(10)
。
默认实参应该在函数名第一次出现时指定,通常是在函数原型中。
显式地传递给函数的任何实参都按从左到右的顺序赋给函数的形参。
14 一元的作用域分辨运算符
局部变量和全局变量是有可能被声明为相同的名字的。C++提供了一元的作用域分辨运算符(::
),在同名的局部变量的作用域内,可以用来访问全局变量。不能使用一元的作用域分辨运算符访问外层语句块中具有相同名字的局部变量。例如:
#include <iostream>
using namespace std;
int number = 7; // global variable named number
int main()
{
double number = 10.5; // local variable named number
cout << "Local double value of number = " << number
<< "\nGlobal int value of number = " << ::number << endl;
}
15 函数重载
C++允许定义多个具有相同名字的函数,只要这些函数具有不同的函数签名。这种特性称为函数重载(function overloading)。当调用一个重载函数时,C++编译器通过检查函数调用中的实参数目、类型和顺序来选择恰当的函数。函数重载通常用于创建执行相似任务、但是作用于不同的数据类型的具有相同名字的多个函数。例如,数学库中的许多函数对于不同的数值类型是重载的。
#include<iostream>
using namepace std;
int square(int x)
{
cout << "square of integer " << x << " is ";
return x * x;
}
double square(double y) {
cout << "square of double " << y << " is ";
return y * y;
}
int main()
{
cout << square(7);
cout << endl;
cout << square(7.5);
cout << endl;
}
编译器如何区分重载的函数
重载的函数通过它们的签名来区分。签名由函数的名字和它的形参类型(按顺序)组成。编译器对每个函数的标识符利用它的形参类型进行编码(有时称为名字改编或名字装饰),以便能够实现类型安全的链接(type-saft linkage)。类型安全的链接保证调用正确的重载函数,并且保证实参的类型与形参的类型相符合。
不能改编main
函数,因为它不能被重载。
编译器只使用形参列表来区分重载的函数。
当重载具有默认形参的函数时要格外小心以免引起二义性。因为调用具有默认实参的函数时省略实参,其形式可能会与调用另一个重载的函数一样,这会产生编译错误。
16 函数模板
重载函数通常用于执行相似的操作,这些操作涉及作用于不司数据类型上的不同程序逻辑。如果对于每种数据类型程序逻辑和操作都是相同的,那么使用函数模板(function template)可以使重载执行起来更加紧凑和方便。程序员需要编写单个函数模板定义。只有在这个模板函数调用中提供了实参类型, C++就会自动生成独立的函数模板特化(function template specialization)来恰如其分地处理每种类型的调用。这样,定义一个函数模板实质上就定义了一整套重载的函数。
template <typename T>
T maximum(T value1, T value2, T value3)
{
T maximumValue = value1;
if (value2 > maximumValue)
{
maximumValue = value2;
}
if (value3 > maximumValue)
{
maximumValue = value3;
}
return maximumValue;
}
每个函数模板定义都以template
关键字开头,后跟一对尖括号括起来的模板形参列表,其中的每个形参都由关键字typename
或者class
开头。这里的T是基本类型或用户自定义类型的占位符。当编译器在程序源代码中检测到maximum
调用时,传递给maximum
的数据类型代替整个模板定义中的T,并且为了确定给定数据类型的三个值的最大值,C++会创建一个完整的函数。然后这个新创建的函数被编译。
#include <iostream>
#include "maximum.h"
using namespace std;
int main()
{
// demonstrate maximum with int values
int int1, int2, int3;
cout << "Input three integer values: ";
cin >> int1 >> int2 >> int3;
cout << "The maximum integer value is: " << maximum(int1, int2, int3)
<< endl;
// demonstrate maximum with double values
double double1, double2, double3;
cout << "\nInput three double values: ";
cin >> double1 >> double2 >> double3;
cout << "The maximum double value is: "
<< maximum(double1, double2, double3) << endl;
// demonstrate maximum with char values
char char1, char2, char3;
cout << "\nInput three characters: ";
cin >> char1 >> char2 >> char3;
cout << "The maximum character value is: "
<< maximum(char1, char2, char3) << endl;
}
C++11 —— 函数的尾随返回值类型
C++11的一个新特性是函数的尾随返回值类型(trailing return type)。为了指定尾随返回值类型,需要将关键字auto
放在函数名之前,且函数的形参列表之后加上->
以及返回值类型。例如:
template <typename T>
auto maximum(T value1, T value2, T value3) -> T
当构建更复杂的函数模板时,在很多情况下只能采用尾随返回值类型。
17 递归
递归函数是直接或者间接地(通过另一个函数)调用自己的函数。注意:在C++标准文档中规定,main函数在一个程序中不应当被其他函数调用或递归调用自身。它的唯一作用就是作为程序执行的起点。
调用递归函数是为了解决问题。这种函数实际上只知道如何解决最简单的情况,或者所谓的基本情况。如果函数为解决基本情况而调用,那么它将简单地返回一个结果。如果函数为解决较复杂的问题而调用,那么它通常会把问题分成两个概念性的部分:一部分是函数知道如何去做的,另一部分是函数不知道如何去做的。为了使递归可行,后一部分必须和原来的问题相类似,但是相对稍微简单一些或者稍微小一些。这个新问题看起来和原来的问题颇为相似,因此函数调用自己的一个全新副本用于解决这一个小的问题 —— 这就是递归调用,也称为递归步骤。递归步骤通常包括关键字return
,因为它的结果会与函数知道如何解决问题的一部分结合起来,从而形成可传递回原来的调用者,可能就是main函数的结果。
基本情况的遗漏或者不正确的递归步骤会造成递归无法收敛到基本情况,从而产生无限递归的错误,这通常会导致堆栈溢出。这类似于迭代(非递归)解决方法中的无限循环问题。
#include <iomanip>
#include <iostream>
using namespace std;
unsigned long factorial(unsigned long);
int main()
{
// calculate factorials of 0 through 10
for (unsigned int counter = 0; counter <= 10; ++counter)
{
cout << setw(2) << counter << "! = " << factorial(counter)
<< endl;
}
}
// recursive definition of function factorial
unsigned long factorial(unsigned long number)
{
// base case
if (number <= 1)
{
return 1;
}
else
{
return number * factorial(number - 1);
}
}
factorial
函数声明为接收unsigned long
类型的形参,并返回类型为unsigned long
的结果。C++标准文档规定unsigned long int
类型的变量至少要与int
类型一样大。通常,unsigned long int
类型的数据存储至少需要4字节(32位),因此,这种类型的变量的取值范围至少是在0
∼
\sim
∼ 4294967295之间(数据类型long int
也要至少4字节保存,取值的范围至少在-2147483648 $ \sim $ 2147483647之间)。阶乘的值很快变得很大。我们选择数据类型unsigned long
以便程序可以在具有小整数类型(例如2字节)的计算机上计算大于7!的阶乘。遗憾的是,factorial
函数很快就能产生非常大的值,unsigned long
也不能帮助我们计算超出其范围的很多阶乘值。
C++11中的新类型unsigned long long int
能在一些系统上用8字节(64位)存储数值。
数据类型为double
的变量可以用来计算比较大的数的阶乘。
18 递归应用:斐波那契数列
#include <iostream>
using namespace std;
unsigned long fibonacci(unsigned long);
int main()
{
// calculate fibonacci values of 0 through 10
for (int counter = 0; counter <= 10; ++counter)
{
cout << "fibonacci(" << counter << ") = " << fibonacci(counter)
<< endl;
}
// display higher fibonacci values
cout << "fibonacci(20) = " << fibonacci(20) << endl;
cout << "fibonacci(30) = " << fibonacci(30) << endl;
cout << "fibonacci(35) = " << fibonacci(35) << endl;
return 0;
}
// recursive function fibonacci
unsigned long fibonacci(unsigned long number) {
// base cases
if ((number == 0) || (number == 1)) {
return number;
} else {
return fibonacci(number - 1) + fibonacci(number - 2);
}
}
操作数的求值顺序
大部分程序员简单地假定操作数是按照从左到右的顺序求值的。C++语言没有指定大多数运算符(包括+
在内)其操作数的求值顺序。因此,程序员不应该对这些调用的执行顺序做出假定。事实上,这些调用可能先执行fibonacci(2)
,然后执行fibonacci(1)
,或者也可能反过来执行先执行fibonacci(l)
再执行fibonacci(2)
。在本程序以及大多数程序中,最后的结果都是相同的。但是,在某些程序中,操作数的求值顺序具有副作用(改变了数据的值),会影响表达式的最终结果。
C++语言只指定了4种运算符操作数的求值顺序,这4种运算符分别是&&
||
,
和 ?:
。前三个是二元运算符,它们的两个操作数是按从左到右的顺序进行求值的。最后一个运算符是C++中唯一的三元运算符,其最左边的操作数总是先被求值。如果最左边的操作数的求值结果为真(true),就接着求中间的操作数的值,最后的操作数被忽略;如果最左边的操作数的求值结果为假(false),接着就求第三个操作数的值,中间的操作数被忽略。
如果程序依赖于不包括&&
||
、,
和 ?:
在内的其他运算符的操作数的求值顺序,那么它们在使用不同编译器时会有不同的表现,因此可能导致逻辑错误。
19 迭代和递归
迭代和递归都是基于控制语句的:迭代使用循环结构,递归使用选择结构。
迭代和递归都涉及到循环:迭代显式地使用循环结构,递归通过重复的函数调用实现循环。
迭代和递归均包括终止条件测试:迭代在循环继续条件不满足时终止,递归在达到基本情况时终止。
采用计数器控制的循环的迭代和递归都是逐步达到终止的:迭代修改计数器直到计算器的值使循环条件不满足,递归产生比原来的问题更简单的问题直到达到基本情况。
迭代和递归都可能无限进行:如果循环继续测试一直都不变成假,则迭代会发生无限循环;如果递归步骤不能通过递归调用归结到基本情况,就会导致无限递归。
阶乘的迭代实现
#include <iomanip>
#include <iostream>
using namespace std;
unsigned long factorial(unsigned int);
int main()
{
for (unsigned int counter = 0; counter <= 10; ++counter)
{
cout << setw(2) << counter << "! = " << factorial(counter)
<< endl;
}
}
// iterative function factorial
unsigned long factorial(unsigned int number)
{
unsigned long result = 1;
// iterative factorial calculation
for (unsigned int i = number; i >= 1; --i)
{
result *= i;
}
return result;
}
递归的不足
递归有许多不足之处。它不断地进行函数调用,必然会增加很多开销。这样不仅消耗处理器的时间,而且还会消耗内存空间。每个递归调用都会创建函数变量的一份副本,这会占用相当可观的内存空间。而迭代通常发生在一个函数内,因此没有重复的函数调用的开销和额外的内存分配。
任何可以用递归解决的问题都可以用迭代(非递归)解决。如果使用递归方法能够更自然地反映问题,并且能够使程序更易于理解和调试,那么应选择递归方法而不是迭代方法。选择递归方法的另一个原因是如果没有想出迭代的方法。
在要求性能的情况下应避免使用递归。递归调用会消耗额外的时间和内存。