第六章是和函数有关的知识,函数就是命名了的计算单元,对程序的结构化非常重要。
本章内容包括:
- 函数的概念基础,包括函数的定义声明以及函数如何生成值和返回结果。
- 函数重载,重载可以使函数接受不同种类或者数量不同的参数。
- 函数指针,指向函数的一类特殊指针。
函数基础(P182,6.1)
|
|
在调用函数时,第一步编译器会隐式的定义并初始化它的形参。比如一个函数void f(int a);
,形参int a
会被用户传入的实参初始化,此时形参是实参的一个副本。当有多个形参时,形参对应的实参的求值顺序是不一定的。实参的类型必须和形参的类型一致或能转化为形参声明的类型。
函数可以返回空值。返回函数指针和数组的特殊函数类型将在之后提到。
局部静态对象(P185,6.1.1)
一个对象的名字有作用域,对象本身也有生命周期。名字的作用域是我们可以通过名字访问对象的的区间。相对的,生命周期是指对象的产生和销毁的过程。
定义在所有函数外部的变量叫做全局变量,在整个程序的执行过程中一直存在。这种对象在程序启动时被创建,直到程序结束才会被销毁。
定义在函数体内的对象或者函数的形参都是局部变量。当函数执行路径经过该对象的定义语句时才会自动开始创建该对象,在对应的块结束时,这个对象会被销毁。
有时候我们有必要使局部变量的生命周期贯穿函数调用及之后的时间,所以我们可以将局部对象定义成static
对象,定义语句形如static int a=1;
,这样我们就可以在程序的别的地方(只要是在这个static对象的作用域内访问它)操作这个局部静态对象。
在一个程序中多次定义局部静态对象仍然是不被允许的。但是当一个函数里的对象被定义为局部静态对象,多次调用这个函数并不会重置这个局部静态对象的值。它自己会记得上一次被函数调用之后的值并继承这个值,不被第二次函数调用的变量定义初始化,这就是它静态的特性。
局部静态变量若没有显式的初始化,则执行值初始化,内置类型的局部静态变量初始化为0。
函数声明(P186,6.1.2)
函数声明要在使用这个函数之前。规范的形式是通常放在头文件里。函数声明可以不写形参的名字,只写形参的类型。
函数声明也称作函数原型(function prototype)。
传引用调用(P189,6.2.2)
当函数的形参是一个引用类型的时候,在使用函数时,这个函数的引用形参就绑定在了传入的实参上,这种函数调用就叫做传引用调用。在函数涉及到一些比较大的类型对象作为参数的时候,通常地我们使用传引用调用,这样就可以避免实参初始化形参带来的拷贝。在C语言里经常传入指针避免拷贝,在C++里,一般使用引用。
大多数情况下函数只能有一个返回值,因此在我们需要的时候,我们可以传一个额外的引用的参数在函数里面。这样函数体内就可以改变引用的值进而改变函数外部被引用连接的对象的值,从而返回多个数值。
const形参和实参( P190,6.2.2)
函数形参的类型也可以是带const的类型。
顶层const作用于对象本身(离对象最近的const),实参初始化形参的时候会忽略掉顶层const。即形参的顶层const被忽略掉了。
|
|
因为顶层const被忽略掉了,所以第二个fcn是错误的。
形参的初始化方式与变量的初始化方式一样。先回顾一下变量的初始化:
将同样的初始化规则应用到参数传递上:
我们不能把const对象、字面值或者需要类型转换的对象传递给普通的引用形参。
当我们接受函数的参数是为了完成比较或者判断等操作,而不需要改变参数的值,我们应该使用带const的参数来确保参数不会被更改。另外,const类型的形参能比普通类型的形参接受更多种类的参数。比如void fn(const string&);
这个函数,字符串字面值是const char [ ]
类型,因此fn这个函数接受字符串字面值。但是如果声明成了void fn(string&);
,那么这个函数就没有办法接受字符串字面值(类似:“string”
这样的值就是字符串字面值)。另外,带const的形参也接受带底层const的对象。
传递数组作为参数(P193,6.2.4)
又是我们想要向函数传递一个数组,但是数组是不可拷贝的,因此我们不能够通过值传递的方式传递一个数组到函数里,另外,如果数组的内容很大,传递数组的每个元素会带来不必要的拷贝。
以下的方法都基于或类似传递数组的指针。一维数组的指针指向数组的第一个元素。我们可以声明类似如下的形式传递一个数组指针到函数:
这三者是等价的。传递之后形参的类型都是int *
类型。
但是正因为数组的信息是以指针的形式传递给函数的,所以函数只得到一个地址,并不知道数组的大小,因此也就很容易访问到未定义的内存区域,因此在传递数组指针的基础上,我们可以通过手动标志数组大小等方法保证函数访问的内存是合法的不越界的。因此衍生出以下几种方法。
- 第一种方法:在数组的末尾加标记。这种方法类似于C风格字符串,末尾会自动加’\0’来告诉大家这个字符串结束了。在数组末尾加特殊的标记来使数组不越界是简单易用的方法。
- 第二种方法:使用标准库规范中的begin和end函数。头文件
iterator
里有针对数组的begin和end函数,返回数组的首指针和尾后指针,指针指向数组元素的类型,这种方法也可以检测越界。 - 第三种方法:传递一个表示数组大小的参数。这样构建函数时就知道数组有多大了。如
print(j, end(j)-begin(j));
- 第四种方法:传递数组的引用。除了使用指针,我们还可以使用引用来得到一个完整数组的引用(别名)。声明格式类似下面这种:
void fx(int (&arr)[10]);
,这里形参的名字是arr
,arr
前面的&
符号代表它是引用类型,引用了一个实参数组,这个数组必须只有10个元素(因为arr后面的[10]也是构成引用声明的必要部分。)
注意
void fx(int &arr[10]);
这个去掉括号的写法是错误的,不存在引用的数组。
传递多维数组:有时我们也需要向一个函数传递多维数组。多维数组的实质是数组的数组,一维数组的名是指向数组元素的指针,二维数组是指向数组元素的指针的指针。因此想要一个函数传递多维数组的形参声明如下:void fx(int (*arr)[10]);
这时arr指向有10个int型元素的数组。当我们把arr+1,它就又指向了新的10个元素,因此arr相当于二维数组的数组名(两者都是指向包含的一维数组首元素的指针)。
也可以用int arr[][10]
代替int (*arr)[10]
,因为它们是等价的,都是二维数组名。用int arr[][10]
这种方式定义形参时,要标出除了第一个维度以外的每个维度。(假设有一个数组int b[2][3]
,就说明b有两列,每列3个元素,这里的2就是第一个维度。指向一维数组的指针不关心在这个维度上有几个元素,因此忽略)。
main函数的命令行选项(P196,6.2.5)
最开始我们使用UNIX
或LINUX
系统编程时经常使用没有图形界面的编译器来把写好的代码编译成obj文件,这时候我们使用命令行来编译一份源代码文件,我们需要在终端里输入类似“prog -d -o oflie data0
”的命令行来进行命令行控制。
现在我们看到的main函数一般都是int main()
,括号里面什么也不写,我们也可以给main传递上述的那个命令行参数。形如:int main(int argc,char *argv[])
。 main可以什么参数也不接受,也可以接受一个int和一个指向字符串的指针这两个参数。main没有第三种形式了。
int argc
是表示后面的argv一共指向几个字符串用的。char *argv[]
里面的每一个字符串都顺序对应着命令行的参数。这些参数的字符串数组的第一个元素应该是可执行文件的名字或者空参数,最后一个字符串的值必须为0
。
有关命令行的更多选项和argv参数的具体用法,可以参照对应的编译器文档。
含有可变形参的函数(P197,6.2.6)
到现在我们定义的函数都是固定参数的,但是有时候我们无法预知向函数传递几个参数,又想使用一个函数接受这种变化,我们就可以使用C++指定的两种方法来定义含有可变形参的函数。
- 第一种方法是当参数个数不一定,但是参数类型都相同时,我们可以传递一个initializer_list参数。这是标准库设施中的一部分。
- 第二种方法在当我们想传递不确定个数的不同类型的实参时要使用的技术:可变参数模板。这个16章才介绍。
其实还有一种方法使函数接受多种形参,不过这种方法多用于和C语言旧代码对接时使用。这个方法用省略符来传递可变数量的形参。
常见的应用场景:日志的打印
initializer_list(P197,6.2.6)
下面是关于定义可变形参函数的第一种方法——initializer_list
参数的介绍:
initializer_list
类似vector
,是一种容器,接纳一种同样类型的元素。initializer_list定义在同名的<initializer_list >
中,我们可以把任意数量,同样类型的参数传递给这个容器使函数能够处理多个元素。
initializer_list支持的操作包括:
initializer_list里面元素的值永远是常量不能被更改,如果里面的元素是指针或引用,这个元素的属性将被自动加上底层const。
当我们声明一个接受 initializer_list
类型的函数 void fa(initializer_list<int> list1);
的时候,我们需要使用大括号来调用这个函数,形如fa({2,3,4});这样我们就向initializer_list传递了一个值的序列。
我们也可以声明void fa(string b,initializer_list<int> list1);
这种函数。
省略符形参(P199,6.2.6)
下面是关于定义可变形参函数的第三种方法——省略符形参的介绍。
省略符形参有下列两种形式:
第一种形式为特定数目的形参提供了声明。在这种情况下,当函数被调用时,对于与显示声明的形参相对应的实参进行类型检查,而对于与省略符对应的实参则暂停类型检查。在第一种形式中,形参声明后面的逗号是可选的。如果没有逗号,相应地,就变成了第二种情况。
省略符形参应该仅仅用于C和C++通用的类型。
特别注意的是,大多数类型的对象在传递给省略符形参时都无法正确拷贝。
(感慨:所以说有什么用?还是用intializer_list
吧?)
你可以传递任意数量的参数给省略符形参。要注意省略号的优先级别最低,所以在函数解析时,只有当其它所有的函数都无法调用时,编译器才会考虑调用省略号函数的。
(optional)首先,如果要用省略符的方式处理不定参数的函数要包含头文件:#include <stdarg.h>
(C语言中)或者#include <cstdarg>
(C++中)。 然后利用va_list类型和va_start、va_arg、va_end 3个宏读取传递到函数中的参数值。
用省略符处理不定参数的函数基于C语言的方法,在C++中不建议使用。(使用了C语言标准库功能varargs)。
返回值(P202,6.3)
在void返回值的语句最后会隐式地有return;
语句,这时函数什么也不返回。
不要返回局部对象的引用或指针。
函数可以返回一个非常量引用作为左值。也可以返回一个花括号括起来的列表,来初始化vector等类型。
|
|
main函数的return语句可以不写,编译器会带为隐式补充。
main 函数不能调用自己。
返回数组指针(P205,6.3.3)
虽然我们不能直接让函数返回一个数组,但是我们可以设定函数返回一个指针的类型。函数会返回数组的指针。返回数组指针的函数定义语句如下所示:
当然,返回一个临时量或者局部对象的引用/指针都是错误的行为,如果你在函数里普通地定义了一个数组,那么这个数组的生命周期在函数返回时就结束了,会被内存中释放,因此可能需要用static使这个数组静态。静态对象只是延长了对象的生命周期,但是无论如何在函数内部定义的对象在外部都无法访问,除非使用返回指针的方法。
一条double (*func(int a))[10]
这种语句来说明函数接受一个int a
形参并且返回一个带有10个元素的double数组,这种语句在写法上比较乱,因此C++11
提供了尾置返回的方法让程序员不必要非要迁就编译器的理解能力,上一条语句等价于这样:auto func(int a)->double(*)[10]
我们使用->
符号把返回值类型的描述放在了参数列表后面并和函数声明分离开让函数看起来不那么乱。
我们也可以使用decltype
语句返回数组指针。decltype
后面的括号可以括起一个现有的数组推导数组类型。我们再手动加*
得到数组指针类型的返回值。在已有int a[10];
的情况下,我们可以使用decltype(a) *fn(int b)
这种形式定义一个返回指向数组的指针的返回值类型。
函数重载(P207,6.4)
我们可以定义一组功能类似,函数名一致,但是接受的参数类型或数量不同的函数。定义多个这种函数就叫做函数重载。函数重载可以提供给我们用一个函数名处理多种参数形式的情况。
定义重载函数要能重传入的参数里区别出实质不同的重载函数,如函数A的定义为int fa(const int a);
和函数B int fa(int b);
这两个函数函数名一样,形参类型不同,但仍然无法作为重载函数。因为我们传入一个int值时,fa不知道应该执行第一种还是第二种。所以只有参数顶层const属性不同的几个函数不是重载函数。
形参相同,但返回类型不同的函数也不能构成重载。
当然,对于底层const,比如参数列表为const int *a
的函数和参数列表为int a的函数能被看出不同,因为对于一个传入的const常量指针,这个实参只能初始化const int *a
,不能被初始化int a
。当同时有这两种形式的重载函数时,当传入一个非常量,IDE会优先选择为它匹配形参为int a
版本的普通变量形参函数。
形参是某种类型的引用或指针,则通过区分其指向的是常量对象还是非常量对象可以实现函数重载。(此时的const是底层的)
const_cast和函数(P209,6.4)
这里主要介绍const_cast
类型强制转换是如何在函数中被使用的。在第四章(4.11.3, P145)第一次接触const_cast
的时候我们提到过这个常被用于函数里。这里我们就看看怎么使用。
之前说过,向函数传递参数时最好传递const
型参数使其能够接受多种参数,这里我们可以在函数体内使用const
再把参数变回普通的变量,这样就可以返回一个非const
值了。
重载和作用域(P210,6.4)
声明变量时,变量的作用域就在块里,声明函数也一样,而且里层的作用域会隐藏外部的作用域。例子如下:
函数声明也一样,
C++中,名字查找发生在类型检查之前。
特殊用途语言特性(6.5)
默认实参(P211,6.5.1)
有时候一些函数我们每次调用它总会向它传递一些特殊的值。我们可以声明带有默认实参的函数。默认实参如果没有明确说明,默认实参会被自动当做函数的初始值传递进去。
形如int fn(int a,int b=2,double c=3.3)
这样定义函数头的方式就给了b和c默认的实参,注意,当一个形参被给了默认实参,它后面的所有参数都要有默认实参才行。
当我们想使用默认实参的时候,只要调用函数的时候使用这种对应的实参就行了,默认实参会用来填补缺少的尾部实参,上面的定义的函数如果这么调用:fn(1,2);
,double c
的值会被自动设为3.3。书写这种函数时要尽量保证要经常用到的默认实参放在参数列表的更后面一点,这样才合理。
可以只在函数声明里标注默认实参不在函数定义里这样写,结果仍然将是正确的。void fn(int = 1, int = 2, int =3);
这种函数声明语句省略了形参的名字,不过也是可以的。
通常应该在函数声明中指定默认实参,并将该声明放在合适的头文件中。
局部变量不能做默认实参,默认实参的定义在函数体之外。另外,默认实参是可以在名字的作用域内通过名字更改的。
内联函数(P213,6.5.2)
有时我们要频繁调用一个优化规模小,流程直接,频繁被调用的函数,定义函数时我们可以在返回值类型前面加上关键字inline
使它成为内联函数,减少运行时的开销。
内联说明只是向编译器发出的一个请求,编译器可以选择忽略这个请求。
constexpr函数(P214,6.5.2)
这是一种能够被用在常量表达式的函数,但是函数的返回值类型和形参类型必须都是字面值。函数体中必须有且只有一条return
语句,constexpr
函数被隐式的指定为内联函数。const函数中也可以有类型别名,使用作用域声明等不执行操作的其他语句。这里没有赋值,没有构建对象。同时constexpr
可以返回计算后的结果。如constexpr int fn(int a){return a+22;}
,这条定义是正确的,前提是调用函数这个函数fn时,传入的实参是一个常量。比如fn(3);
内联函数和constexpr函数通常定义在头文件中。
调试帮助(P215,6.5.3)
程序员在写程序时可能涉及到一些调试中的代码,这些代码只在开发程序时使用,当即将发布程序的时候,要暂时屏蔽掉正在调试中的代码。C++提供了assert
和NDEBUG
两个预处理功能屏蔽测试代码。
assert预处理宏
assert这个宏定义在cassert
头文件中,assert使用一个表达式作为它的条件,形如assert(expr);
首先对expr或者表达式求值,如果结果为真(非0),那么assert什么都不做。如果结果为假(表达式值为0),那么assert输出信息并且终止程序的执行。
assert经常用于处理不能发生的条件,如果你写了一段代码,代码没测试越界,你就可以用assert,当它越界了我们就结束程序的执行。
NDEBUG预处理变量
NDEBUG
宏定义可以影响assert的行为,这个默认是没被定义的。当我们宏定义了NDEBUG
,就屏蔽掉了assert的功能。
可以使用NDEBUG
编写自己的调试代码。
此外,IDE还提供了__FILE__
(这里是两个英文下划线,这个存放文件名) 、__func__
(这个存放所在的函数名) 、 __LINE__
(这个存放所在的行数) 、__TIME__
(这个存放调试的时间) 、 __DATE__
(这个存放调试的日期) 这五种静态数组来提供错误信息。
函数匹配(P217,6.6)
程序员定义重载函数之后就可以使用它们了,挑选到底使用哪个版本的函数是一个过程,这个过程叫做函数匹配。
- 函数匹配的第一步是在调用时先找与与调用函数同名的函数名。且调用点在函数作用域内。这一步筛选出的函数叫做候选函数。
- 函数匹配的第二步是从候选函数中选择出能够被本次函数调用的实参传入的函数,函数名一致的前提下还要求函数的形参个数和实参一致,实参能够转化成(或者就是)形参规定的类型。这一步筛选出的函数叫做可行函数。
- 寻找最佳匹配。当
有int fn(int a);
和int fn(double a,double b=1.0)
时,我们调用函数fn形如fn(3.4);
显然这两种函数都是可行函数,这是我们再寻找最佳的匹配,因为fn(3.4);
对应fn(double,double=1.0);
的话无需转化,因此是最佳匹配。当有多个最佳匹配的时候函数将停止调用。
为了划分最佳匹配的各种情况,编译器将实参类型到形参类型的转换划分为几个等级,具体排序如下所示:
- 精确匹配:
精确匹配可以包含以下情况:数组名转化成数组指针的匹配,函数类型转换成函数指针的匹配,实参类型与形参类型相同。另外,像实参添加顶层const或者忽略实参赋值给形参的顶层const也属于精确匹配。 - 通过指针的转换把非常量指针转换成常量指针。
- 通过类型提升实现的匹配。
- 通过算数类型转换或指针转换实现的匹配
- 通过类类型转换实现匹配(类类型转换还没有讲)
要注意小整数字面值会被自动转换成int,而带小数点的字面值会被默认转换成doube。
函数指针(P221,6.7)
声明一条函数指针的语句如下: int (*PtrOfFunc)(参数列表)
,其中PtrOfFunc
就是指向函数的指针。我们可以把函数名赋值给定义的函数指针的名字。
*PtrOfFunc
两端 的括号不能少。
返回函数指针的形参定义为double(*fn(int a)) (int d,char b);
这里声明的函数是fn,函数的形参是int a
,返回值是函数指针类型的,返回的函数指针对应的函数的返回类型是double,参数是int d,char b。
和处理数组一样,我们也可以使用尾置返回来返回一个函数指针,尾置返回函数指针的声明是auto fn(int a)->double (*)(int d,char b);
尾置返回适合用来返回复杂的类型比如数组,函数指针等等。
遇到double(*fn(int a)) (int d,char b);
这种复杂的表达式,应该以定义的变量名为中心,从里往外一层层往外扩展。这个函数的定义语句里面,fn就是其中的变量名,看它右侧,有(int a)
,这(int a)
是一个形参列表。因此得出结论fn的本质是一个函数,再看左侧,*
代表这个函数返回一个指针,这个指针的类型在更外层(double (*) (int d,char b))
型。
当然这种声明/定义容易让人心累,所以这种情况下使用auto fn(int a)->double (*)(int d,char b)
是不错的选择。如果这样还是觉得太长了,可以使用typdef,USING等重命名语句加上decltype推导。比如tpyedef double func (int d,char b);
这样的语句之后,func就是一个函数类型。
也可以使用tpyedef decltype(fn) func2;
这条语句等价于上面的语句。
对于using语句,using Func2 = double (int d,char b);
即可。
可见typedef和using的替换原则是不同的,在涉及到复杂类型的时候,类似数组,函数指针,tpyedef的替换名要和被替换的类型一起被声明。
|
|
术语表(P225)
1.函数 function
2.形参 parameter
3.调用运算符(一个动作) call operator
4.实参 argument
5.主调函数 calling function
6.被调函数 called function
7.生命周期 lifetime
8.局部变量 local variable
9.自动对象 automatic object
10.局部静态对象 local static object
11.函数原型 function prototype
12.分离式编译 separate compilation
13.可执行文件 executable file
14.引用传递 passed by reference
15.传引用调用 called by reference
16.值传递 passed by value
17.传值调用 called by value
18.重载 overloaded
19.函数匹配 function matching
20.重载确定 overloaded resolution
21.最佳匹配 best match
22.二义性调用 ambiguous call
23.默认实参 default argument
24.预处理宏 preprocessor marco
25.候选函数 candidate function
26.可行函数 viable function
27.递归循环 recursion loop
28.递归函数 recursive function
29.尾置返回类型 trailing return type
参考资料
- C++ Primer 中文版 第5版
- C++primer第五版第六章学习笔记
- C++Primer第5版学习笔记(六)