第十三章 函数(二)

13.1 函数的返回值

  13.1.1 函数的返回类型

  13.1.2 return 语句

  13.1.3 跟踪函数

13.2 函数的参数

  13.2.1 形参和实参

  13.2.2 参数的传递方式

    13.2.2.1 传值方式

    13.2.2.2 传址方式

  13.2.3 参数的传递过程(选修)

  13.2.4 参数默认值

13.3 函数重载

  13.3.1 重载的目的

  13.3.2 函数重载的规则

  13.3.3 参数默认值与函数重载的实例

13.4 inline 函数

  13.4.1 什么叫inline函数?

  13.4.2 inline函数的规则

13.5 函数的递归调用(选修)

  13.5.1 递归和递归的危险

  13.5.2 递归调用背后隐藏的循环流程

  13.5.3 参数在递归调用过程中的变化

  13.5.4 一个安全的递归调用函数实例

  13.5.5 递归函数的返回

13.6 小结

 

上一章我们讲了函数最基本的知识,即如何函数调用一个函数,和如何写一个函数。这一章我们的任务是:重点加深学习函数的返回值和函数的参数;另外我们还将选修函数的递归调用。

 

通过对这两个知识点的深化学习,我们对函数的理解会更深。

 

13.1 函数的返回值

有关函数的返回值,将涉及到函数的这些知识点:函数的类型,return,及如何得到函数的返回类型。

 

13.1.1 函数的返回类型

 

函数的类型,其实是函数返回值的类型。请看例子:

 

//实现两个整数相加的函数:

int AddTwoNum(int a,int b)

{

   return a + b;

}

 

上面标为红色的int即为函数 AddTwoNum的类型,普通的说法是“函数AddTwoNum的返回类型是整型”。也就是说函数AddTwoNum只能返回整型的值。我们看代码:

return a + b;

返回了a + b,其中a和b都是整型,二者相加也是整型。所以这个函数的返回类型正确。下面看一个错误的实例:

 

int AddTwoNum(float a,float b)

{

   return a + b;

}

 

尽管从逻辑上看,这段代码也没有错误,同样可以实现两个数相加,但我们认为它是有错的代码。因为函数AddTwoNum()的类型仍然规定为int类型,但函数体中的代码,却试图返回的却是float类型。为什么说返回的是float类型呢?因为请注意,现在a,b都是float类型了。

不仅这段代码有错,下面的代码也同样错误:

 

int AddTwoNum(int a, int b)

{

   float c = a + b;

   return c;

}

要注意,写类似上面的代码,编译器会放行,并不认为错误。那是因为编译器将一个float类型强制转换为int类型,这就会造成精度丢失。比如调用:AddTwoNum(1.2, 2.4),得到结果为3,而不3.6。

 

13.1.2 return 语句

return 语句只在函数内使用。它起到让函数停止运行,然后返回一个值的作用。我们通过一个特殊的对比,可以看到return的第一个作用:让函数停止运行.

代码一 代码二
  void OutputSomething()

{

   cout << "第1行" << endl;

   cout << "第2行" << endl;

   cout << "第3行" << endl;  

}

 

 

OutputSomething();

void OutputSomething()

{

   cout << "第1行" << endl;

   return;

   cout << "第2行" << endl;

   cout << "第3行" << endl;  

}

 

OutputSomething();

输出结果:

第1行

第2行

第3行

第1行

 

为什么代码二只输出了一行?原因正是因为代码中标成红色的return;当函数执行到return;后,函数就结束了。后面的代码等于白写。这里只是为了突出return的作用才故意这样写。

一个函数没有return;语句,也可以自然地结束,比如上面的代码一,当在屏幕上打印完第三行后,函数体内的代码也没了,所以函数自然就结束了,为什么还要return语句呢?

结合流程控制语句和return 语句,我们可以控制一个函数在合适的位置返回,并可返回合适的值。

下面的函数实现返回二数中的较大者:

 

int max(int a, int b)

{

   if(a > b)

      return a;

   return b;

}

 

这个函数有两个return;但并不是说它会返回两次。而是根据条件来执行不同的返回。执行以下面代码来调用上面的函数:

int c = max(10,7); 

得到的结果将是c等于10。

 

这个例子也演示了return 后面可以接一个表达式,然后将该表达式的值返回。

 

请大家想一想调用max(10,7)时,max函数中哪一行的return语句起作用了?想不出来也没关系,我们下一节将通过调试,看最终走的是哪一行。

 

关于return的最后几句话是:

1、有些函数确实可以不需要return,自然结束即可,如上面的OutputSomething();

2、有些人习惯为return的返回值加一对(),如: return (a); 这样写和 return a;完全一样。当然,在某些特殊的情况下,一对()是必要的。

3、一个函数是void类型时,return不能接返回,这时return仅起结束函数的作用。

4、记得return 接的是一个表达式,可以是一个立即数,一个变量,一个计算式,前面我们就看到 return a+b;的例子。 return 甚至也可以接一个函数。

 

13.1.3 跟踪函数

结合本小节的最后一个例子,我们来学习如何跟踪一个函数。同时我们也将直观地看到return的作用。

 

我们一直说F7,F8都是单步运行,下面的例子,二者的区别就表现出来了。

F7和F8都是单步跟踪,让代码一行行运行,但如果代码调用一个函数,那么,按F8将使调试器直接完成该函数的调用,而按下F7,调试器将进入该函数的内部代码。

 

例一:调试函数

 

新建一个控制台的工程,然后加入以下黑体部分的代码。

//---------------------------------------------------------------------------

#include <iostream.h>

#pragma hdrstop

//---------------------------------------------------------------------------

int max(int a, int b)

{

   if(a > b)

      return a;

   return b;

}

//---------------------------------------------------------------------------

#pragma argsused

int main(int argc, char* argv[])

{

    int c = max(10,7);

    cout << c << endl;

 

    getchar();

    return 0;

}

//---------------------------------------------------------------------------

并在图中所示行加上断点:

 

现在按F9运行,程序在断点处停下,然后请看准了F7键,按下,发现在我们跟进了max()函数:

现在继续按F7或F8键,程序走到:if(a > b) 这一行,然后请将鼠标分别移到该行的a和b上,稍停片刻出现的浮动提示将显示a或b的当前值,相信你会明白很多,至少,你能知道程序下一步何去何从。看一看你想对了没有,再按一次F8或F7:

 

现在程序即将运行return语句!再按一次F7或F8,再想一想程序将何去何从?

 

现在,你若是再说不明白 return 在函数内的作用,就有点过份了吧?

 

下面大家照我说的继续做两次实验:

第一,重做这个实验,但在第一次需要按F7处,改为按F8,看看事情起了什么变化?

第二,将代码中的: int c = max(10,7); 改为: int c = max(7,10); 然后重做这个实验,看看这回走的是哪一行的return 语句?

 

现在我们明白,在跟踪的过程,如果当前代码调用了一个函数,而你想进入这个函数跟踪,最直接的方法就是在调用函数的代码处按F7。

而关于F8与F7键所起的作用,名称为:F7单步进入();F8单步越过。它们分别对应于主菜单上 Run 下的 Trace Into 和 Step Over。如果当前行代码并没有调用函数,则二者的功能完全一样。以后我们讲到无区分的单步跟踪时,将只说按F8。

 

如果你需要频繁地跟踪某一函数,这种方法并不方便,你可以在设置断点,直接设置在那个函数的定义代码上。然后按F9,程序即可直接停在断点。比如,你可以将断点设置在这一行:int max(int a, int b)。

 

13.2 函数的参数

 

讲完函数的类型及返回值,我们来看函数另一重点及难点:参数。

不知上一章关于“修理工需要一台电视”的比喻,有没有在一定程度上帮助大家理解参数对于一个函数的作用。那时我们只是要大家从概念上明白:参数是调用者给出去的,被调用进接来过使用的数据,就像我们给修理工一台电视,供也修理一样。你只有明白了这点,才可以在下面继续学习参数的具体知识点;否则你应该复习上一章

 

13.2.1 形参和实参

 

隔壁小李开了家服装店,在往服装上贴价钱标签时,小李突发奇想:他决定直接往衣服上贴上和价钱相应的钞票!比如这条裤子值100元,小李就往贴一张百元大钞……

尽管我们无法否认小李是一个非常具有创意的人,但听说最近他一直在招保安,才10来平方的小店里,挤满了一堆保安,不干别的事,就盯着那些贴在衣服上的钱。

这当然只是一个笑话……

 

上一章我们说过,一个函数它需要什么类型的参数,需要多少参数,这都由函数本身决定。这就像一件商品需要多少钱,是由商人来决定,商人认为某件衣服需要100元,他就往上面贴个写着"¥100"价钱的标签,而一张真正百元钞票。

 

函数也一样,它需要什么样的参数?这需要在声明和定义时写好。

所以,所谓的形参就是:函数在声明或定义时,所写出的参数定义。

 

比如有这么一段代码:

 

 

由于形参只是形式上参数,所以在声明一个函数时,你甚至可以不写形参的变量名:

声明某个函数:

int max(int a, int b);

你完全可以这么写:

int max(int ,int );

 

 

现在再说实参:实际调用时,传给函数的实际参数,是为实参。请参看上图有关“调用”函数时的参数。

 

13.2.2 参数的传递方式

 

参数是调用函数的代码,传给函数的数据,在C,C++中,参数有两种传递方式:传值方式和传址方式。这两个名词分别指:传递“参数的值”和传递“参数的地址”。

 

13.2.2.1 传值方式

 

如果你是第一次学习程序语言,下面代码的执行结果可能让你出乎意料。

 

例二:传值方式演示

 

//定义函数F:

void F(int a)

{

   a = 10; //函数F只做一件事:让参数a等于10

}

 

int main(int argc, char* argv[])

{

   //初始化a值为0:  

   int a = 0;

 

   //调用函数F:

   F(a);

 

   //输出a的结果:

   cout << a << endl;

}

 

想一想,屏幕上打出的a的值是什么? 如果你猜“10”,呵呵,恭喜你猜了。猜错了是好事,它可以加深你对本节内容的记忆。

如果你不服,立即打开CB将上面代码付诸实际,然后查看结果,那就更是好事!

正确答案是:a打出来将是“0”。

 

代码不是明明将参数a传给函数F了吗?

而函数F不是明明让传来的a等于10了吗?

为什么打出的a却还是原来的值:0呢?

 

这就是C,C++传递参数的方法之一:值传递。它是程序中最常见的传递参数的方法。

 

传值方式:向函数传递参数时,先复制一份参数,然后才将复制品传给参数。这样,函数中所有对参数的操作,就只是在使用复制品。不会对改变传递前的参数本身。

 

我曾经说过(不是在课程说的),你要学习编程,可以没有任何编程基础,但你至少会要对普通的电脑的操作很熟练。是该考一考你的电脑操作水平了。

 

以下是某用户在电脑上的操作过程,请仔细阅读,然后回答问题。

 

操作一:用户在C盘上找一个文本文件;

操作二:用户使用鼠标右键拖动该文件到D盘,松开后,出现右键菜单,用户选择“复制到当前位置”,如图:

操作三:用户双击打开复制到D盘的该文件,进行编辑,最后存盘。

 

请问:C盘上的文件内容是否在该过程受到修改?

 

答案:不会,因为D盘文件仅是C盘文件的复制品,修改一个复制文件不会造成原文件也受到改动。

 

前面关于a值为什么不会变,道理和此相同:a被复制了一份。然后才传递给函数F();

 

请看参数传值方式的图解:

 

 

13.2.2.2 传址方式

即:传递参数地址

 

“地址”?是啊,我们第三次说到它。请大家先复习一下以前的内容:

第三章:3.4.1 内存地址

第五章:5.2 变量与内存地址

 

地址传递和值传递正好相反,这种方式可以将参数本身传给函数。从而,函数对参数的操作,将直接改变实参的值。

那么,如何才能指定让某个函数的某个参数采用“地址传送”的方式呢?方法很简单,只要在定义时函数时,在需要使用地址传送的参数名之前,加上符号:&。

如:

void F(int &a) //在形参a之前加一个 &

{

  a = 10;

}

 

笔者我更习惯于把 & 贴在参数的类型后面:

void F(int& a) //把&贴在类型之后,也可以

{

  a = 10;

}

 

两种书写格式在作用上没有区别。

 

现在让我们用一模一样的代码调用函数F:

 

例三:地址传递演示:

int main(int argc, char* argv[])

{

   //初始化a值为0:  

   int a = 0;

 

   //调用函数F:

   F(a);

 

   //输出a的结果:

   cout << a << endl;

}

 

输出结果,a的值真的被函数F()改为10了。

 

通过这个例子我们发现,C++中,函数的参数采用“值”或“地址”传递,区别仅仅在于函数的定义中,参数是否加了&符号。在调用函数时,代码没有任何区别。如此产生一个不便之处:我们仅仅通过看调用处的代码,将不好确定某一函数中有哪些参数使用地址传递?我们不得不回头找这个函数的定义或声明。C++很多地方被反对它的人指责,这是其一。C#语言改进了这一点,要求在调用时也明确指明调用方式。

比如,假设有一函数:

int foo(int a ,int &b, int c);

……

在代码某处调用该函数:

int i,j,k;

int r = foo(i,j,k);

 

如果你看不到前面函数的声明,那么你在读后面的代码时,可能比较难以想起其中的j是采用传址方式。

 

当然,我们没有必要因此就放弃C++这门强大的语言。如果的确需要让阅读代码的人知道某个地方采用了地址传送,可以加上注释,也可以使用我们以后将学的指针作为参数来解决就是。

 

关于地址传送方式的作用,及如何实现地址传送,我们已明白。剩下来需要弄明白的是,“地址传送”是如何实现的?

 

首先,为什么叫“地址”传送?如果你完成了前面指出的复习任务。那么你应该明白了变量与地址的关系,这里我从根本上重述一次:

 

程序中,数据存放在内存里;

内存按照一定规则被编号,这些号就称为内存地址,简称地址;

内存地址很长,所以高级语言实现了用变量代表内存地址;

所以,一个变量就是一个内存地址。

 

因此,这里“地址传送”中的“地址”,指的就是变量的地址。那么参数(实参)是变量吗?

 

参数可以是变量,也可以不是变量。我们先来说是的情况。比如前面的例子:

 

……

int a = 0;

F(a); //正确地调用函数F:参数a是一个变量

……

如果面上面例子中,我们直接传给F函数0,可以吗?

 

……

F(0); //错误地调用函数F:0是一个常数,不是变量。

……

 

错误原因是:因为函数F()的参数采用“地址传递”方式,所以它需要得到参数的地址,而0是一个常数,无法得到它地址。

得出第一个结论:在调用函数时,凡是采用“传址方式”的参数,不能将常数作为该参数。

 

如果你在程序中违返了这一规定,不要紧,编译器不会放过这一错误。下面让我们来理解为什么传递变量地址可以起到让函数修改参数。这也好有一比,我们再来考一次“电脑操作知识”。

 

以下是某用户在电脑上的操作过程,请仔细阅读,然后回答问题。

 

操作一:用户在C盘上找一个文本文件;

操作二:用户使用鼠标右键拖动该文件到D盘,松开后,出现右键菜单,用户选择“在当前位置创建方式”,如图:

操作三:用户双击打开在D盘创建的该快捷方式,然后进行编辑,最后存盘。

 

请问:C盘上的文件内容是否在该过程受到修改?

 

答案:C盘的文件并改变了,因为D盘上的快捷方式,正是C盘上文件的一个“引用”,双击该快捷方式,正是打开了C盘的文件。

 

“地址传递”类似于此,将地址传送给函数,函数对该地址的内容操作,相当于对实参本身的操作。

 

 

13.2.3 参数的传递过程(选修)

刚讲完“参数的传递方式”,又讲“参数的传递过程”,不禁让人有点发懵:方式和过程有何区别?中学时我对前桌的女生“有意思”,想给人家传递点信息,是往她家打个电话呢?还是来个“小纸条”?这就是“传递方式”的不同。我选择了后者。至于传递过程:刚开始时我把纸条裹在她的头发里,下课时假装关心地“喂,你的头发里掉了张纸……”。后来大家熟了,上课时我轻轻动一下她的后背,她就会不自在,然后在一个合适时机,自动把手别过来取走桌沿的纸条……这就是传递过程的不同吧?

(以上故事纯属虚构)

 

程序是在内存里运行的。所以无论参数以哪一种方式传递,都是在内存中“传来传去”。在一个程序运行时,程序会专门为参数开辟一个内存空间,称为“栈”。栈所在内存空间位于整个程序所占内存的顶部(为了直观,我们将地址较小的内存画在示意图顶部,如果依照内存地址由下而上递增规则,则栈区应该在底部),如图:

当程序需要传递参数时,将一个个参数“压入”栈区内存的底部,然后,函数再从栈区一个个读出参数。

如果一个函数需要返回值,那么调用者首先需要在栈区留出一个大小正好可以存储返回值的内存空间,然后再执行参数的入栈操作。

假设有一函数:

int AddTwoNum(int n1, int n2);

 

然后在代码某处调用:

....

int a = 1;

int b = 2;

 

int c = AddTwoNum(a,b);

 

当执行上面黑体部分,即调用函数的动作发生时,栈区出现下面的操作:

 

 

图中标明为返回值预留的空间大小是4个字节,当然不是每个函数都这个大小。它由函数返回值的数据类型决定,本函数AddTwoNum返回值是int类型,所以为4个字节。其它的a,b参数也是int类型,所以同样各占4字节大小的内存空间。

 

至于参数是a还是b先入栈,这依编译器而定,大都数编译器采用“从右到左的次序”将参数一个个压入。所以本示意图,参数b被先“压”入在底部,然后才是a。

 

这样就完成了参数的入栈过程。根据前面讲的不同“传递方式”,被实际压入栈的数据也就不同。

一、如果是“传值”,则栈中的a,b就是“复制品”,对二者的操作,仅仅是改变此处栈区的内存,和调用处的实参:a,b毫不关联:

二、而在“传址”方式时,编译器会将调用处的a,b的内存地址写入栈区,并且将函数中所有对该栈区内存的操作,都转向调用处a,b的内存地址。请看:

 

看起来二的图比一要复杂得多。其实实质的区别并不多。

你看:

 

实参a, 值为1, 内存地址为:00129980

实参b, 值为2, 内存地址为:00129984

 

在一图中,传给函数的是a,b的值,即1,2;

在二图中,传给函数的是a,b的地址,即:00129980,00129984。

 

这就是二者的本质区别。

 

“参数的传递过程”说到最后,还是和“参数的传递方式”纠缠在一起。我个人认为,在刚开始学习C++时,并不需要--或者甚至就是最好不要--去太纠缠语言内部实现的机制,而重在于运用。下面我们就来举一个使用“传址”方式的例子。

题目是:写一函数,实现将两个整型变量的值互换。

比如有变量:int a = 1, b =2;我们要求将它作为所写函数的参数,执行函数后,a,b值互换。即a为2,b为1。

 

交换两个变量的值,这也是一个经典题目。并且在实际运用中,使用得非常广泛。事实上很多算法都需要用到它。

幸好实现它也非常的简单和直观。典型的方法是使用“第三者”你可能感到不解:交换两个变量的值,就让这两个变量自个互换就得了,比如小明有个苹果,小光有个梨子,两人你给我给你就好了啊,要小兵来做什么?

呵,你看吧:

int a = 1, b = 2;

 

//不要“第三者”的交换(失败)

a = b;

b = a;

 

好好看看,好好想想吧。当执行交换的第一句:a = b;时,看去工作得不错,a的值确实由1变成了2。然后再下去呢?等轮到b想要得到a的值时,a现在和b其实相等,都是2,结果b=a;后,b的值还是2.没变。

 

只好让“第三者”插足了……反正程序没有婚姻法。

 

int a = 1, b = 2;

int c ; //“第三者”

 

//交换开始:

c = a;

a = b;

b = c;

 

好了,代码你自已琢磨吧。下面把这些代码写入函数,我命名为Swap;

 

例四:两数交换。

 

void Swap(int& a, int& b)

{

   int c = a;

   a = b;

   b = c;

}

 

int main(int argc, char* argv[])

{

 

   int a, b;

   cout << "请输入整数a:" ;

   cin >> a;

  

   cout << "请输入整数b":";

   cint >> b;

 

   cout << "交换之前,a = " << a << " b = " << b << endl;

 

   Swap(a,b);

   cout << "交换之后,a = " << a << " b = " << b << endl;

 

   getch(); //getchar会自动“吃”到我们输入b以后的回车符,所以改为getch(),记得前面有#include <conio.h>

 

   return 0;

}

 

完整程序请见下载的课程实例。

13.2.4 参数默认值

 

在手头的某本C++教材里,有关本节内容的第一句话是:“参数默认值也称默认参数值”。对着这话我愣了半天才算明白。所以在后面课程里,有些地方我说“参数默认值”有些地方我又会胡里胡涂说成“默认参数值”。你可不别像我一样去“研究”二者的区别呵。个人认为,从词法角度上看,“参数默认值”更准确些。

 

C++支持在定义或声明函数时,设置某些参数的默认值,这一点C不允许。

 

比如我们为卖萝卜的大娘的写一个计价函数。这个函数需要三个参数:顾客交多钱?买多少斤萝卜?及萝卜的单价。返回值则是大娘应该找多少钱。例如,顾客交了100元,他买5斤萝卜,单价是1.00元/斤。那么函数就会计算并返回95,表示应该找给顾客95元钱。

 

//函数定义如下:

float GiveChange(float money, float count, float price)

{

   return money - count * price; //找钱 = 已付款 - 数量 * 单价

}

 

当我们在程序中需要使用该函数时,我们大致是这样调用:

 

float change = GiveChange(100,5,1);

 

看上去一切很完美--确实也很完美。不过C++允许我们锦上添花。并且不是一朵只为了好看的“花”。

现实情况是,萝卜的价钱是一个比较稳定的数--当然并不是不会变,在时出现亚洲经济风暴,萝卜价还是发变--总之是会变,但很少变。

碰上这种情况,我们每回在调用函数时都写上最后一个参数,就有些亏了,这时,我们可以使用“参数的默认值”。

 

//首先,函数的定义做一点改动:

float GiveChage(float money, float count, float price = 1.0)

{

   .....

}

 

看到变化了吗?并不是指函数体内我打了省略号,而是在函数参数列表中,最后一个参数的定义变为:float price = 1.0。这就默认参数值,我们指定价格默认为1元。

 

然后如何使用呢?

以后在代码中,当需要计算找钱时,如果价钱没有变,我们就可以这样调用:

 

float change = GiveChange(100,5); //没有传递最后一个参数。

 

是的,我没有写最后一参数:价钱是多少?但编译器发现这一点时,会自动为我填上默认的1.0。

如果在代码的个别地方,大娘想改一改价钱,比如某天笔者成了顾客,大娘决定按1斤2毛钱把萝卜卖给我:

我给大娘5毛钱,买2斤:

float changeForBCBSchool = GiveChange(0.5, 2 ,0.2); //你一样可以继续带参数

 

我想,这个实例很直观,但必须承认这个例子并没有体现出参数默认值的种种优点。不过不管如何,你现在应该对参数的默认值有感性认识。

 

下面学习有关参数默认值的具体规定。

 

1、必须从最右边开始,然后连续地设置默认值。

 

如果理解这句话?

首先我们看关键词“最右边”。也就是说假如一个函数有多个参数,那么你必须从最后一个参数开始设置默认值。

如:

void foo(int a, int b, bool c);

 

那么,下面的设置是正确的:

void foo(int a, int b, bool c = false); //ok,c是最后一个参数

//而,下面是错误的:

void foo(int a, int b = 0, bool c);    //fail,b不是最后一参数

 

然后我们看“连续”。也就是说,从最右边开始,你可以连续地向左设置多个参数的默认值,而不能跳过其中几个:

如:

 

下面的的设置是正确的:

void foo(int a, int b=0, bool c = false); //ok ,连续地设置c,b的默认值

同样,这也是正确的:

void foo(int a=100, int b=0, bool c = false); //ok ,连续地设置c,b,a的默认值

//而,这样设置是错误的:

void foo(int a=100, int b, bool c = false); //fale,不行,你不能跳过中间的b。

 

2、如果在函数的声明里设置了参数默认值,那么就不参在函数的定义中再次设置默认值。

 

函数的“声明”和“定义”你可能又有些胡涂了。好,就趁此再复习一次。

 

所谓的“定义”,也称为“实现”,它是函数完整的代码,如:

//函数定义如下(函数定义也称函数的实现):

float GiveChange(float money, float count, float price)

{

   return money - count * price; //找钱 = 已付款 - 数量 * 单价

}

 

而函数的“声明”,则是我们上一章不断在说的函数的“名片”,它用于列出函数的格式,函数的声明包含函数的“返回类型,函数名,参数列表”,惟一和函数定义不一样的,就是它没有实现部分,而是直接以一分号结束,如:

//声明一个函数:

float GiveChange(float money, float count, float price); //<---注意,直接以分号结束。

 

现在和参数默认值有关的是,如果你在函数声明里设置了默认值,那就不用,也不能在函数定义处再设置一次。

如,下面代码正确:

----------------------------------------

//定义:

float GiveChange(float money, float count, float price)

{

   return money - count * price; //找钱 = 已付款 - 数量 * 单价

}

 

//声明:

float GiveChange(float money, float count, float price = 1.0);

----------------------------------------

而下面的代码有误:

//定义:

float GiveChange(float money, float count, float price = 1.0)

{

   return money - count * price; //找钱 = 已付款 - 数量 * 单价

}

 

//声明:

float GiveChange(float money, float count, float price = 1.0);

----------------------------------------

 

3、默认值可以最常见的常数,或全局变量,全局常量,甚至可以是一个函数的调用。

关于题中的“全局”,我们还没有学习,这时理解就是在程序运行区别稳定存在的变量或常量。下面举一个让我们比较狐疑的,使用函数作来参数默认的例子:

 

//某个返回double的函数:

double  func1();

double  func2(double a, double b = func1()); //func1()的执行结果将被用做b的默认值。

 

13.3 函数重载

重,重复也。载者,承载也。

“重复”一词不用解释,“承载”不妨说白一点,认为就是“承负”。

函数的“重载”,意为可以对多个功能类似的函数使用相同的函数名。

13.3.1 重载的目的

有这个需要吗?不同的函数取相同的名字?这不会造成混乱?在现实生活中,我们可一点也不喜欢身边有哪两个人同名。

当然有这个必要。“函数名重载”是C++对C的一种改进(因此C也不支持重载)。

 

想一想那个求“二数较大者”的max函数吧。如果不支持函数名重载,那么就会有以下不便:

 

int max(int a, int b);

这是前面我们写的,用以实现两数中较大者的函数。比如你传给它20,21,那么,它将很好地工作,返回21。现在,我们想求 20.5,和21.7 两个实数中较大者?对不起,max函数要求参数必须为int类型,所以传给它20.5,21.7:

float larger = max(20.5,21.7);

编译器不会让这行代码通过。它会报错说“参数不匹配”。

 

好吧,我们只好为实数类型的比较也写一个参数,但C语言不允函数重名,所以我们只好另起一个名字:

float maxf(float a, float b);

 

你可能会就,那就不要int版的max,只要这个float版本的:

float max(float a, float b);

因为,实数版本的完全可以处理整数。说得没错,但这不是一个好办法,其一我们已知道实数和整数相比,它有计算慢,占用空间大的毛病;其二,float版本的max函数,其返回值必然也是float类型,如果你用它来比较两个整数:

int larger = max(1, 2);

编译器将不断警告你,“你把一个float类型的值赋值一个int类型的变量”。编译器这是好心,它担心你丢失精度,但这会让我们很烦,我们不得不用强制类型转换来屏蔽这条警告消息:

int larger = (int) max(1,2);

这样的代码的确不是好代码。

 

好吧,就算你能容忍这一切,下一问题是,我想写了一个求3个整数谁最大的函数。这回你没有理由因为要写三个参数的版本,就把两个参数的版本扔了。只好还是换名:

int max_3(int a, int b, int c);

 

看着 max_3这个函数名字,我不禁想起前几天在yahoo申请免费电子信箱,我想叫 nanyu@yahoo.com.cn ,它却坚持建议我改为:nanyu1794@yahoo.com.cn (1794?一去就死?),折腾了我两个半小时,我才找到一个可以不带一串数字,又让我能接受点的呢称。

结论是:不允许重名的世界真的有些烦。C++看到了这一点,所以,它允许函数在某些条件的限制下重名。这就是函数重载。

 

前面有关max()的问题,现在可以这样解决:

 

//整数版的max()

int max(int a, int b);

 

//单精度实数版的max()

float max(float a, float b);

 

//双精度实数版的max();

double max(double a, double b);

 

//甚至,如果你真的有这个需要,你还可以来一个这种版本的max();

double max(int a, double b);

 

//接下来是三个参数的版本:

int max(int a, int b, int c);

double max(double a, double b, double c);

 

上面林林总总的求最大值函数,名字都叫max();好处显而易见:对于实现同一类功能的函数,只记一个名字,总比要记一堆名字要来得舒服。

13.3.2 函数重载的规则

有一个问题,那么多max函数,当我们要调用其中某一个时,编译器能知道我们到底在调用哪一个吗?

如何让编译器区分出我们代码中所调用的函数是哪一个max,这需要有两个规则。

 

实现函数重载的规则一:同名函数的参数必须不同,不同之处可以是参数的类型参数的个数

 

如果你写想两个同名函数:

错误一:

int max(int a, int b);

int max(int c, int d);

 

看上去这两个函数有些不同,但别忘了,形参只是形式,事实上两个声明都可以写成:

void max(int, int);

所以记住:仅仅参数名不一样,不能重载函数。

 

错误二:

float max(int a, int b);

int max(int a, int b);

两个函数不同之处在返回类型,对不起,C++没有实现通过返回值类型的不同而区分同名函数的功能。

所以记住:仅仅返回值不一样,不能重载函数。

 

正因为函数的重载机制和函数的参数息息相关,所以我们才把它紧放在“函数参数”后面。但函数重载并不能因此就归属于“参数”的变化之一,以后我们会学习不依赖于参数的重载机制。

 

实现函数重载的规则二:参数类型的匹配程度,决定使用哪一个同名函数的次序。

 

若有这三个重载函数:

1)int max(int a, int b);

2)float max(float a, int b);

3)double max(double a, double b);

 

现在我这样调用:

int larger = max(1, 2);

被调用的将是第1)个函数。因为参数1,2是int类型。

 

而:

double larger = max(1.0, 2);

被调用的将是第……注意了!是第3)个函数。为什么?

首先它不能是第1)个,因为虽然参数2是int类型,但1.0却不是int类型,如果匹配第1)函数,编译器认为会有丢失精度之危险。

然后,你可能忘了,一个带小数的常数,例如1.0,在编译器里,默认为比较保险的double类型(编译器总是害怕丢失精度)。

 

最后,关于这两个规则,都是在同名的函数参数个数也相同的情况下需要考虑,如果参数个数不一样:

int max(int a, int b);

int max(int a, int b ,int c);

当然就没有什么好限制了,编译器不会傻到连两个和三个都区分不出,除非……

 

实现函数重载的附加规则:有时候你必须附加考虑参数的默认值对函数重载的影响。

 

比如:

int max(int a, int b);

int max(int a, int b ,int c = 0);

 

此例中,函数重载将失败,因为你在第二个max函数中设置了一个有默认值的参数,这将造成编译器对下面的代码到底调用了哪一个max感到迷惑。不要骂编译器笨,你自已说吧,该调用哪个?

int c = max(1, 2);

 

没法断定。所以你应该理解、接受、牢记这条附加规则。

 

事实上影响函数重载的还有其它规则,但我们学习这些就够了。

13.3.3 参数默认值与函数重载的实例

例五:参数默认值、函数重载的实例

 

有关默认值和函数重载的例子,前面都已讲得很多。这里的实例仅为了方便大家学习。请用CB打开下载的配套例子工程。所用的就是上面提到例子,希望大家自已动手分别写一个默认值和重载的例子。

 

13.4 inline 函数

从某种角度上讲,inline对程序影响几乎可以当成是一种编译选项(事实上它也可以由编译选项实现)。

13.4.1 什么叫inline函数?

inline(小心,不是online),翻译成“内联”或“内嵌”。意指:当编译器发现某段代码在调用一个内联函数时,它不是去调用该函数,而是将该函数的代码,整段插入到当前位置。

这样做的好处是省去了调用的过程,加快程序运行速度。(函数的调用过程,由于有前面所说的参数入栈等操作,所以总要多占用一些时间)。

这样做的不好处:由于每当代码调用到内联函数,就需要在调用处直接插入一段该函数的代码,所以程序的体积将增大。

拿生活现象比喻,就像电视坏了,通过电话找修理工来,你会嫌慢,于是干脆在家里养了一个修理工。这样当然是快了,不过,修理工住在你家可就要占地儿了。

(某勤奋好学之大款看到这段教程,沉思片刻,转头对床上的“二奶”说:

“终于明白你和街上‘鸡’的区别了”。

“什么区别?”

“你是内联型。”)

 

内联函数并不是必须的,它只是为了提高速度而进行的一种修饰。要修饰一个函数为内联型,使用如下格式:

 

inline 函数的声明或定义

 

简单一句话,在函数声明或定义前加一个 inline 修饰符。

 

inline int max(int a, int b)

{

  return (a>b)? a : b;

}

 

13.4.2 inline函数的规则

规则一、一个函数可以自已调用自已,称为递归调用(后面讲到),含有递归调用的函数不能设置为inline;

规则二、使用了复杂流程控制语句:循环语句和switch语句,无法设置为inline;

规则三、由于inline增加体积的特性,所以建议inline函数内的代码应很短小。最好不超过5行。

规则四、inline仅做为一种“请求”,特定的情况下,编译器将不理会inline关键字,而强制让函数成为普通函数。出现这种情况,编译器会给出警告消息。

规则五、在你调用一个内联函数之前,这个函数一定要在之前有声明或已定义为inline,如果在前面声明为普通函数,而在调用代码后面才定义为一个inline函数,程序可以通过编译,但该函数没有实现inline。

比如下面代码片段:

 

//函数一开始没有被声明为inline:

void foo();

 

//然后就有代码调用它:

foo();

 

//在调用后才有定义函数为inline:

inline void foo()

{

  ......

}

 

代码是的foo()函数最终没有实现inline;

 

规则六、为了调试方便,在程序处于调试阶段时,所有内联函数都不被实现。

 

最后是笔者的一点“建议”:如果你真的发觉你的程序跑得很慢了,99.9%的原因在于你不合理甚至是错误的设计,而和你用不用inline无关。所以,其实,inline根本不是本章的重点。

 

所以,有关inline 还会带来的一些其它困扰,我决定先不说了。

 

13.5 函数的递归调用(选修)

第4次从洗手间里走出来。在一周前拟写有关函数的章节时,我就将递归调用的内容放到了最后。

 

函数递归调用很重要,但它确实不适于初学者在刚刚接触函数的时候学习。

13.5.1 递归和递归的危险

递归调用是解决某类特殊问题的好方法。但在现实生活中很难找到类似的比照。有一个广为流传的故事,倒是可以看出点“递归”的样子。

“从前有座山,山里有座庙,庙里有个老和尚,老和尚对小和尚说故事:从前有座山……”。

在讲述故事的过程中,又嵌套讲述了故事本身。这是上面那个故事的好玩之处。

 

一个函数可以直接或间接地调用自已,这就叫做“递归调用”。

C,C++语言不允许在函数的内部定义一个子函数,即它无法从函数的结构上实现嵌套,而递归调用的实际上是一种嵌套调用的过程,所以C,C++并不是实现递归调用的最好语言。但只要我们合理运用,C,C++还是很容易实现递归调用这一语言特性。

 

先看一个最直接的递归调用:

 

有一函数F();

 

void F()

{

  F();

}

 

这个函数和“老和尚讲故事”是否很象?在函数F()内,又调用了函数F()。

这样会造成什么结果?当然也和那个故事一样,没完没了。所以上面的代码是一段“必死”的程序。不信你把电脑上该存盘的存盘了,然后建个控制台工程,填入那段代码,在主函数main()里调用F()。看看结果会怎样?WinNT,2k,XP可能好点,98,ME就不好说了……反正我不负责。出于“燃烧自己,照亮别人”的理念,我在自已的XP+CB6上试了一把,下面是先后出现的两个报错框:

 

 

这是CB6的调试器“侦察”到有重大错误将要发生,提前出来的一个警告。我点OK,然后无厌无悔地再按下一次F9,程序出现真正的报错框:

 

 

这是程序抛出的一个异常,EStackOverflow这么看:E字母表示这是一个错误(Error),Stack正是我们前面讲函数调用过程的“栈”,Overflow意为“溢出”。整个 StasckOverflow 意思就:栈溢出啦!

“栈溢出”是什么意思你不懂?拿个杯子往里倒水,一直倒,直到杯子满了还倒,水就会从杯子里溢出了。栈是用来往里“压入”函数的参数或返回值的,当你无限次地,一层嵌套一层地调用函数时,栈内存空间就会不够用,于是发生“栈溢出”。

(必须解释一下,本例中,void F()函数既没有返回值也没有参数,为什么还会发生栈溢出?事实上,调用函数时,需要压入栈中的,不仅仅是二者,还有某些寄存器的值,在术语称为“现场保护”。正因为C,C++使用了在调用时将一些关键数值“压入”栈,以后再“弹出”栈来实现函数调用,所以C,C++语言能够实现递归。)

 

这就是我们学习递归函数时,第一个要学会的知识:

 

逻辑上无法自动停止的递归调用,将引起程序死循环,并且,很快造成栈溢出。

 

怎样才能让程序在逻辑上实现递归的自动停止呢?这除了要使用到我们前面辛辛苦苦学习的流程控制语句以后,还要掌握递归调用所引起的流程变化。

 

13.5.2 递归调用背后隐藏的循环流程

 

递归引起什么流程变化?前面的黑体字已经给出答案:“循环”。自已调用自已,当然就是一个循环,并且如果不辅于我们前面所学的if...语句来控制什么时候可以继续调用自身,什么时候必须结束,那么这个循环就一定是一个死循环。

如图:

 

 

递归调用还可间接形成:比如 A() 调用 B(); B() 又调用 A(); 虽然复杂点,但实质上仍是一个循环流程:

 

 

在这个循环之里,函数之间的调用都是系统实现,因此要想“打断”这个循环,我们只有一处“要害”可以下手:在调用会引起递归的函数之前,做一个条件分支判断,如果条件不成立,则不调用该函数。图中以红点表示。

 

现在你明白了吗?一个合理的递归函数,一定是一个逻辑上类似于这样的函数定义:

 

void F()

{

  ……

  if(……)  //先判断某个条件是否成立

  {

     F(); //然后才调用自身

  }

 ……

}

 

在武侠小说里,知道了敌人的“要害”,就几乎掌握了必胜的机会;然而,“递归调用”并不是我们的敌人。我们不是要“除掉”它,相反我们利用它。所以尽管我们知道了它的要害,事情还要解决。更重要的是要知道:什么时候该打断它的循环?什么时候让它继续循环?

这当然和具体要解决问题有关。所以这一项能力有赖于大家以后自已在解决问题不断成长。就像我们前面的讲的流程控制,就那么几章,但大家今后却要拿它们在程序里解决无数的问题。

(有些同学开始合上课本准备下课)程序的各种流程最终目的是要合适地处理数据,而中间数据的变化又将影响流程的走向。在函数的递归调用过程中,最最重要的数据变化,就是参数。因此,大多数递归函数,最终依靠参数的变化来决定是否继续。(另外一个依靠是改变函数外的变量)。

所以我们必要彻底明了参数在递归调用的过程中如何变化。

13.5.3 参数在递归调用过程中的变化

我们将通过一个模拟过程来观察参数的变化。

 

这里是一个递归函数:

 

void F(int a)

{

  F(a+1);

}

 

和前面例子有些重要区别,函数F()带了一个参数,并且,在函数体内调用自身时,我们传给它当前参数加1的值,作为新的参数

红色部分的话你不能简单看过,要看懂。

 

现在,假设我们在代码中以1为初始参数,第一次调用F():

 

F(1);

 

现在,参数是1,依照我们前面“参数传递过程”的知识,我们知道1被“压入”栈,如图:

F()被第1次调用后,马上它就调用了自身,但这时的参数是 a+1,a就是原参数值,为1,所以新参数值应为2。随着F函数的第二次调用,新参数值也被入栈:

再往下模拟过程一致。第三次调用F()时,参数变成3,依然被压入栈,然后是第四次……递归背后的循环在一次次地继续,而参数a则在一遍遍的循环中不断变化。

由于本函数仍然没有做结束递归调用的判断,所以最后的最后:栈溢出。

 

要对这个函数加入结束递归调用的逻辑判断是非常容易的。假设我们要求参数变到10(不含10)时,就结束,那么代码如:

 

void F(int a)

{

   if( a < 10)

     F(a+1);

}

 

终于有了一个安全的递归调用例子了。不过它似乎什么也没有做,我们加一句输出代码,然后让它做我们有关递归的第一个实例吧。

13.5.4 一个安全的递归调用函数实例

例六:用递归实现连续输出整数1到9。

 

//递归调用的函数:

void F(int a)

{

  if( a < 10)

  {

    cout << a;

    F(a+1);

  }

}

 

//然后这样调用:

 

F(1);

 

完整的代码请见下载的相应例子。输出将是:

 

123456789

 

请大家自行模拟本题函数的调用过程。

 

13.5.5 递归函数的返回

 

这里并不是要讲递归函数的返回值。

 

天气还不是很冷,你能把身上的衣服脱光一下吗?当初你穿衣服时,一定是先穿上最里层的衣服,然后穿上第二层的,再穿上第三层。现在让你脱衣服,你就得先脱外层,再脱稍里一层,然后才是最内层。

 

函数的递归调用,和穿衣脱衣类似,不过内外相反而已。开始调用时,它是外层调内层,内层调更内一层。等到最内层由于条件不允许,必须结束了,这下可好,最内层结束了,它就会回到稍外一层,稍外一层再结束时,退到再稍外一层,层层退出,直到最外层结束。

 

如果用调用折线图来表示前例,则为:

 

本小节不是讲递归函数的返回值,而是讲递归函数的返回次序。前面听说要脱衣服而跑掉或跑来的同学,可以各回原位了。

 

做为本小节的一个例子,我只给实例的代码,请大家考虑会是什么输出结果。考虑并不单指托着腮做思考状(你以为你是大卫?)。另外,我相信有很多同学有小聪明,他们凭感觉就可以猜出结果。聪明很好,但千万别因为聪明而在不知不觉中失去动手调试程序的动力。

 

代码其实只是在上例中再加上一行。

 

例七:递归函数的返回次序:

 

//递归调用的函数:

void F(int a)

{

  if( a < 10)

  {

    cout << a;

    F(a+1);

    cout << a;

  }

}

 

//然后这样调用:

F(1);

 

完整代码见下载的例子。

 

13.6 小结

我们讲了函数是如何通过return 返回一个值,这个的值的类型就是函数类型。

讲完return之后,我们还“深入函数”内部进行跟踪,其实就是一个按F7还是F8的问题。

 

关于参数,我们讲了什么叫形参,函数在声明或定义处的参数,称为形参,它实际上位于栈内的某个内存地址。至于实参,就是调用时函数时所用的参数,它不在栈内存区里。

一句话:一个参数在未被“压入”栈内,就称为实参,并“压入”栈内了,就成了形参。

呵,如果你刚才没有选修“参数的传递过程”这一节,可能现在有些后悔了。

 

C++支持的设置参数默认值比较好玩,大家在实际编程中可能会常用。你知道“从右到左,连续”这两个词和默认值的关系吧。

 

函数的重载就更有意思了,不过记住了,只对“功能类似”的函数进行重载吧,把一些功能互异的函数全叫成同名,那么效果就适得其反。另外,由于重载规则的限制,有时候与其在为排列同名函数的参数表头痛,不如还是恢复老办法,直接另取个函数名就是。学了一项技术,千万不要有非要用上的想法。是改变名字还是改变参数,怎样方便怎样来。

 

inline 函数?估计很多同学什么也没记住,只记了关于“大款”的讲话,哎,失败的教育。

 

最后是函数的递归调用,没什么好说的。我认为学习编程很重要的一点是学会看别人的代码(当然,那个人应该水平比你高)。大家自已尝试一下写几个安全的递归调用,然后有机会看别人如何用递归解决实际问题。至于本章的课程,希望你常回头看。

 

后面的课程,将不断地用到函数了。大家多动手写一些有关这两章的练习程序。