《C++Primer(第5版)》第六章笔记

本文深入讲解C++函数的基础知识,包括函数的组成部分、参数传递方式、返回类型与return语句的使用,以及函数重载、内联函数和constexpr函数的概念。此外,还探讨了函数指针的应用场景。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

6.1 函数基础

一个典型函数包括:

  1. 返回类型
  2. 函数名
  3. 形参列表
  4. 函数体

我们通过调用运算符来执行函数。调用运算符的形式是一对圆括号它作用于一个表达式,该表达式是函数或者指向函数的指针:圆括号之内是一个用逗号隔开的实参列表,我们用实参初始化函数的形参。调用表达式的类型就是函数的返回类型。

编写函数

举个例子:1~n的阶乘

int fact(int val){
	int ret = 1;
	while (val > 1)
		ret *= val--;
	return ret;
}

调用函数

int main(){
	int j = fact(5);
	cout << "5! is " << j << endl;
	return 0;
}

函数的调用完成两项工作:一是用实参初始化函数对应的形参,二是将控制权转移给被调用函数。此时,主调函数的执行被暂时中断,被调函数开始执行。
执行函数的第一步是(隐式地)定义并初始化它的形参。因此,当调用fact函数时,首先创建一个名为val的int变量,然后将它初始化为调用时所用的实参5。当遇到一条return语句时函数结束执行过程。

和函数调用一样,return语句也完成两项工作:一是返回retrn语句中的值(如果有的话),二是将控制权从被调函数转移回主调函数。函数的返回值用于初始化调用表达式的结果,之后继续完成调用所在的表达式的剩余部分。

形参和实参

实参是形参的初始值。
实参的类型必须与对应的形参类型匹配或提供一个能转换成形参类型的实参。

函数的形参列表

函数的形参列表可以为空,但是不能省略。要想定义一个不带形参的函数,最常用的办法是书写一个空的形参列表。 不过为了与C语言兼容,也可以使用关键字void表示函数没有形参:

void f1(){/* ... */}
void f2(void){/* ... */}

函数返回类型

大多数类型都能用作函数的返回类型。一种特殊的返回类型是void,它表示函数不返回任何值。函数的返回类型不能是数组类型或函数类型,但可以是指向数组或函数的指针。

6.1.1 局部对象

名字有作用域,对象有生命周期

  • 名字的作用域是程序文本的一部分,名字在其中可见
  • 对象的生命周期是程序在执行过程中该对象存在的一段时间

如我们所知,函数体是一个语句块。块构成一个新的作用域,我们可以在其中定义变量。形参和函数体内部定义的变量统称为局部变量。它们对函数而言是“局部"的,仅在函数的作用域内可见,同时局部变量还会隐藏在外层作用域中同名的其他所有声明中。

自动对象

形参是一种自动对象。函数开始时为形参申请存储空间,因为形参定义在函数体作用域之内,所以一旦函数终止,形参也就被销毁。
我们把只存在于块执行期间的对象称为自动对象。当块的执行结束后,块中创建的自动对象的值就变成未定义的了。

局部静态对象

某些时候,有必要令局部变量的生命周期贯穿函数调用及之后的时间。可以将局部变量定义成static类型从而获得这样的对象。局部静态对象(local static object)在程序的执行路径第一次经过对象定义语句时初始化,并且直到程序终止才被销毁,在此期间即使对象所在的函数结束执行也不会对它有影响

举个例子,下面的函数统计他自己被调用了多少次:

size_t countt_calls(){
	static size_t ctr = 0;
	return ++ctr;
}
int main(){
	for (size_t i = 0; i != 10 ++i) cout << count_calls() << endl;
	return 0;
}

6.1.2 函数声明

和其他名字一样,函数的名字也必须在使用之前声明。类似于变量,函数只能定义一次,但可以声明多次。唯一的例外是如果一个函数永远也不会被我们用到,那么它可以只有声明没有定义。

函数的声明和函数的定义非常类似,唯一的区别是函数声明无须函数体,用一个分号替代即可。

因为函数的声明不包含函数体,所以也就无须形参的名字。事实上,在函数的声明中经常省略形参的名字。尽管如此,写上形参的名字还是有用处的,它可以帮助使用者更好地理解函数的功能:

void print(vector<int>::const_iterator beg, vector<int>::const_iterator end);

函数声明也称作函数原型

在头文件中进行函数声明

之前我们建议变量在头文件中声明,在源文件中定义。与之类似,函数也应该在头文件中声明而在源文件中定义
看起来把函数的声明直接放在使用该函数的源文件中是合法的,也比较容易被人接受;但是这么做可能会很烦琐而且容易出错。相反,如果把函数声明放在头文件中,就能确保同一函数的所有声明保持一致。 而且一旦我们想改变函数的接口,只需改变一条声明即可。
定义函数的源文件应该把含有函数声明的头文件包含进来,编译器负责验证函数的定义和声明是否匹配。

6.1.3 分离式编译

随着程序越来越复杂,我们希望把程序的各个部分分别存储在不同文件中。例如,可以把函数存在一个文件里,把使用这些函数的代码存在其他源文件中。为了允许编写程序时按照逻辑关系将其划分开来,C++语言支持所谓的分离式编译。分离式编译允许我们把程序分割到几个文件中去,每个文件独立编译。

编译和链接多个源文件

假设fact函数的定义位于一个fact.dpp的文件中,它的声明位于名为Chapter6.h的头文件中。另外,我们在名为factMain.cpp文件中创建main函数,main函数调用fact函数。

6.2 参数传递

当形参是引用类型时,我们说它对应的实参被引用传递或者函数被传引用调用。和其他引用一样,引用形参也是它绑定的对象的别名;也就是说,引用形参是它对应的实参的别名。
当实参的值被拷贝给形参时,形参和实参是两个相互独立的对象。我们说这样的实参被值传递或者函数被传值调用

6.2.1 传值参数

对形参的改动不会影响实参

指针引用

指针的行为和其他非引用类型一样。当执行指针拷贝操作时,拷贝的是指针的值。拷贝之后,两个指针是不同的指针。因为指针使我们可以间接地访问它所指的对象,所以通过指针可以修改它所指对象的值:

viud reset(int *ip){
	*ip = 0;	// 改变了指针ip所指对象的值
	ip = 0;		// 实参未被改变
}

6.2.2 传引用参数

对于引用的操作实际上是作用在被引用的对象

void reset(int &i){
	i = 0;		// 改变了i所引用的对象的值
}

使用引用避免拷贝

拷贝大的类类型对象或者容器对象比较低效,甚至有的类类型(包括IO类型在内)根本就不支持拷贝操作。当某种类型不支持拷贝操作时,函数只能通过引用形参访问该类型的对象。
如果函数无须改变引用形参的值,最好将其声明为常量引用。

使用引用形参返回额外信息

一个函数只能返回一个值,然而有时函数需要同时返回多个值,引用形参为我们一次返回多个结果提供了有效的途径。举个例子,我们定义一个名为find_char的函数,它返回在string对象中某个指定字符第一次出现的位置。 同时,我们也希望函数能返回该字符出现的总次数。
该如何定义函数使得它能够既返回位置也返回出现次数呢?一种方法是定义一个新的数据类型,让它包含位置和数量两个成员。还有另一种更简单的方法,我们可以给函数传入一个额外的引用实参,令其保存字符出现的次数:

string::size_type find_char(const string &s, cahr c, string::size_type &occurs){
	auto ret = s.size();
	occurs = 0;
	for (decltype(ret) i = 0; i != s.size(); ++i){
		if (s[i] == c) {
			ret = i;
		}
		++occurs;
	}
	return ret;
}

6.2.3 const形参和实参

和其他初始化过程一样,当用实参初始化形参时会忽略掉顶层const,换句话说,形参的顶层const被忽略掉了。当形参有顶层const时,传给它常量对象或者非常量对象都是可以的。 但是忽略掉形参的顶层const可能产生意想不到的结果:

void fcn(const int i){}
void fcn(int i){}

在C++语言中,允许我们定义若干具有相同名字的函数,不过前提是不同函数的形参列表应该有明显的区别。因为顶层const被忽略掉了,所以在上面的代码中传入两个fcn函数的参数可以完全一样。因此第二个fcn是错误的,尽管形式上有差异,但实际上它的形参和第一个fcn的形参没什么不同

指针或引用形参与const

形参的初始化方式和变量的初始化方式是一样的。我们可以使用非常量初始化一个底层const对象,但是反过来不行;同时一个普通的引用必须用同类型的对象初始化。

尽量使用常量引用

把函数不会改变的形参定义成(普通的)引用是一种比较常见的错误,这么做带给函数的调用者一种误导, 即函数可以修改它的实参的值。此外,使用引用而非常量引用也会极大地限制函数所能接受的实参类型。就像刚刚看到的,我们不能把const对象、字面值或者需要类型转换的对象传递给普通的引用形参。

// 不良设计:第一个形参的类型应该是const string&
string::size_type find_char(string &s, char c, string::size_type &occurs);

则只能讲find_char函数作用于string对象。类似下面这样的调用

find_char("Hello World", 'o', ctr);

将在编译时发生错误

还有一个更难察觉的问题,假如其他函数(正确地)将它们的形参定义成常量引用,那么第二个版本的find_char无法在此类函数中正常使用。举个例子,我们希望在一个判断string对象是否是句子的函数中使用find_char:

bool is_sentence(const string &s){
	string::size_tyoe ctr = 0;
	return find_char(s, '.', ctr) == s.size() - 1 && ctr == 1;
}

如果find_char的第一个形参类型是string&,那么上面这条调用find_char的语句将在编译时发生错误。原因在于s是常量引用,但find_char被(不正确地)定义成只能接受普通引用。

6.2.4 数组形参

数组的两个特殊性质对我们定义和使用作用在数组上的函数有影响,这两个性质分别是:不允许拷贝数组以及使用数组时(通常)会将其转换成指针。因为不能拷贝数组,所以我们无法以值传递的方式使用数组参数。因为数组会被转换成指针,所以当我们为函数传递一个数组时,实际上传递的是指向数组首元素的指针。

// 等价
void print(const int*);
void print(const int[]);
void print(const int[10]);

和其他使用数组的代码一样,以数组作为形参的函数也必须确保使用数组时不会越界。

因为数组是以指针的形式传递给函数的,所以一-开始函 数并不知道数组的确切尺寸,调用者应该为此提供一些额外的信息。 管理指针形参有三种常用的技术。

使用标记指定数组长度

如C语言风格的字符串
``cpp
void print(const char *cp){
if (cp)
while (*cp) cout << *cp++ ;
}

对于int这种所有取值都是合法值的数据就不太有效了

**使用标准库规范**

管理数组实参的第二种技术是传递指向数组首元素和尾后元素的指针,这种方法受到了标准库技术的启发。使用该方法,我们可以按照如:下形式输出元素内容:
```cpp
void print(const int *beg, const int *end){
	while(beg != end) const << *beg++ << endl;
}

只要调用者能正确地计算指针所指的位置,那么上述代码就是安全的。

显式传递一个表示数组大小的形参
第三种管理数组实参的方法是专门定义一个表示数组大小的形参,在C程序和过去的C++程序中常常使用这种方法。使用该方法,可以将print函数重写成如下形式:

void print(const int ia[], size_t size) {
	for (size_t i = 0; i != size; ++i) {
		cout << ia[i] << endl;
	}
}

数组形参和const

我们的三个print函数都把数组形参定义成了指向const的指针当函数不需要对数组元素执行写操作的时候,数组形参应该是指向const的指针。只有当函数确实要改变元素值的时候,才把形参定义成指向非常量的指针。

数组引用形参

C++语言允许将变量定义成数组的引用,基于同样的道理,形参也可以是数组的引用。此时,引用形参绑定到对应的实参上,也就是绑定到数组上:

// 正确:形参是数组的引用,维度是类型的一部分,这里表示arr是具有10个整数的整型数组的引用
void print(int (&arr)[10]){		// 括号必不可少,否则int &arr[10]意味着将arr声明成引用的数组
	for (auto elem : arr)
		cout << elem << endl;
}

因为数组的大小是构成数组类型的一部分, 所以只要不超过维度,在函数体内就可以放心地使用数组。但是,这一用法也无形中限制了print函数的可用性,我们只能将函数作用于大小为10的数组:

int i = 0, j[2] = {0, 1};
print(&i);	// 错误
print(&j);	// 错误

传递多维数组

C++语言没有实际上的多维数组,其实是数组的数组

和所有数组一样, 当将多维数组传递给函数时,真正传递的是指向数组首元素的指针。因为我们处理的是数组的数组,所以首元素本身就是一个数组,指针就是一个指向数组的指针。数组第二维(以及后面所有维度)的大小都是数组类型的一部分,不能省略:

// matrix指向数组的首元素,该数组的元素是由10个整数构成的数组
void print(int (*matrix)[10], int rowSize){/* ... */}

matrix两端的括号不能少

int *matrix[10];	// 10个指针构成的数组 
int(*matrix)[10];	// 指向含有10个整数的指针

我们也可以使用数组的语法定义函数,此时编译器会一如既往地忽略掉第一个维度,所以最好不要把它包括在形参列表内:

void print(int matrix[][10], int rowSize){/* ... */}

matrix的声明看起来像一个二维数组,实际上形参是指向含有10个整数的数组的指针。

6.2.5 main:处理命令行选项

main函数是演示C++程序如何向函数传递数组的好例子。到目前为止,我们定义的main函数都只有空形参列表。然而,有时我们确实需要给main传递实参,一种常见的情况是用户通过设置一组选项来确定函数所要执行的操作。例如,假定main函数位于可执行文件prog之内,我们可以向程序传递下面的选项:


```prog -d -o ofile data0

这些命令行选项通过两个(可选的)形参传递给main函数:
```cpp
int main(int argc, char *argc[]){ ... }

第二个形参argv是一个数组,它的元素是指向C风格字符串的指针:第一个形参argc表示数组中字符串的数量。因为第二个形参是数组,所以main函数也可以定义成:

int main(int argc, char **argc[]){ ... }

其中argv指向char*。

当实参传给main函数之后,argv的第一个元素指向程序的名字或者一个空字符串,接下来的元素依次传递命令行提供的实参。最后一个指针之后的元素值保证为0。
以上面提供的命令行为例,argc应该等于5,argv应该包含如下的C风格字符串:

argv[0] = "prog";
argv[1] = "-d";
argv[2] = "-o";
argv[3] = "ofile";
argv[4] = "data0";
argv[5] = 0;

当使用argv中的实参时,一定要记得可选的实参从argv[1]开始; argv[0]保存程序的名字,而非用户输入。

6.2.6 含有可变形参的函数

有时我们无法提前预知应该向函数传递几个实参。例如,我们想要编写代码输出程序产生的错误信息,此时最好用同一个函数实现该项功能,以便对所有错误的处理能够整齐划一。然而,错误信息的种类不同,所以调用错误输出函数时传递的实参也各不相同。
为了编写能处理不同数量实参的函数,C++11新标准提供了两种主要的方法:如果所有的实参类型相同,可以传递一个名为initializer_list的标准库类型;如果实参的类型不同,我们可以编写一种特殊的函数,也就是所谓的可变参数模板.
C++还有一种特殊的形参类型(即省略符),可以用它传递可变数量的实参。本节将简要介绍省略符形参,不过需要注意的是,这种功能一般只用于与C函数交互的接口程序。

initializer_list形参

如果函数的实参数量未知但是全部实参的类型都相同,我们可以使用initializer_list类型的形参。initializer_list 是一种标准库类型,用于表示某种特定类型的值的数组。initializer_list类型定义在同名的头文件中,它提供的操作如表所示。

在这里插入图片描述
和vector一样,initializer_list也是一种模板类型。定义initializer_list对象时,必须说明列表中所含元素的类型:

initializer_list<string> ls;	// 元素类型是string	
initializer_list<int> li;		// 元素类型是int

和vector不一样的是,initializer_list对象中的元素永远是常量值,我们无法改变initializer_list对象中元素的值

void error_msg(initializer_list<string> il){
	for (auto beg = il.begin(); beg != il.end(); ++beg)
		cout << *beg << " ";
	cout << endl;
}

含有initializer_list形参的函数也可以同时拥有其他形参。例如,调试系统能有个名为ErrCode的类用来表示不同类型的错误,因此我们可以改写之前的程序,使其包含一个initializer_list形参和一个ErrCode形参:

void error_msg(ErrCode e, initiallizer_list<string> il){
	cout << e.msg() << ": ";
	for (const auto &elem : il)
		cout << elem << " ";
	cout << endl;
}

省略符形参

省略符形参是为了便于C++程序访问某些特殊的C代码而设置的,这些代码使用了名为varargs的C标准库功能。通常,省略符形参不应用于其他目的。你的C编译器文档会描述如何使用varargs。

省略符形参只能出现在形参列表的最后-一个位置,它的形式无外乎以下两种:

void foo(parm_list, ...);
void foo(...);

第一种形式指定了foo函数的部分形参的类型,对应于这些形参的实参将会执行正常的类型检查。省略符形参所对应的实参无须类型检查。在第一种形式中, 形参声明后面的逗号是可选的。

6.3 返回类型和return语句

return语句终止当前正在执行的函数并将控制权返回到调用该函数的地方。

6.3.1 无返回值函数

没有返回值的return语句只能用在返回类型是void的函数中。返回void的函数不要求非得有return语句,因为在这类函数的最后一句后面会隐式地执行return。

void swap(int &v1, int &v2){
	if (v1 == v2) return;
	int tmp = v2;
	v2 = v1;
	v1 = tmp;
	// 此处无需显式的return
}

6.3.2 有返回值函数

只要函数的返回类型不是void,则该函数内的每条return语句必须返回一个值。return语句返回值的类型必须与函数的返回类型相同,或者能隐式地转换成函数的返回类型。

在含有return语句的循环后面应该也有一条return语句,如果没有的话该程序就是错误的。很多编译器都无法发现此类错误。

值是如何被返回的

返回一个值的方式和初始化一个变量或形参的方式完全一样:返回的值用于初始化调用点的一个临时量,该临时量就是函数调用的结果。

不要返回局部对象的引用或者指针

函数完成后,它所占用的存储空间也随之被释放掉。因此,函数终止意味着局部变量的引用将指向不再有效的内存区域:

const string &manip(){
	string ret;
	if (!ret.empty())
		return ret;			// 错误:返回局部对象的引用
	else
		return "Empty";		// 错误:"Empty"是一个局部临时量
}

如前所述,返回局部对象的引用是错误的:同样,返回局部对象的指针也是错误的。一旦函数完成,局部对象被释放,指针将指向一个不存在的对象。

返回类类型的函数和调用运算符

和其他运算符一样, 调用运算符也有优先级和结合律。调用运算符的优先级与点运算符和箭头运算符相同,并且也符合左结合律。因此,如果函数返回指针、引用或类的对象,我们就能使用函数调用的结果访问结果对象的成员。例如,我们可以通过如下形式得到较短string对象的长度:

// 调用string对象的size成员,该string对象是由shorterString函数返回的
auto SZ = shorterString(s1, s2) .size() ;

因为上面提到的运算符都满足左结合律,所以shorterString 的结果是点运算符的左侧运算对象,点运算符可以得到该string对象的size成员,size 又是第二个调用运算符的左侧运算对象。

引用返回左值

函数的返回类型决定函数调用是否是左值(当对象被用作左值的时候,用的是对象的身份(在内存中的位置);当对象被用作右值的时候,用的是对象的值(内容))。调用一个返回引用的函数得到左值,其他返回类型得到右值。可以像使用其他左值那样来使用返回引用的函数的调用,特别是,我们能为返回类型是非常量引用的函数的结果赋值:

char &get_val(string &str, string::size_type ix){
	return str[ix];
}
int main(){
	string s("a value");
	cout << s << endl;
	get_val(s, 0) = 'A';
	cout << s << endl;
	return 0;
}

把函数调用放在赋值语句的左侧可能看起来有点奇怪,但其实这没什么特别的。返回值是引用,因此调用是个左值,和其他左值一样它也能出现在赋值运算符的左侧。
如果返回类型是常量引用,我们不能给调用的结果赋值,这一点和我们熟悉的情况是一样的:

shorterstring("hi", "bye") = "X"; 	// 错误:返回值是个常量

列表初始化返回值

C++11新标准规定,函数可以返回花括号包围的值的列表。类似于其他返回结果,此处的列表也用来对表示函数返回的临时量进行初始化。如果列表为空,临时量执行值初始化;否则,返回的值由函数的返回类型决定。
举个例子,回忆6.2.6节的error_msg函数,该函数的输入是一组可变数量的string实参,输出由这些string对象组成的错误信息。在下面的函数中,我们返回一个vector对象,用它存放表示错误信息的string对象:

vector<string> process(){
	// ...
	// expected 和 actual 是string对象
	if (expected.empty())
		return {};
	else if (expected == actual)
		return {"functionX", "okay"};
	else
		return {"functionX", expected, actual};
}

如果函数返回的是内置类型,则花括号包围的列表最多包含一个值,而且该值所占空间不应该大于目标类型的空间。如果函数返回的是类类型,由类本身定义初始值如何使用。

主函数main的返回值

之前介绍过,如果函数的返回类型不是void,那么它必须返回一个值。但是这条规则有个例外:我们允许main函数没有return语句直接结束。如果控制到达了main函数的结尾处而且没有return语句,编译器将隐式地插入一条返回0的return语句。

main函数的返回值可以看做是状态指示器。返回0表示执行成功,返回其他值表示执行失败,其中非0值的具体含义依机器而定。

递归

如果一个函数调用了它自身,不管这种调用是直接的还是间接的,都称该函数为递归函数。

在递归函数中,一定有某条路径是不包含递归调用的:否则,函数将“永远"递归下去,换句话说,函数将不断地调用它自身直到程序栈空间耗尽为止。我们有时候会说这种函数含有递归循环。

6.3.3 返回数组指针

因为数组不能被拷贝,所以函数不能返回数组。不过,函数可以返回数组的指针或引用。虽然从语法上来说,要想定义一个返回数组的指针或引用的函数比较烦琐,但是有一些方法可以简化这一任务,其中最直接的方法是使用类型别名:

typedef int arrT[10];	// arrT是一个类型别名,它表示的类型是含有10个整数的数组
using arrT = int[10];	// arrT的等价声明
arrT* func(int i);		// func返回一个指向10个整数的数组的指针

其中arrT是含有10个整数的数组的别名。因为我们无法返回数组,所以将返回类型定义成数组的指针。因此,func函数接受一个int实参,返回一个指向包含10个整数的数组的指针。

声明一个返回数组指针的函数

要想在声明func时不使用类型别名,我们必须牢记被定义的名字后面数组的维度:

int arr[10];			// arr是一个含有是个整数的数组
int *p1[10];			// p1是一个含有十个指针的数组
int (*p2)[10] = &arr;	// p2是一个指针,它指向含有十个整数的数组

和这些声明一样,如果我们想定义一个返回数组指针的函数,则数组的维度必须跟在函数名字之后。然而,函数的形参列表也跟在函数名字后面且形参列表应该先于数组的维度。因此,返回数组指针的函数形式如下所示:
Type (*function(parameter_list)) [dimension]
举个具体点的例子,下面这个func函数的声明没有使用类型别名:

int (*func(int i)) [10];

可以按照以下的顺序来逐层理解该声明的含义:

  • func(int i)表示调用func函数时需要一个int类型的实参。
  • (*func(int i))意味着我们可以对函数调用的结果执行解引用操作。
  • (*func(int i)) [10]表示解引用func的调用将得到一个大小是10的数组。
  • int (*func(int i)) [10]表示数组中的元素是int类型。

使用尾置返回类型

在C++11新标准中还有一种可以简化上述func声明的方法,就是使用尾置返回类型。 任何函数的定义都能使用尾置返回,但是这种形式对于返回类型比较复杂的函数最有效,比如返回类型是数组的指针或者数组的引用。尾置返回类型跟在形参列表后面并以一个->符号开头。为了表示函数真正的返回类型跟在形参列表之后,我们在本应该出现返回类型的地方放置一个auto

// func接受一个int类型的实参,返回一个指针,该指针指向含有10个整数的数组
auto func(int i) -> int(*) [10] ;

因为我们把函数的返回类型放在了形参列表之后,所以可以清楚地看到func函数返回的是一个指针,并且该指针指向了含有10个整数的数组。

使用decltype

还有一种情况,如果我们知道函数返回的指针将指向哪个数组,就可以使用decltype关键字声明返回类型。例如,下面的函数返回一个指针,该指针根据参数i的不同指向两个已知数组中的某一个:

int odd[] = {1, 3, 5, 7, 9};
int enen[] = {0, 2, 4, 6, 8};
// 返回一个指针,该指针指向含有5个整数的数组
decltype(odd) *arrPtr(int i){
	return (i % 2) ? &odd : & even;		// 返回一个指向数组的指针
}

6.4 函数重载

如果同一作用域内的几个函数名字相同但形参列表不同,我们称之为重载函数。例如,之前我们定义了几个名为print的函数:

void print(const char *cp);
void print(const int *beg, const int *end);
void print(const int ia[], szie_t size);

不允许两个函数除了返回类型外其他所有的要素都相同。假设有两个函数,它们的形参列表一样但是返回类型不同,则第二个函数的声明是错误的。

main函数不能重载

判断两个形参的类型是否相同

有时候两个形参列表看起来不一样,但实际上是相通的:

Record lookup(const Account &acct);
Record lookup(const Account&);		// 省略了形参的名字

typedef Phone Telno;
Record lookup(const Phone&);
Record lookup(const Telno&);	// Telno和Phone的类型相同

void print(int a);
void print(const int a);	// 不行

void print(int &a);
void print(const int &a);	// 行

重载和const形参

顶层const不影响传入函数的对象。一个拥有顶层const的形参无法和另一个没有顶层const的形参区分开

void print(int a);
void print(const int a);	// 重复声明

void print(int* a);
void print(int* const a);	// 重复声明

在这两组函数声明中,每一组的第二个声明和第一个声明是等价的。

另一方面,如果形参是某种类型的指针或引用,则通过区分其指向的是常量对象还是非常量对象可以实现函数重载,此时的const是底层的:

void print(int &a);			// 作用于int的引用
void print(const int &a);	// 新函数,作用于int的常量引用

void print(int* a);			// 新函数,作用于指向int的指针
void print(const int* a);	// 新函数,作用于指向int的常量指针

建议:何时不应该重载函数

尽管函数重载能在一定程度上减轻我们为函数起名字、记名字的负担,但是最好只重载那些确实非常相似的操作。有些情况下,给函数起不同的名字能使得程序更易理解。举个例子,下面是几个负责移动屏幕光标的函数:

Screen& moveHome();
Screen& moveAbs(int, int);
Screen& moveRel(int, int, string direction);

乍看上去,似乎可以把这组函数统一命名为move,从而实现函数的重载:

Screen& move();
Screen& move(int, int);
Screen& move(int, int, string direction);

其实不然,重载之后这些函数失去了名字中本来拥有的信息。尽管这些函数确实都是在移动光标,但是具体移动的方式却各不相同。以moveHome为例,它表示的是移动光标的一种特殊实例。一般来说,是否重载函数要看哪个更容易理解。

// 哪个更容易理解?
myScreen.moveHome();
myScreen.move();

const_cast和重载

const_cast在重载函数的情境中最有用。举个例子:

const string &shorterString(const string &s1, const sring &s2){
	return s1.size() <= s2.size() ? s1 : s2;
}

这个函数的参数和返回类型都是const string的引用。我们可以对两个非常量的string实参调用这个函数,但返回的结果仍然是const string的引用。因此我们需要一种新的shorterString函数,当它的实参不是常量时,得到的结果是一个普通的引用,使用const_ cast可以做到这一点:

string &shorterString(string &s1, string &s2){
	auto &r = shorterString(const_cast<const string&>(s1)), const_cast<const string&>(s2));
	return const_cast<string&>(r);
}

在这个版本的函数中,首先将它的实参强制转换成对const的引用,然后调用了shorterString函数的const版本。const版本返回对const string的引用,这引用事实上绑定在了某个初始的非常量实参上。因此,我们可以再将其转换回一个普通的string&,这显然是安全的。

调用重载的函数

  • 编译器找到一个与实参最佳匹配的函数,并生成调用该函数的代码。
  • 找不到任何–个函数与调用的实参匹配,此时编译器发出无匹配的错误信息。
  • 有多于一个函数可以匹配,但是每一个都不是明显的最佳选择。此时也将发生错误,称为二义性调用

6.4.1 重载与作用域

如果我们在内层作用域中声明名字,它将隐藏外层作用域中声明的同名实体。在不同的作用域中无法重载函数名。

void print(const string &);
void print(double);

void fooBar(int ival){
	void print(int);		// 新作用域,隐藏了之前的print
	print("Hello");			// 错误
}

6.5 特殊用途语言特性

6.5.1 默认实参

某些函数有这样一种形参,在函数的很多次调用中它们都被赋予一个相同的值,此时,我们把这个反复出现的值称为函数的默认实参。调用含有默认实参的函数时,可以包含该实参,也可以省略该实参。

使用默认实参调用函数

string window;
window = screen();				// 等价于screen(24, 80, ' ');
window = screen(66);			// 等价于screen(66, 80, ' ');
window = screen(66, 256);		// screen(66, 256, ' ');
window = screen(66, 256, '#');	// screen(66, 256, '#');

函数调用时实参按其位置解析,默认实参负责填补函数调用缺少的尾部实参(靠右侧位置)。例如,要想覆盖backgrnd的默认值,必须为ht和wid提供实参:

window = screen(,,'?');	// 错误:只能省略尾部的实参
window = screen('?');	// 调用screen('?', 80, ' ');

虽然第二个调用是合法调用,但是它的实际效果却与书写的意图不符。

默认实参声明

对于函数的声明来说,通常的习惯是将其放在头文件中,并且一个函数只声明一次,但是多次声明同一个函数也是合法的。不过有一点需要注意,在给定的作用域中一个形参只能被赋予一次默认实参。换句话说,函数的后续声明只能为之前那些没有默认值的形参添加默认实参,而且该形参右侧的所有形参必须都有默认值。假如给定:

// 表示高度和宽度的形参没有默认值
string screen(sz, sz, char = ' ' );

我们不能修改一个 已经存在的默认值:

string screen(sz, sz, char = '*');	// 错误: 重复声明

但是可以按照如下形式添加默认实参:

string screen(sz = 24, sz = 80, char);	// 正确

通常,应该在函数声明中指定默认实参,并将该声明放在合适的头文件中。

默认实参初始值

局部变量不能作为默认实参。除此之外,只要表达式的类型能转换成形参所需的类型,该表达式就能作为默认实参。

6.5.2 内联函数和constexpr函数

调用函数一般比求等价表达式的值要慢一些。 在大多数机器上,一次函数调用其实包含着一系列工作:

  1. 调用前要先保存寄存器,并在返回时恢复。
  2. 可能需要拷贝实参。
  3. 程序转向一个新的位置继续执行。

内联函数可以避免函数调用的开销

将函数指定为内联函数(inline), 通常就是将它在每个调用点上“内联地”展开。假设我们把shorterString函数定义成内联函数,则如下调用:

cout << shorterString(s1, s2) << endl;

将在编译过程中展开类似于下面的形式

cout << (s1.size < s2.size() > s1 : s2) << endl;

从而消除了shorterString函数运行时的开销

在函数分返回类型前面加上关键字inline,这样就科技将他声明为内联函数了:

inline const string& shorterString(const string &s1, const string &s2){
	return s1.size() <= s2.size() ? s1 : s2;
}

一般来说,内联机制用于优化规模较小、流程直接、频繁调用的函数。很多编译器都不支持内联递归函数,而且一个75行的函数也不大可能在调用点内联地展开。

constexpr函数

constexpr函数是指能用于常量表达式的函数。定义constexpr函数的方法与其他函数类似,不过要遵循几项约定:

  1. 函数的返回类型及所有形参的类型都得是字面值类型
  2. 函数体中必须有且只有一条return语句
constexpr int nex_sz(){return 42;}
constexpr int foo = new_sez();		// 正确:foo是一个常量表达式

执行该初始化任务时,编译器把对constexpr函数的调用替换成其结果值。为了能在编译过程中随时展开,constexpr函数被隐式地指定为内联函数。

constexpr函数体内也可以包含其他语句,只要这些语句在运行时不执行任何操作就行。例如,constexpr 函数中可以有空语句、类型别名以及using声明。
我们允许constexpr的返回值并非一个常量:

// 如果arg是常量表达式时,它的返回值也是常量表达式
constexpr size_t scale(size_t cnt) {return new_sz() *cnt;}

当scale的实参是常量表达式时,它的返回值也是常量表达式;反之则不然:

int arr[scale(2)];	// 正确:scale(2)是常量表达式
int i = 2;			// i不是常量表达式
int a2[scale(i)];	// 错误:scale(i)不是常量表达式

constexpr函数不一定返回常量表达式

把内联函数和constexpr函数放在头文件内

6.5.3 调试帮助

C++程序员有时会用到一种类似于头文件保护的技术,以便有选择地执行调试代码。基本思想是,程序可以包含一些用于调试的代码,但是这些代码只在开发程序时使用。当应用程序编写完成准备发布时,要先屏蔽掉调试代码。这种方法用到两项预处理功能:assertNDEBUG

assert预处理宏

assert是一种预处理宏( preprocessor marco)。所谓预处理宏其实是-一个预处理变量,它的行为有点类似于内联函数。assert 宏使用一个表达式作为它的条件:

assert(expr);

首先对expr求值,如果表达式为假,assert输出信息并终止程序的执行。如果表达式为真,assert什么也不做。

assert宏定义在cassert头文件中。如我们所知,预处理名字由预处理器而非编译器管理,因此我们可以直接使用预处理名字而无须提供using声明。也就是说,我们应该使用assert而不是std::assert,也不需要为assert提供using声明

和预处理变量一样,宏名字在程序内必须唯一。 含有cassert头文件的程序不能再定义名为assert的变量、函数或者其他实体。在实际编程过程中,即使我们没有包含cassert头文件,也最好不要为了其他目的使用assert,很多头文件都包含了
cassert,这就意味着即使你没有直接包含cassert,它也很有可能通过其他途径包含在你的程序中。

assert宏常用于检查“不能发生”的条件。例如,一个对输入文本进行操作的程序可能要求所有给定单词的长度都大于某个阈值。此时,程序可以包含一条如下所示的语句:

assert(word.size() > threshold);

NDEBUG预处理变量

assert的行为依赖于一个名为NDEBUG的预处理变量的状态。如果定义了NDEBUG,则assert什么也不做。默认状态下没有定义NDEBUG,此时assert将执行运行时检查。
我们可以使用一个#define语句定义NDEBUG,从而关闭调试状态。同时,很多编译器都提供了一个命令行选项使我们可以定义预处理变量:

$ CC -D NDEBUG main.C # use /D with the Microsoft compiler

这条命令的作用等价于在main.c文件的一开始写#define NDEBUG

定义NDEBUG能避免检查各种条件所需的运行时开销,当然此时根本就不会执行运行时检查。因此,assert应该仅用于验证那些确实不可能发生的事情。我们可以把assert当成调试程序的一种辅助手段,但是不能用它替代真正的运行时逻辑检查,也不能替代程序本身应该包含的错误检查。

除了用于assert外,也可以使用NDEBUG编写自己的条件调试代码。如果NDEBUG未定义,将执行#ifndef#endif之间的代码;如果定义了NDEBUG,这些代码将被忽略掉:

void print(const int ia[], size_t size){
#ifndef NDEBUG
	// __func__是编译器定义的一个局部静态变量,用于存放函数的名字
	cerr << __func__ << ": array size is" << size << endl;
#endif
// ...
}

在这段代码中,我们使用变量__ func__输出当前调试的函数的名字。编译器为每个函数都定义了__ func__ 它是const char的一个静态数组,用于存放函数的名字。

除了C++编译器定义的__ func__之外,预处理器还定义了另外4个对于程序调试很有用的名字:

  • __FILE__存放文件名的字符串字面值
  • __LINE__存放当前行号的整型字面值
  • __TIME__存放文件编译时间的字符串字面值
  • __DATE__存放文件编译日期的字符串字面值

可以使用这些常量在错误消息中提供更多信息

6.6 函数匹配

以下面这组函数及其调用为例:

void f();
void f(int);
void f(int, int);
void f(double, double = 3.14);
f(5.6);		// 调用void f(double, double = 3.14);

确定候选函数和可行函数

函数匹配的第一步是选定本次调用的候选函数( candidate function)。 候选函数具备两个特征:一是 与被调用的函数同名,二是其声明在调用点可见。在这个例子中,有4个名为f的候选函数。

第二步考察本次调用提供的实参,然后从候选函数中选出可行函数( viable function)。 可行函数也有两个特征:一是其形参数量与本次调用提供的实参数量相等,二是每个实参的类型与对应的形参类型相同,或者能转换成形参的类型。在这个例子中,使用一个int形参的函数和使用两个double形参的函数是可行的,他们都能用一个实参调用。

如果没找到可行函数,编译器将报告无匹配函数的错误

寻找最佳匹配(如果有的话)

函数匹配的第三步是从可行函数中选择与本次调用最匹配的函数。在这一过程中,逐一检查函数调用提供的实参,寻找形参类型与实参类型最匹配的那个可行函数。下一节将介绍“最匹配”的细节,它的基本思想是,实参类型与形参类型越接近,它们匹配得越好

在这个例子中,调用只提供了一个(显式的)实参,它的类型是double。如果调用void f (int),实参将不得不从double转换成int。另一个可行函数void f(double, double)则与实参精确匹配。精确匹配比需要类型转换的匹配更好,因此,编译器把f(5.6)解析成对含有两个double形参的函数的调用,并使用默认值填补我们未提供的第二个实参。

含有多个形参的函数匹配

当实参的数量有两个或更多时,函数匹配就比较复杂了。对于前面那些名为f的函数,我们来分析如下的调用会发生什么情况:

f(42, 2.56);

此例中可行函数包括f(int, int)f(double, double)。接下来编译器依次检查每个实参以确定哪个函数是最佳匹配。如果有且仅有一个函数满足下列条件,则匹配成功

  • 该函数每个实参的匹配都不劣于其他可行函数需要的匹配
  • 至少有一个实参的匹配优于其他可行函数提供的匹配

如果在检查了所有实参之后没有任何一个函数脱颖而出,则该调用是错误的。编译器将报告二义性调用的信息。.
此例中,编译器最终将因为这个调用具有二义性耳拒绝其请求:每个可行函数各自在一个实参上实现了更好地匹配,从整体上无法判断孰优孰劣

6.6.1 实参类型转换

为了确定最佳匹配,编译器将实参类型到形参类型的转换划分成几个等级,具体排序如下所示:

  1. 精确匹配
  2. const转换
  3. 类型提升
  4. 算数类型转换

需要类型提升和算数类型转换的匹配

分析函数调用前,我们应该知道小整型一般都会提升到int类型或更大的整数类型。假设有两个函数,一个接受int另一个接受short,则只有当调用提供的是short类型的值时才会选择short版本的函数。有时候,即使实参是一个很小的整数值,也会直接将它提升成int类型;此时使用short版本反而会导致类型转换。

所有算术类型转换的级别都一样。例如,从int向unsigned int的转换并不比从int向double的转换级别高:

void manip(long);
void manip(float);
manip(3.14);		// 错误:二义性调用

函数匹配和const实参

如果重载函数的区别在于它们的引用类型的形参是否引用了const,或者指针类型的形参是否指向const,则当调用发生时编译器通过实参是否是常量来决定选择哪个函数:

Record lookup(Account&);
Recond lookup(const Account&);
const Account a;
Account b;

lookup(a);		// 调用lookup(const Account&)
lookup(b);		// 调用lookup(Account &)

6.7 函数指针

函数指针指向的是函数而非对象。和其他指针一一样,函数指针指向某种特定类型。函数的类型由它的返回类型和形参类型共同决定,与函数名无关。例如:

bool lengthCompare(const string &, const string &);

该函数类型是bool (const string&, const string&)。想要生命一个可以指向该函数的指针,只需要用指针替换函数名即可:

// pf指向一个函数,该函数的参数是两个const string的引用,返回值是bool
bool (*pf)(const string&, const string&);	// 未初始化

从我们声明的名字开始观察,pf前面有个*,因此pf是指针;右侧是形参列表,表示pf指向的是函数;再观察左侧,发现函数的返回类型是布尔值。因此,pf就是一个指向函数的指针,其中该函数的参数是两个const string的引用,返回值是bool类型。

*pf两端的括号必不可少,不写这对括号,则pf是一个返回值为bool指针的函数:

// 声明一个名为pf的函数,该函数返回bool*
bool *pf(const string&, const string&);

使用函数指针

当我们把函数名作为一个值使用时,该函数自动地转换成指针。例如,按照如下形式我们可以将lengthCompare的地址赋给pf:

pf - lengthCompare;		// pf指向名为lengthCompare的函数
pf = &lengthCompare;	// 等价的赋值语句:取地址符是可选的

此外,我们还能直接使用指向函数的指针调用该函数,无须提前解引用指针:

bool b1 = pf("hello", "goodbye");				// 调用lengthCompare
bool b2 = (*pf)("hello", "goodbye");			// 等价调用
bool b3 = lengthCompare("hello", "goodbye");	// 另一个等价调用

在指向不同函数类型的指针间不存在转换规则。但是和往常一样, 我们可以为函数指针赋一个nullptr或者值为0的整型常量表达式,表示该指针没有指向任何一个函数。

重载函数的指针

当我们使用重载函数时,上下文必须清晰地界定到底应该选用哪个函数。如 果定义了指向重载函数的指针,编译器将通过指针类型决定选用哪个函数,指针类型必须与重载函数中的某一个精确匹配。

void ff(int*);
void ff(unsigned int);

void (*pf1)(unsigned int) = ff;	// 指向ff(unsigned)

函数指针形参

和数组类似,虽然不能定义函数类型的形参,但是形参可以是指向函数的指针。此时,形参看起来是函数类型,实际上却是当成指针使用:

// 第三个形参树函数类型,它会自动地转换成指向函数的指针
void useBigger(const string &s1, const string &s2, bool pf(const string&, const string&));

// 等价声明:显式的将形参定义成指向函数的指针
void useBigger(const string &s1, const string &s2, bool (*pf)(const string&, const string&));

我们可以直接把函数作为实参使用,此时它会自动转换成指针。

直接使用函数指针类型显得冗长而繁琐,类型别名和decltype能让我们简化使用了函数指针的代码:

// Func和Func2是函数类型
typedef bool Func(const string&, const string&);
typedef decltype(lengthCompare) Func2;			// 等价的类型

// FuncP和FuncP2是指向函数的指针
typedef bool(*FuncP)(const string&, const string&);
typedef decltype(lengthCompare) *FuncP2;		// 等价的类型

我们使用typedef定义自己的类型。Func和Func2是函数类型,而FuncP和FuncP2是指针类型。需要注意的是,decltype返回函数类型,此时不会将函数类型自动转换成指针类型。因为decltype的结果是函数类型,所以只有在结果前面加上*才能得到指针。

返回指向函数的指针

和数组类似(参见6.3.3节,第205页),虽然不能返回一个函数,但是能返回指向函数类型的指针。然而,我们必须把返回类型写成指针形式,编译器不会自动地将函数返回类型当成对应的指针类型处理。与往常一样, 要想声明一个返回函数指针的函数,最简单的办法是使用类型别名:

using F = int(int*, int);		// F是函数类型,不是指针
using PF = int(*)(int*, int);	// PF是指针类型

其中我们使用类型别名将日定义成函数类型,将PF定义成指向函数类型的指针。必须时刻注意的是,和函数类型的形参不一样, 返回类型不会自动地转换成指针,我们必须显式地将返回类型指定为指针

PF f1(int);		// 正确
P f1(int);		// 错误
F *f1(int);		// 正确

当然我们也能直接声明f1:

int (*f1(int))(int*, int);

按照由内向外的顾序阅读这条声明语句:我们看到f1有形参列表,所以f1是个函数;f1前面有*,所以f1返回一个指针;进一步观察发现,指针的类型本身也包含形参列表,因此指针指向函数,该函数的返回类型是int。

还可以使用尾置返回类型的方式声明一个返回函数指针的函数:

auto f1(int) -> int (*)(int*, int);

将auto和decltype用于函数指针类型

如果我们明确知道返回的函数是哪一个, 就能使用decltype简化书写函数指针返回类型的过程。例如假定有两个函数,它们的返回类型都是string::size_type, 并且各有两个const string&类型的形参,此时我们可以编写第三个函数,它接受一个string类型的参数,返回一个指针,该指针指向前两个函数中的一个:

string::size_type sumLength(const String&, const string&);
string::size_type largerLength(const string&, const string&);

// 根据其形参的取值,getFcn函数返回指向sumLength或者largerLength的指针
decltype(sumLength) *getFcn(const string&);

声明getFcn唯一需要注意的地方是,牢记当我们将decltype作用于某个函数时,它返回函数类型而非指针类型。因此,我们显式地加上*以表明我们需要返回指针,而非函数本身。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值