C++学习笔记(6):函数
使用函数
函数的定义、调用和声明
函数的定义
函数的定义包括:返回类型、函数名、形参列表、函数体。
返回类型定义了函数的返回值类型;函数名应遵循C++标识符命名规则;形参列表由括号包围、逗号分隔,可有任意个形参(包括0个);函数体实际上是一个花括号括起来的代码块。
函数的返回类型可以为void,此时想要结束函数可以使用不带表达式的return语句返回。
函数的返回类型不能是数组,但可以是数组的指针。
函数的调用
使用函数调用运算符来进行函数调用,函数调用运算符是跟在函数名后的一对括号,括号内是实参列表,实参用以初始化函数的形参。
调用表达式的值就是函数的返回值,其类型就是函数返回值的类型。
由于主函数是C++程序的入口,所以运行中的C++代码一定是在函数内的,函数调用实际是将控制权在不同函数之间转移。在一个调用中,进行调用(执行调用表达式)的函数是主调函数,被调用的函数称为被调函数,函数调用将程序控制权从主调函数转移给被调函数。
函数调用的流程为:
- 用实参初始化函数函数对应的形参。
- 控制权从主调函数转移至被调函数,主调函数的执行被中断。
- 执行函数。
- 隐式地定义并初始化它的形参。
- 执行函数语句。
- 遇到return语句返回。
- 从被调函数返回,获得函数返回值,继续执行主调函数。
函数的声明
函数的声明类似于变量的声明:即只能定义一次,但可以声明多次。
如果一个函数永远也不会被用到,可以只有声明没有定义。(当然也可以用来记录Todo)
函数的声明和函数的定义形式上类似,唯一的区别是无需函数体,只需要一个分号结束。此外函数声明可以省略形参的名字,只提供形参的类型。
实际上,函数声明可以说是函数原型加上一个分号作为结束。函数原型就是描述了函数三要素(函数名、形参类型、返回值类型)的函数接口,说明了调用该函数所需的全部信息。
在实际应用中往往使用声明与定义分离的方式,使得代码清晰规范,即在头文件中声明,在源文件中定义。
局部对象
生命期
名字有作用域,对象有生命期:
- 名字的作用域是程序文本的一部分,名字在其中可见。
- 对象的生命期是该对象存在的一段时间。
函数体是一个代码块,其中的形参和定义的变量的作用域都是这个块作用域,同时还会屏蔽函数外(外层作用域)的相同名称。
在所有函数之外定义的对象是全局对象,其在程序启动时就被创建,直到程序结束才会销毁。
在函数内定义的对象是局部对象,其生命期依赖于定义的方式。
自动对象
只存在于块执行期间的最普通的局部对象称为自动对象,也就是当函数的控制路径经过变量定义语句时创建该对象,当到达定义所在的块末尾时销毁它。
自动对象的作用域是块内,生命期是从对象定义到块结束。
形参也是一种自动对象。
局部静态对象
将局部变量定义为static类型即为局部静态对象。
局部静态对象的名字的作用域依然是在局部,也就是块作用域中,区别是其生命期贯穿整个程序运行期间。局部静态对象在程序的执行路径第一次经过对象定义语句时初始化,直到程序终止时销毁。
局部静态对象与自动对象的区别在于生命期,也就是其作用域是局部的,存储方式是静态的。
在使用时与自动对象的区别为,多次调用同一函数使用同一变量时,该变量的值将接着上一次调用该函数结束时该变量的值继续使用。这适用于某一函数需要一个全局变量但是该变量仅在该函数中使用的情形。
内置类型的局部静态变量默认初始化的值为0,和其他静态变量一样。
编译控制
分离式编译
C++支持分离式编译,允许将程序分割到几个文件中去,每个文件可以独立编译。
这样做的好处是可以使程序的组织结构更加清晰明了。
assert宏
assert是预处理指令定义的宏,定义在头文件cassert中。它使用一个表达式作为条件:
1 |
|
assert首先对expr求值,如果表达式为假(即0值),assert输出信息并终止程序的执行;如果表达式为真(即非0),assert什么也不做。即assert宏常用于检查“必须发生”的条件,所以assert被称为“断言”。
NDEBUG宏
NDEBUG即not debug,也就是非调试环境。assert的行为依赖于NDEBUG的宏的状态。如果定义了NDEBUG,那么说明当前为非调试环境,则assert什么也不做。
默认状态下没有定义NDEBUG,为调试环境,此时assert将执行运行时检查。
参数传递
形参和实参
实参是形参的初始值,在函数开始执行之前使用实参初始化对应的形参。由于形参与实参必须一一对应,因此形参必会被初始化。
函数调用时同样没有规定实参的求值顺序,这也是未定义行为,由编译器决定。
如果提供的实参与形参类型不一致将会尝试类型转换,无法转换将导致调用失败。
可以使用不提供名称的形参,这表示该形参在函数中不会被用到,但是在调用时依然要提供对应的实参。
参数传递的方式
引用传递
当形参是引用类型时,我们说它对应的实参被引用传递或者函数被传引用调用,和其他引用一样,引用形参也是它绑定的对象的别名。
对引用的操作实际上作用在了被引用的对象上,这样使得函数可以改变实参的值。
除了需要改变实参的情形之外,在传递较大的对象作为参数时,值传递现得非常低效,甚至某些对象根本就不支持拷贝(如IO类型),这使得值传递无法进行,引用传递参数成了最好的选择。
引用传递也可返回更多的值,因为函数的返回值是唯一的,想要返回更多的值时可以将接收更多返回内容的对象作为实参将引用传递给函数,函数将更多返回值写入这些对象。
值传递
在这种情况下函数对形参的操作都不会影响实参,形参只是实参的一个副本。
如果函数接受的参数是指针的话,则可以以值传递的方式做到引用传递可以做到的事情,即修改函数外部的对象。
const参数
当形参是const时要注意顶层const的问题,即使用实参初始化形参时会忽略掉形参的顶层const。
假如形参是const int类型的话,它就是一个顶层const,为顶层const赋值是不允许的,而此时传参时会忽略其const属性使得参数传递能够进行。
在函数重载时,重载函数与原来的函数只有形参const的区别是不行的,因为为const int传参时可提供const int也可提供int,无法区分使用哪个函数。
和变量的初始化一样,传参时可以用一个非常量初始化一个底层const但是反过来不能。
尽量把函数中不改变其值的形参(如果要定义为引用的话)定义为常量引用,否则会大大限制函数可以接收的实参类型,因为非常量可以初始化常量,但是常量不能初始化非常量。
数组形参
数组指针形参
数组不能拷贝,但是可以以指针的方式传递,因此传递数组形参实际上就是传递指针,即以下形式是等价的:
1 |
|
如果我们传给print函数的是一个数组,则实参自动地转换成指向数组首元素的指针,数组的大小对函数的调用没有影响。
实际上这只对一维数组有效,对于多维数组,函数声明中形参需要明确二维及以后的长度,传入的实参数组二维及以后长度必须与形参一致,如:函数声明为 void print(int [][10]); 则传入的实参数组第二维长度必须是10,第一维长度不检查。
对于不检查长度的一维数组,我们要使用数组传参需要明确告诉函数这个数组的首尾在哪里,解决这一问题的常用办法有:
- 使用结束标记:就像C风格字符串一样要求数组有一个结束标记,但是这种方法只适用于能找到一个值作为结束标记不会与正常数据混淆的情况。
- 占用一个参数传递数组的长度,显式地告诉函数该数组有多少个元素。
- 使用标准库规范:使用begin和end函数获得数组的首指针和尾后指针来界定数组的范围。
数组引用形参
C++允许将变量定义为数组的引用,因此形参也可以是数组的引用。此时引用形参将直接绑定到数组上:
1 |
|
其中&arr两端的括号必不可少,如果没了括号,就声明成了引用的数组。
这样将数组的尺寸作为类型的一部分声明在了形参中,但是这样一来,该函数就只能接受尺寸为10的数组作为参数了。
其实也有办法给引用类型的形参传递任意大小的数组,但这都是后话了。
带参数的主函数
main函数也是可以有参数的。
main函数的两个参数分别是argc和argv,其中argv是一个数组,数组中的每个元素都是一个C风格字符串,而argc则给出了argv中有几个这样的字符串,那么main函数可以定义为以下两种形式:
1 |
|
假定main函数位于可执行程序prog中,那么可以使用这样的命令行执行程序:
prog-d -o ofile data0
此时argc的值为5,表示argv数组中有5个元素(包括结尾的0元素),即:
1 |
|
可变参数个数的函数
三种方法:
- 标准库类型initializer_list:只适用于所有实参类型相同的情况。
- 可变参数模板:支持实参类型不同的情况。
- 省略符形参:C风格的可变参数函数。
其中可变参数模板先按下不表。
initializer_list形参
如果函数的实参数量未知但是全部实参的类型都相同,我们可以使用initializer_list类型的形参。
initializer_list是一种标准库类型,用于表示某种特定类型的值的数组。initializer_list类型定义在同名的头文件中,我们可以像使用列表容器一样地使用它:
- initializer_list<T> lst:默认初始化,T类型元素的空列表。
- initializer_list<T> lst{a,b,c…}:列表初始化。
- lst2 = lst:拷贝或赋值一个initializer_list对象不会拷贝列表中的元素,原始列表和副本共享元素(浅拷贝)。
- lst2(lst):同上,拷贝。
- lst.size():列表中的元素数量。
- lst.begin():返回指向lst中首元素的指针。
- lst.end():返回指向lst中尾元素下一位置的指针。
但是要注意initializer_list中的值都只能是const常量值,不能被修改。
假如有这么一个使用initializer_list形参的函数:
1 |
|
在使用它时不用定义一个initializer_list对象再传参,而是可以直接以列表的形式传参,视为对形参的列表初始化了(其中actual和expected都是string对象):
1 |
|
含有initializer_list形参的函数也可以同时拥有其他形参。
省略符形参
省略符形参是C语言中的标准库(cstdarg)功能。
省略符形参只能出现在形参列表的最后一个位置,如:
1 |
|
第一种情况下前面的形参需要正常进行类型检查,后面的省略号捕捉的形参则不需要。
省略符形参只应用于C和C++中都拥有的类型,只在C++中拥有的类型的对象大多情况下无法正确拷贝传参。
默认实参
在函数声明中可以为函数的若干参数指定默认值,这样在调用函数时可以选择省略这些参数从而使用默认值作为参数调用函数。具有默认值的参数必须是函数的后几个参数,即为某个形参制定了默认值那么它之后的所有形参也都要有默认值,否则会产生歧义。声明默认实参的形式如下:
1 |
|
默认值一旦设定不可以再修改,即以下是错误的:
1 |
|
但是可以给未定义过默认值的形参新添加默认值,如:
1 |
|
局部变量不能作为默认实参,除此之外,只要表达式的类型能转换成形参所需的类型,该表达式就能作为默认实参。用作默认实参的名字在函数声明所在的作用域内解析,而这些名字的求值过程发生在函数调用时。
函数返回
return语句
函数可分为有返回值的函数和无返回值的函数,函数的返回均使用返回语句。
返回值类型为void的函数不需要返回值,使用不跟表达式的return语句返回,其余有返回值的函数则需要后跟表达式的return语句来返回一个值。
函数返回值
返回一个值的方式和初始化一个变量或形参的方式完全一样:返回的值用于初始化调用点的一个临时量,该临时量就是函数调用的结果,是函数返回对象的拷贝。
函数返回时不要返回局部对象的引用或指针。
如果函数返回引用,那么其返回值可以作为左值,否则其返回值只能作为右值。返回值为左值的例子:
1 |
|
C++11规定了函数可以返回花括号包围的值的列表,列表返回后作为一个临时量可以初始化(或赋值)别的对象。
对于main函数,C++规定其返回值类型只能为int,并且没有为main函数指定return语句时,编译器将隐式地为main函数添加return 0语句,即默认返回0。
cstdlib头文件定义了两个宏来表示成功和失败,分别为EXIT_FAILURE和EXIT_SUCCESS,可以直接用于main函数返回值。
函数可以递归调用,但main函数不允许。
返回数组指针的函数
数组不能被拷贝因此函数不能返回数组,但可以返回数组的指针或引用来间接地返回数组。
使用类型别名
为某种特定的数组定义一个别名,使用别名作为返回类型:
1 |
|
两种方式都可以声明arrT为十个元素的整型数组的别名,那么就可以这样定义一个函数:
1 |
|
函数func的返回值类型即为arrT,即包含十个元素的整型数组。
直接声明
如果要直接声明一个返回数组的函数,返回的数组需要指定维度,维度需要声明在函数名的后面,即:
1 |
|
其中*function(parameter_list)必须要外加括号包含,如果没有括号,返回的将是指针的数组。
假如有以下声明:
1 |
|
可以按照以下的顺序来逐层理解该声明的含义:
- func(int i)表示调用func函数时需要一个int类型的实参。
- (*func(int i))意味着我们可以对函数调用的结果执行解引用操作(即返回的是数组的指针)。
- (*func(int i))[10]表示解引用func的调用将得到一个大小是10的数组。
- int (*func(int i))[10]表示数组中的元素是int类型。
尾置返回类型
这是在C++11标准中规定的,对返回类型复杂的函数很有用,即在返回值类型的位置使用auto标识符,然后在形参列表后使用->符号指定返回值类型,如:
1 |
|
这里星号*两边要加括号,如果不加括号的话int* [10]将表示指针的数组。
使用decltype
如果确定返回值是某个特定的数组或者与该数组类型一样,可以用decltype关键字运算得出返回值类型,如:
1 |
|
之所以函数名arrPtr前要加星号*是因为decltype运算得出的类型是个数组,即int[5],而返回的应该是数组的指针。
函数重载
重载函数
同一作用域内的几个函数名字相同但形参列表不同的函数叫重载函数。调用时编译器会根据传递的实参类型推断使用哪个函数。
重载的函数应当在形参数量或形参类型上有所不同。重点在于形参,返回值类型相同不相同没什么关系,不允许两个函数除了返回值类型以外什么都相同。
此外之前说过的,顶层const不影响传入函数的对象,可以用非const对象初始化const形参,因此如果两个函数的形参列表的区别仅仅在于const,那么也是不行的,而形参的区别如果是底层const则是可以的。
const_cast运算符在重载函数中可以发挥很大的作用,即可以使用它来创建一个函数的const版本和非const版本。
重载与作用域
重载函数只能在同一作用域中进行重载,如果在不同作用域中,名字的屏蔽机制会使得内层作用域中的函数名称屏蔽掉外层的同名函数,这样就不是函数重载了。如以下例子:
1 |
|
函数匹配
概述
函数匹配(又叫做重载确定)是指把函数调用与一组重载函数中的某一个具体函数关联起来的过程。编译器首先将调用的实参与重载集合中每一个函数的形参进行比较,然后根据比较的结果决定到底调用哪个函数。
当调用重载函数时有三种可能的结果:
- 编译器找到一个与实参最佳匹配的函数,并生成调用该函数的代码。
- 找不到任何一个函数与调用的实参匹配,此时编译器发出无匹配的错误信息。
- 有多于一个函数可以匹配,但是每一个都不是明显的最佳选择,此时也将发生错误,称为二义性调用。
在大多数情况下,确定使用哪个重载函数比较容易,而当几个重载函数的形参数量相等且某些形参类型可以由其他类型转换而来时,确定使用哪个函数就不那么容易了。
确定候选函数和可行函数
匹配的第一步就是选定本次调用的对应重载函数集,集合中的函数称为候选函数。
候选函数的两个特征:
- 与被调用函数同名。
- 其声明在调用点可见。
匹配的第二步是考察本次调用使用的实参,然后从候选函数中选出能被这组实参调用的函数,这些函数构成的集合叫做可行函数。
可行函数的两个特征:
- 形参数量与本次调用提供的实参数量相等。
- 每个实参的类型与对应的形参类型相同,或能转换成形参的类型。
在检查形参类型时是需要考虑默认实参的,假如有调用f(1),对于函数f(int)和f(double,double=3.14)都是可行的。
寻找最佳匹配
匹配的第三步是从可行函数中选择与本次调用最匹配的函数,基本原则是实参类型与形参类型越接近则越匹配。
如果只有一个实参,那么寻找只有一个形参的重载函数并且来匹配,如果找不到只有一个形参的函数(没有可行函数)则匹配失败;如果找到不止一个仅有一个形参的重载函数且它们的形参都不与调用的实参类型相同但都是兼容的,则引发二义性问题,匹配失败。
如果不止一个实参,则从可行函数中依次检查形参,如果一个函数的每个实参的匹配都不劣于其他可行函数的匹配,且至少有一个实参的匹配优于其他函数,那么该匹配成功,否则找不到最佳匹配,匹配失败。
一个良好的系统不应该在找不到最佳匹配的情况下通过类型转换勉强调用某个函数,应该因二义性而拒绝匹配。
实参类型转换
为确定最佳匹配,编译器将实参类型转换到形参类型的方式划分了等级:
(1)精确匹配:
- 实参类型与形参类型相同。
- 实参从数组类型、函数类型转换为对应的指针类型。
- 向实参添加顶层const或删除实参的顶层const。
(2)通过const转换实现的匹配。
(3)通过类型提升实现的匹配。
(4)通过算术类型转换或指针转换实现的匹配。
(5)通过类的类型转换实现的匹配。
如果两个重载函数的区别在于它们的引用类型的形参是否引用了const,或者指针类型的形参是否指向const,则当调用发生时编译器通过实参是否是常量来决定选择哪个函数。其实原因就是不能使非常量的引用绑定到常量上,这样会非法取得写权限。
特殊函数
内联函数
内联函数适用于较小型的函数,当定义为内联函数时,函数将在函数调用位置展开为函数代码。定义内联函数只需在最前面加上inline关键字。
一般来说,内联机制用于优化规模较小、流程直接、频繁调用的函数。
很多编译器都不支持内联递归函数,而且一个大于等于75行的函数也不大可能在调用点内联地展开。
内联函数与普通函数不同,可以多次定义,但是它的所有定义必须完全一致,不能出现二义性,因此常常直接将内联函数的定义放在头文件里。
constexpr函数
constexpr函数是指能用于常量表达式的函数,其返回值可以构成常量表达式的一部分。定义constexpr函数的方法与其他函数类似,不过要遵循以下约定:
- 函数的返回类型及所有形参的类型都必须是常量表达式。
- 函数体中必须有且只有一条return语句。
执行此类函数的初始化任务时,编译器把对constexpr函数的调用替换成其结果值。为了能在编译过程中随时展开,constexpr函数被隐式地指定为内联函数,因此和内联函数一样可以拥有多个相同的定义。
constexpr函数体内也可以包含其他语句,只要这些语句在运行时不执行任何操作就行,如constexpr函数中可以有空语句、类型别名以及using声明。
函数指针
函数指针的定义
函数指针指向某种特定类型的函数,而函数的类型由它的返回值和形参类型决定,与函数名无关。如:
1 |
|
该函数的类型就是bool (const string &, const string &),要声明一个该类型的函数指针,其实只需要把函数名换成指针名就可以了,即:
1 |
|
使用函数指针
通过指针调用函数
像数组一样,数组名其实就是数组的指针,而函数名其实也就是函数的指针,如:
1 |
|
都可以将函数lengthCompare的指针赋值给指针pf,其中取地址符&是可选的,有没有都行。
而指向函数的指针也可以像函数一样直接调用,解不解引用也没有关系:
1 |
|
这两种方式的调用是等价的。
函数指针形参
函数指针可以作为形参传递给函数,:
1 |
|
然后我们就可以将函数作为实参来调用函数了:
1 |
|
返回函数指针
建议使用类型别名来定义:
1 |
|
这里F是函数类型,PF才是指针类型。
我们使用函数名赋值或者传参的时候,函数名会自动解释为指针,但是定义返回值的时候要显式地指定返回的类型是函数的指针,即如下两种定义:
1 |
|
或者用比较麻烦的方式直接定义:
1 |
|
当然也可以用尾置的定义方式:
1 |
|
函数指针的类型
不同类型的函数指针没有转换的规则,无法进行类型转换,但是和其他对象的指针一样,允许给任何类型的指针赋一个空值nullptr,或者0值,都表示该指针没有指向任何一个函数。
而重载函数的返回值和形参列表可能不同,但返回值和形参列表决定了一个函数指针的类型,因此每一个重载函数的类型实际上是不同的,那么为重载函数定义指针的时候,应当与重载函数中的某一个精确匹配。
同样,当我们知道函数返回的函数指针是哪个或者和哪个函数同一类型的话,我们可以直接用decltype运算符来代替类型书写。