Visual C++从入门到精通(第5版)
上QQ阅读APP看书,第一时间看更新

4.1 函数

通常一个应用程序需要包含多个子功能模块,其中每一个子功能都可以由函数来实现。对于C++语言来说,应用程序主要由一个主函数(main函数)和其他函数构成,其中主函数负责调用其他函数。对于函数来说,它可以调用自身(递归调用)或其他函数,也可以被其他函数调用。

如果说将一个程序比做一间公司的话,那么主函数(main函数)就是公司的董事会,也就是领导层,而其他策划、生产、销售等部门就是负责单一功能的函数,各个部门之间相互协调又各司其职,使公司的运作和管理更加流畅。而在程序中,每个函数完成一项功能,当程序出现问题时,只要根据功能就可以判断出是哪个函数出现了问题,使程序更加容易修改;由于函数可以完成指定的功能,因此也增强了可移植性。

读者通过上述的比喻可以了解函数在程序中所处的位置,从而更好地体会函数的存在价值。本节将详细介绍有关函数的相关知识。

4.1.1 定义和调用函数

1. 定义函数

对于C++语言来说,定义函数的一般格式如下。

通常函数都有一个返回值,当函数结束时,将返回值返回给调用该函数的语句。但是,函数也可以没有返回值,即返回值类型为void。函数名可以是任何合法的标识符。函数的参数列表是可选的,如果函数不需要参数,则可以省略参数列表,但是参数列表两边的括号不能省略。函数体描述的是函数的功能,主要由一条或多条语句构成。函数也可以没有函数体,此时的函数称为空函数。空函数不执行任何动作。在开发程序时,当前可能不需要某个功能,但是将来可能需要,此时可以定义一个空函数,在需要时为空函数添加实现代码。如果函数有返回值,通常在函数体的末尾使用return语句返回一个值,其类型必须与函数定义时的返回值类型相同或兼容。下面定义一个简单的求和函数,用于计算两个数的和。

【例4.1】 定义简单求和函数。

在上面的代码中,定义了一个临时变量ret,用于记录参数x与y的和,然后返回ret。对于return语句来说,还可以直接返回一个表达式,表达式的结果将作为函数的返回值。例如:

为了更清晰地描述函数的返回值,可以在函数体中将返回的表达式使用括号括起来。例如:

下面再编写一个函数,用于输出十进制数的十六进制形式。

在上面的代码中定义了一个无返回值的Hex函数,用于输出十进制数的十六进制形式。众所周知,在定义同种类型的多个变量时,可以使用一条语句来定义。例如:

但是在定义函数参数时,如果多个参数具有相同的类型,则需要分别定义,不允许整体定义。例如,下面的函数定义是非法的。

注意

在定义函数时,如果函数不是void类型,则一定要在函数中加入return语句。

2. 调用函数

函数调用的一般格式如下:

下面以调用Hex函数为例,介绍函数调用的方法。

在调用函数时,如果函数有参数,注意实际参数的类型应与函数定义时的参数类型相同或兼容。对于没有参数的函数,在调用函数时只需要写函数名和括号即可。下面的代码演示了无参函数的定义及调用。

注意

在调用无参函数时,注意不要遗忘括号,这是许多初学者经常犯的错误。

对于具有返回值的函数,在调用函数时,需要获得函数的返回值。下面的代码演示了如何调用具有返回值的函数以及如何应用函数的返回值。

【例4.2】 调用具有返回值的函数。(实例位置:资源包\TM\sl\4\1)

在调用函数时,如果当前函数处于被调用函数的下方,则需要对被调用函数进行前置声明。例如,下面的函数调用将是非法的。

【例4.3】 非法的函数调用。

在上述代码中,main函数调用了sum函数,但是sum函数的定义处于main函数的下方,导致了sum标识符没有声明的错误。为什么会出现这种问题呢?其实很简单,假设有甲、乙两人,甲相当于编译器,乙相当于被调用的函数,而你就相当于主函数,这时,上述代码就好比你在和甲先生聊天时说到了乙先生,而甲、乙两人还不认识,甲先生自然会发出疑问,也就是在程序编译中发送的标识符有没有什么错误。为了解决上述问题,只要在你和甲先生聊天前先介绍一下乙先生的信息,这样就不会有问题发生了,如果体现在上述程序中,就是对sum函数进行前置声明,即在main函数的上方声明sum函数。例如:

【例4.4】 前置声明函数。(实例位置:资源包\TM\sl\4\2)

注意

在对函数进行声明时,注意不要忘记在括号后面加上分号,这是许多初学者容易忽视的地方。

4.1.2 设置默认值参数

在调用有参函数时,如果经常需要传递同一个值到调用函数,则可以在定义函数时为参数设置一个默认值。这样在调用函数时便可以省略一些参数,此时程序将采用默认值作为函数的实际参数。下面的代码定义了一个具有默认值参数的函数。

【例4.5】 创建默认值参数的函数。

下面来调用OutputInfo函数,其中一条语句利用参数的默认值调用函数,另一条语句直接为函数传递实际参数。

运行程序,效果如图4.1所示。

图4.1 默认值参数

从图4.1中可发现,调用函数时如果不设置参数,具有默认值参数的函数会自动按照默认值来运行,而在调用函数时设置了参数,函数则会按照设置的参数值来运行。

在定义函数默认值参数时,如果函数具有多个参数,应保证默认值参数出现在参数列表的右方,没有默认值的参数出现在参数列表的左方,即默认值参数不能出现在非默认值参数的左方。例如,下面的函数定义是非法的。

【例4.6】 非法的默认值参数。

在上述代码中,默认值参数y出现在非默认值参数z的左方,导致了编译错误。正确的做法是将默认值参数放置在参数列表的右方。例如:

4.1.3 设置数组参数

在编写函数参数时,可以使用数组作为函数参数。例如,编写一个函数,按从小到大的顺序输出10个整数。如果每一个整数定义一个参数时需要定义10个参数,那么,输出100个整数呢?可以想象编写参数列表多么麻烦。如果使用数组作为函数参数,则可以大大节省编写参数列表的时间。下面的代码演示了使用数组作为函数参数。

【例4.7】 使用数组作为函数参数。(实例位置:资源包\TM\sl\4\3)

执行上述代码,结果如图4.2所示。

在定义数组参数时,也可以不指定大小。在调用数组参数的函数时,C++编译器不对数组的长度进行检查,它只是将数组的首地址传递给了函数,因此可以对Sort函数进行修改,使其更具有灵活性。

图4.2 数组参数

说明

参数按引用方式传递,在4.1.4节中将详细介绍参数的传递方式。

【例4.8】 使用动态数组作为函数参数。

在上述代码中,函数Sort可以对任意长度的数组进行排序,增强了灵活性。这里不是通过显示指定数组的长度,而是通过另一个参数标识数组的长度。但是有些读者可能会考虑,如何通过数组参数来限制函数调用时必须传递指定长度的数组呢?如函数包含一个10个元素的数组参数,如果用户只传递8个或11个元素的数组,该如何禁止用户的传递呢?换句话说,如何让编译器知道此类错误呢?解决的方法是使用数组的引用作为函数的参数。当数组的引用作为函数参数时,数组的长度将作为参数的一部分。下面仍以Sort函数为例,使用数组的引用作为函数参数。

【例4.9】 使用数组的引用作为函数参数。

注意

在上述代码中注意Sort函数的定义,其中“int(&array)[10]”中的括号是不可省略的。如果省略了括号,“[]”运算符的优先级高于“&”运算符,便成了引用数组,而在C++语言中定义引用数组是非法的。

写成“int (&array) [10]”的格式是合法的,表示定义一个引用对象,它可以指向(严格地说应该是取代)具有10个元素的数组。这里回忆一下指针数组的定义。

这里如果对“*parray”使用括号括起来,其性质就变了。例如:

上述代码实际定义了一个整型指针,可以指向具有5个元素的数组。这与“int (&array) [10]”是类似的,“int (&array) [10]”表示定义一个引用,指向具有10个元素的整型数组。因此,以“int(&array)[10]”形式定义函数参数列表,编译器会强制检查数据元素的个数,如果不为10,会显示编译错误。对于上述的Sort函数,如果采用如下方式调用,将是非法的。

正确的调用方式为:

4.1.4 设置指针/引用参数

除了数组可以作为函数参数外,指针和引用也可以作为函数参数。在介绍指针和引用参数之前,先来介绍一下函数参数的传递方式。在C++语言中,函数参数的传递方式主要有两种,分别为值传递和引用传递。所谓值传递,是指在函数调用时,将实际参数的值复制一份传递到调用函数中。这样,如果在调用函数中修改了参数的值,其改变不会影响到实际参数的值。下面的例子演示了函数的按值传递方式。

【例4.10】 按值方式传递参数。(实例位置:资源包\TM\sl\4\4)

执行上述代码,结果如图4.3所示。

从图4.3中可以发现,在调用ValuePass函数后,虽然在函数中修改了参数的值,但在函数调用后,变量ivar的值仍为5。

而引用传递则恰恰相反,如果函数按引用方式传递,在调用函数中修改了参数的值,其改变会影响到实际参数。下面的代码演示了使用指针作为函数参数,此时函数采用引用传递方式。

【例4.11】 按引用方式传递参数。(实例位置:资源包\TM\sl\4\5)

执行上述代码,结果如图4.4所示。

图4.3 值传递

图4.4 引用传递

从图4.4中可以发现,当调用ValuePass函数修改参数的值为10时,变量ivar的值也随之改变了。之所以发生改变,是因为调用ValuePass函数时将变量ivar的地址传了过去,这样函数的参数与变量ivar的地址是相同的,因此修改了参数的值必然会影响到变量ivar的值。

技巧

函数的值传递和引用传递是如何区分的呢?实际上,通常在定义函数时,如果参数为数组、指针或引用类型,则函数采用引用传递方式,否则采用值传递方式。

下面以引用类型为例来演示函数引用参数的传递。

【例4.12】使用引用类型作为函数参数。(实例位置:资源包\TM\sl\4\6)

运行上述代码,结果与图4.4所示是相同的。使用指针或引用作为函数参数,均采用引用方式传递。那么在开发程序时究竟应该使用指针还是使用引用类型作为函数参数呢?实际上,使用指针和引用类型作为函数参数各有优缺点,视具体环境而定。对于引用类型,引用必须被初始化为一个对象,并且不能使它再指向其他对象,因为对引用赋值实际上是对目标对象赋值。这是引用类型的缺点,但也是引用类型的优点,因为在函数中不用验证引用参数的合法性。例如,下面的函数调用是非法的。

上述代码中,如果ValuePass采用指针作为函数参数,使用“ValuePass(0);”语句调用是合法的,但是却带来了隐患,因为0被认为是空指针,对空指针操作必然会导致地址访问错误。因此对于指针对象作为函数参数,函数体中需要验证指针参数是否为空。这是使用指针类型作为函数参数的缺点。但是,使用指针对象作为函数参数,用户可以随意修改指针参数指向的对象,这是引用类型参数所不能的。

4.1.5 省略号参数

在本章和其他章节的代码中多次使用了printf函数输出信息。当在程序中调用该函数时,其参数列表会显示省略号,如图4.5所示。

省略号参数代表的含义是函数的参数是不固定的,可以传递一个或多个参数。对于printf函数来说,可以输出一项信息,也可以同时输出多项信息。例如:

那么如何在程序中定义省略号参数函数呢?可以按如下方式定义:

对于上述方式的函数,在编写函数体时需要一一读取用户传递的实际参数。可以使用va_list类型和va_start、va_arg、va_end 3个宏读取传递到函数中的参数值。下面以一个具体的示例介绍省略号参数函数的定义及使用。

【例4.13】 定义省略号形式的函数参数。(实例位置:资源包\TM\sl\4\7)

执行上述代码,结果如图4.6所示。

图4.5 省略号参数

图4.6 省略号参数执行结果

说明

要使用va_list类型和va_start、va_arg、va_end 3个宏,需要引用cstdarg头文件。

4.1.6 内联函数

所谓内联函数,是指对于程序中出现函数调用的地方,如果函数是内联函数,编译器则直接将函数代码复制到函数调用的地方,这样省去了跳转到函数定义的地方执行代码,然后再返回到调用函数处的一个过程,提高了程序的执行效率。内联函数最大的一个缺点是增加了程序代码,可以想象一下,如果一个内联函数有上千行的代码,程序中多次出现该函数的调用,执行程序的代码将是多么庞大。但是,对于代码较少、经常需要调用的函数,将其定义为内联函数,则可以显著提高程序执行效率。在C++语言中,可以使用inline关键字定义内联函数。例如:

对于使用inline关键字声明的内联函数,程序不一定将函数作为内联函数对待。可以认为inline关键字只是对编译器的一个建议,编译器是否将其作为内联函数依赖于编译器的优化机制。对于微软的C++编译器来说,可以使用_forceinline关键字强制编译器将该函数作为内联函数。

注意

在调用内联函数时,每次都会将函数代码复制到被调用函数处,这样会导致应用程序变大、执行速度变慢。对于经常使用的、代码较少的函数,可以使用内联函数;对于代码较多的函数,不应该使用内联函数。

4.1.7 重载函数

所谓重载函数,是指多个函数具有相同的函数名称,而参数类型或参数个数不同。函数调用时,编译器以参数的类型及个数来区分调用哪个函数。例如,下面的代码定义了两个重载函数。

【例4.14】 定义重载函数。(实例位置:资源包\TM\sl\4\8)

执行上述代码,结果如图4.7所示。

从图4.7中可以发现,语句“int ivar=Add(5,2);”调用的是第1个Add函数,而语句“float fvar=Add(10.5,11.4);”调用的是第2个Add函数。考虑这样一种情况,再定义一个Add函数,其参数类型为float类型,那么“float fvar = Add(10.5,11.4);”语句将调用哪个函数呢?

图4.7 重载函数

答案是调用第2个Add函数,如果改为如下的调用形式,则调用第3个重载函数。

在定义重载函数时,应注意以下几点。

(1)函数的返回值类型不作为区分重载函数的一部分。

下面的函数重载是非法的。

(2)对于普通的函数参数来说,const关键字不作为区分重载函数的标识。

下面的函数重载是非法的。

但是如果参数的类型是指针或引用类型,const关键字则将作为重载函数的标识。因此,下面的函数重载是合法的。

(3)参数的默认值不作为区分重载函数的标识。

下面的函数重载是非法的。

(4)使用typedef自定义类型不作为重载的标识。

当函数使用了typedef自定义类型作为参数类型时,如果另一个函数的参数类型与自定义类型的原始类型相同,则函数的重载是非法的。例如:

上述代码的函数重载是非法的,因为typedef不是创建新的数据类型,所以编译器认为上面的两个函数属于同一个函数,不能区分重载函数。

(5)局部域中声明的函数将隐藏而不是重载全局域中的函数。

下面的代码定义了3个重载函数,但是在主函数内部前置声明了第3个重载函数,此时第3个重载函数将隐藏而不是重载其他函数。下面代码中主函数main中的函数调用是非法的。

上述代码中,在main函数内部(独立域)前置声明了第3个重载函数,这将导致第1个、第2个函数被隐藏,语句“Validate(10.5f);”试图调用第1个重载函数,导致编译错误。为了能够访问被隐藏的函数,需要使用域运算符“::”。在main函数中将“Validate(10.5f);”语句修改为“::Validate(10.5f);”语句即可通过编译。有关域运算符的相关知识参见4.2节。

4.1.8 函数递归调用

函数递归是指函数直接或间接调用其本身。递归的直接调用是指在函数体中再次调用该函数。递归的间接调用是指函数调用另一个函数,而被调用函数又调用了第一个函数。在编写程序时,使用递归可以简化问题的求解方法。例如,下面的代码利用递归求n的阶乘。

【例4.15】 利用递归求n的阶乘。

在上述代码中,factorial函数实现了计算n的阶乘。以n等于4为例,4!等于4*3!,3!等于3*2!,…,1!等于1。当计算4的阶乘时,只要知道3的阶乘就可以了,4*3!等于4!。同理,计算3的阶乘,只要知道2的阶乘就可以了,以此类推。1的阶乘为1,知道了1的阶乘,就可以计算2的阶乘,知道2的阶乘就可以计算3的阶乘……

对于递归函数来说,必须具有终止条件,无限制的递归将导致程序崩溃。对于上述factorial函数来说,递归的终止条件是n等于1或等于0。

注意

递归函数会增加系统的额外开销,导致程序性能下降,因此开发程序时应尽量避免使用递归。

在上面的递归函数中,如果传递一个很大的数,会导致堆栈溢出,因为每调用一个函数,系统便会为函数的参数分配堆栈空间。上述的递归函数factorial完全可以用连续乘积的方式实现。

【例4.16】 利用循环求n的阶乘。

4.1.9 函数指针

对于C++语言来说,函数名实际上是指向函数的指针。理解这一点,就可以定义一个普通的函数指针,使其指向某一类型的函数。例如,下面的代码定义了一个指向具有两个整型参数的函数指针。

也可以使用typedef定义一个函数指针类型,然后定义该类型的变量。例如:

当定义了一个函数指针后,可以将已经定义的函数(函数的返回值、参数个数、参数类型必须与函数指针定义的形式相同)赋值给函数指针,通过函数指针调用指向的函数。例如:

【例4.17】 使用函数指针调用函数。

对于函数指针来说,它指向的函数必须与函数指针定义的函数形式相同,即返回值类型相同、参数类型相同、参数个数相同。这与函数重载是不同的,函数重载不以函数的返回值类型为依据,但是函数指针则不同,如果将上面的sum函数的返回值类型修改为double类型,则上述代码将无法编译。

在程序中使用函数指针的好处是增强程序的灵活性。例如,编写一个通用的函数,实现两个数的加、减、乘、除操作。

【例4.18】 使用函数指针指向不同的函数。(实例位置:资源包\TM\sl\4\9)

上述代码的main函数中,同样的“Invoke(20,10,pfun);”语句,由于pfun指向了不同的函数,因此Invoke函数实现的功能也不相同。如果用户的程序有改动,例如需要实现两个数的求余运算,只需要再编写一个求余函数,将其赋值给pfun函数指针,Invoke函数则无须进行任何改动。

对于指针变量来说,用户可以定义指针数组;对于函数指针,用户也可以定义函数指针数组。例如,下面的代码定义了一个函数指针数组。

下面列举一个示例,通过遍历函数指针数组来调用不同的函数。

【例4.19】 通过遍历函数指针数组来调用不同的函数。(实例位置:资源包\TM\sl\4\10)

执行上述代码,结果如图4.8所示。

图4.8 函数指针数组