你好,C++(24)好大一个箱子!5.1.1 函数的声明和定义
第5章 用函数封装程序功能
在完成功能强大的工资程序V1.0之后,我们信心倍增,开始向C++世界的更深远处探索。
现在,我们可以用各种数据类型定义变量来表达问题中所涉及的各种数据;用操作符连接这些变量对其进行运算;用程序流程控制结构来控制对这些数据的复杂处理过程,最终实现对数据进行处理得到结果,而这就是程序了。但是,随着要处理的问题越来越复杂,程序的代码自然也就会越来越复杂。如果把所有程序代码都放到main()主函数中,主函数也会越来越复杂。这就像将所有东西都堆放到一个仓库中,随着东西越堆越多,仓库慢慢就被各种东西堆满了,显得杂乱无章,管理起来非常困难。面对一个杂乱无章的仓库,聪明的仓库管理员提供了一个很好的办法来进行管理:将东西分门别类地装进箱子,然后在箱子上贴上标签,通过这些箱子来对其进行管理。
这个好方法也可以用到程序设计中,把复杂的程序代码按照功能不同装进不同的箱子,也同样可以让整个程序结构清晰,更易于开发和维护。仓库中用的箱子是木箱,而程序中用到的箱子,就是我们下面要介绍的“函数”。
5.1 函数就是一个大“箱子”
在程序越来越复杂的时候,我们可以根据“分而治之”的原则,按照功能的不同将复杂的程序进行模块划分,完成相同功能的代码被划分到同一个模块,最终形成一个函数。就像管理一个仓库,我们总是将同类的东西放到同一个箱子中,然后通过管理这些箱子来管理整个仓库中的物件。而在程序设计中,我们同样也将那些相对独立的完成某一特定功能的代码放到一起而成为一个函数,然后通过这些函数的组合来完成一个比较大的功能。
举一个简单的例子:看书看得肚子饿了,我们想要泡方便面吃。这其实是一个很复杂的过程,我们先要洗锅,然后烧水,水烧开后再泡面,吃完面后还要洗碗。如果把整个过程都表达在主函数中,那么主函数会非常复杂,结构也会非常混乱。这时就可以将整个过程划分为多个小步骤,然后把每一个小步骤用一个独立的函数来表达,最后在主函数中通过组织调用这些函数来完成这个复杂过程,这样可以使得程序的主函数变得简单明了,结构也非常清晰。如图5-1所示。
使用函数封装功能的另外一个重要优势是,函数可以被不同的模块重复多次调用,从而实现代码的复用。这就像一个箱子既可以放在这个仓库,也可以放在另外一个仓库。例如,泡面可以调用烧水函数,同样煮饭也可以调用烧水函数,这样只需要一个烧水函数,就可以满足泡面和煮饭两个过程对烧水功能的需要,既省时又省力。既然函数有这么多的好处,那我们下面就来看看到底如何定义和使用函数。
图5-1 将程序封装到箱子,分而治之
5.1.1 函数的声明和定义
要想使用变量来表达程序中的数据,我们必须先声明和定义变量,然后才能使用。同样的,要想使用函数来表达程序中的计算过程,我们同样也需要先声明和定义函数。在C++中,声明一个函数的语法格式如下:
返回值类型标识符 函数名(形式参数表);
例如,下面的代码就声明了一个Add()函数,用来计算两个整数的和:
int Add(int a, int b);
对照声明函数的语法格式,下面具体来看看这个函数声明中的各个部分。
1. 返回值类型标识符
函数在执行完毕后,往往需要给它的调用者返回一个数据,表示函数执行的结果或者其他意义。函数的返回值类型标识符就是这个返回数据的数据类型。比如上面的Add()函数,在执行完毕后需要向它的调用者返回加和结果数据,而这个结果数据的类型是int,所以我们在声明中就指定其返回值类型为int,表示该函数在完成加法计算后,将向它的调用者返回一个int类型的数值,而这个值就是Add()函数加法计算的结果。所以我们通常利用这种方式来获得函数的执行结果数据。而如果函数只是执行一些动作,无须返回结果数据,则可以使用“void”作为返回值的类型,表示这个函数没有返回数据。
2. 函数名
函数名就是为了标识一个函数而取的名字,就像给箱子贴上的标签一样,我们可以通过标签找到箱子,也可以通过函数名调用这个函数执行其中的代码。函数的命名规则跟变量的命名规则相同。如果说变量命名重在说明这个变量“是什么”,那么函数的命名则重在说明这个函数要“做什么”,所以从这个意义上说函数名往往是一个动词或动名词。例如,在上面的例子中,函数要完成的是两个数的加法运算,执行的是“加”这个动作,所以我们就将这个函数命名为Add(加)。
3. 形式参数表
在调用函数的时候,往往要进行函数间的数据交换,向函数传入或者从函数传出一些数据。函数的参数就是用来进行数据交换的,而形式参数表是对函数参数的描述,它主要描述了参数的个数、具体的数据类型和参数名。其语法格式如下:
数据类型1 参数名1, 数据类型2 参数名2…
在上面的Add()加法函数声明中,函数名之后括号内的“int a, int b”就是其形式参数表,它表示这个函数一共有两个int类型的参数,参数名分别是a和b。Add()函数的形式参数表之所以要设计成这样,是因为这个函数需要两个int类型的数据作为被加数,所以为了向函数内传递所需的数据,形式参数表中就有了两个int类型的参数,又为了加以区别,所以分别命名为a和b。在使用函数的形式参数表时,有以下几个需要注意的地方。
(1) 形式参数要有明确的数据类型。
函数参数的定义与定义变量类似,总是先写参数的数据类型,然后写参数的名字。如上面例子中的“int a”,因为要向函数内传递一个int类型的数据,所以就用它做相应参数的数据类型,a是参数的名字。要向函数传递多个数据时,可以在形式参数表中定义多个参数,各个参数之间用逗号间隔。例如,上面例子中的“int a,int b”就定义了两个参数a和b。在形式参数表中,每个参数必须有明确的数据类型说明。即使两个参数的数据类型相同,也不能使用同一个数据类型说明符定义多个多个参数。例如,在上面的例子中,虽然参数a和b的数据类型相同,但是形式参数表不能写成“int a, b”,这一点跟定义变量是有差别的。
最佳实践:用const对参数进行修饰,防止参数被意外修改
跟我们在定义一些其值固定不变的变量时,使用const关键字对其进行修饰,可以防止其值被错误修改一样,如果某个参数的值在整个函数执行过程中是固定不变的,比如那些只是负责向函数内部传入数据的参数,我们也同样可以使用const关键字对其进行修饰,这样可以防止这个参数在函数执行过程中被意外地修改,从而避免错误的发生。例如:
// 用const关键字保护参数值不被修改 int Add(const int a, const int b) { // 错误:尝试修改使用const修饰的参数 a = 1982; b = 1003; return a + b; }
在这里,Add()函数的两个参数只是起一个向函数内传入数据的作用,在函数执行过程中,其值不应该被修改。所以我们在函数声明中加上const关键字对其进行修饰,表示这是一个只读的传入参数。如果我们错误地在函数中对这个参数进行修改,编译器就会给出相应的错误提示,从而防止参数的值被意外修改,避免错误的发生。
(2) 形式参数可以有默认值。
在定义变量时可以给定变量的初始值,同样,在定义参数时也可以给参数一个初始值。拥有初始值的参数可以在调用的时候不给出具体的数值而使用这个初始值。例如,可以写一个函数来判断某个分数是否及格。及格与否,是通过当前分数与及格分数进行比较的结果,这就意味着这个函数需要两个参数,一个向函数内传递当前分数,而另一个则负责传递及格分数。在绝大多数情况下,及格分数为60,这时就可以用60作为这个参数的默认值:
// 判断某个分数是否超过及格分数,默认及格分数为60 bool IsPassed( int nScore,int nPass = 60 );
使用参数默认值,可以给函数的调用带来很大的灵活性。大多数情况下,如果参数应该使用默认值,则可以在调用函数时省略拥有默认值的参数,直接使用其余参数对函数进行调用。这时,被省略的参数的值就是在声明时指定的初始值。而在某些特殊情况下,又可以用其他的具体数值作为参数对函数进行调用,这时的参数值将不再是函数声明中的初始值,而是函数调用时给定的具体数值。例如:
int nScore = 82; // 当前成绩 // 使用参数的默认值 // 这时,被省略掉的nPass参数的值是函数声明中的初始值60 // 也就相当于调用的是IsPassed(nSocre ,60) IsPassed(nSocre); // 成绩不理想,调低及格分数。不使用参数的默认值,用具体数值来调用函数 // 这时nPass参数的值就是函数调用时给定的数值56 IsPassed(nSocre, 56);
这里需要注意的是,拥有默认值的参数应该位于形式参数表的末尾位置。不能在形式参数表的开始或者中间位置定义拥有默认值的参数,例如:
bool max( int a = 0, int b ); // 错误的形式,默认参数不能在形式参数表的开始位置 bool max( int a, int b = 0 ); // 正确的形式,默认参数在形式参数表的末尾位置
(3) 没有形式参数时可以用void代替。
一个函数的形式参数并不是必需的,当一个函数只是单纯地完成某个动作,不需要通过函数参数与调用者之间进行数据传递时,函数的形式参数表就是多余的。这时既可以将形式参数表直接省略留空,也可以用“void”代替形式参数表,表明这个函数没有形式参数表。例如:
// 将形式参数表留空 void DoSomeThing(); // 或者使用void代替形式参数表 void DoAnotherThing(void);
虽然这两种形式都可以表示函数没有参数,但是在调用的时候,却有一定的差别:如果将形式参数表留空,在调用时就可以用任意实际参数调用这个函数。在形式上,好像函数调用使用了参数,但实际上这些参数根本不起任何作用;而如果用void作为函数的形式参数,那么在调用的时候实际参数只能为空。例如:
// 正确:以字符串为参数调用形式参数表留空的函数 DoSomeThing("cook"); // 正确:以整数为参数调用形式参数表留空的同一函数 DoSomeThins(1982); // 错误:以整数为参数调研以void为形式参数的函数 DoAnotherThing(1982);
将函数的形式参数表留空或者使用void代替,都可以达到函数无参数的目的。只是使用void作为形式参数时,这种意图更加强烈和明显,不仅在函数声明中用void明确表示这是一个无参数的函数,而且在函数调用时也不能使用任何参数。所以,如果我们想要明确地表达某个函数无参数的意思,最好使用void表示。
完成函数的声明,只是将程序代码装进函数箱子的第一步,它相当于给这个函数箱子贴上了标签,表明这个箱子中装的是什么功能的代码(函数名),而这些代码的执行又需要什么数据(形式参数表),而最后又会向外返回什么数据(返回值类型)。接下来的第二步才是关键,要完成函数的定义,也就是在函数内部用具体的程序代码处理数据实现函数的功能,这样才最终将程序代码装到了函数箱子中。函数的定义往往是紧接着函数的声明进行的,其语法格式如下:
返回值类型标识符 函数名(形式参数表) { // 函数定义 }
在函数声明之后紧接着用一对花括号“{}”括起来的代码就是一个函数的定义,也称为函数体。在函数体中,利用函数参数传入的数据,我们用具体的程序代码对其进行操作实现函数的具体功能,最后再用“return”关键字向函数的调用者返回执行结果数据。例如,可以这样来定义上面声明的Add()函数,对两个数进行相加并返回它们的计算结果。
// 计算两个数的和 int Add( int a, int b ) // 函数声明 { // 函数定义 // 利用参数传入的数据执行加和计算,实现对数据的处理 int nRes = a + b; // 用return关键字返回函数执行的结果数据 return nRes; }
在这段代码中,函数声明之后用花括号括起来的一段代码就是Add()函数的函数体。这个函数体只有两条语句,第一句“int nRes = a + b;”是计算参数a和b传递进来的两个数的和,并将计算结果保存到变量nRes中,这样就实现了这个函数加和(Add)的功能;第二句是“return nRes;”,也就是通过return关键字将nRes中保存的结果数据返回给函数的调用者。这样,该函数体就实现了计算两个数之和的功能。
这里的return关键字是C++中很常用的一个关键字, “return”即“返回”的意思,当函数执行到return关键字时,函数将立即结束执行并返回,即使return后面还有代码,也不会被执行。如果函数有返回值,它还会负责将结果数据返回给函数的调用者。return返回的结果类型必须和函数声明中的返回值类型一致。根据程序的执行情况,同一个函数可以有多个return语句,用于不同的情况下返回不同的结果。当然,对于不需要返回结果的函数,可以不写return 语句,函数体在执行完所有代码后自然结束。
最佳实践:声明和定义相分离
在实际的开发实践中,在某个源文件中定义的函数往往会在另一个源文件中被调用。另外,对于一些函数库(比如DirectX)而言,我们希望他人可以使用函数库中的函数,但又不希望将函数的实现细节暴露给使用者。这时我们通常采取的方法是:只向函数的使用者提供函数的声明,而使用者根据函数声明就知道如何对函数进行调用了。至于具体的函数实现,则写在另外的函数实现文件中,或者是通过动态链接库文件(比如,.dll文件)或静态链接库文件(比如,.lib文件)的形式提供给函数的使用者。这样,一个程序的所有源代码文件通常就被分成两类:一类文件主要用来记录函数或者类的声明,提供给他人或者程序中另外的文件使用。这类文件被称为头文件(header file),通常以.h为文件名后缀;另外一类文件则用来定义函数和类,实现函数和类的具体功能,这类文件被称为源文件(source file),多以.cpp为文件名后缀。
这样,一个函数的声明和定义被分别放在了两个文件中,实现了接口和实现的相互分离。