《C++ primer》chapter 6:函数
1. 函数基础
一个典型的函数定义包括一下部分:返回类型,函数名字,由0个或多个形参组成的列表以及函数体。
形参以逗号隔开,位于一对圆括号内,函数执行的操作在语句块中说明,称为函数体。
通过调用运算符来执行函数,调用运算符的形式是一对圆括号,它作用于一个表达式,该表达式是函数或者指向函数的指针;圆括号内是用逗号隔开的实参列表,我们用实参初始化函数的形参。调用表达式的类型就是函数的返回类型。
编写和调用函数
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;
}
函数的调用完成两项工作:一是用实参初始化函数对应的形参,二是将控制权移交给被调用函数。
当遇到一条return语句时,函数结束执行过程,return语句也完成两项工作:一是返回return语句中的值(如果有的话),二是将控制权从被调用的函数还给主调函数。函数的返回值用于初始化调用表达式的结果,之后,继续完成调用所在表达式的剩余部分。
形参实参和返回类型
函数的形参列表可以为空,但不能省略。要想定义一个不带形参的函数,最常用的办法是书写一个空的形参列表。为了与C语言兼容,也可以用关键字void表示函数没有形参:
void f1() {} // 隐式地定义空形参列表
void f2(void) {} // 显式地定义空形参列表
// 每个形参都是含有一个声明符的声明,即使两个形参类型完全一样,也必须把两个类型都写出来。
int f3(int v1, v2) {} // 错误
int f4(int v1, int v2) {} // 正确
形参名是可选的,但我们无法使用未命名的形参,所有形参一般都应该有个名字,偶尔,函数有个别形参不会用到,则此类形参通常不命名以表示再函数体内不会使用它。不管怎样,是否设置未命名的形参不影响调用时提供的实参数量。
大多数类型都能用作函数的返回类型。一种特殊的返回类型是void,它表示函数不返回任何值,函数的返回类型不能是数组类型或函数类型,但可以是指向数组或函数的指针。
1.1 局部对象
C++语言中,名字有作用域,对象有生命周期:
- 名字的作用域是程序文本的一部分,名字在其中可见。
- 对象的生命周期是程序执行过程中该对象存在的一段时间。
函数体是一个语句块,块构成了一个新的作用域,我们可以在其中定义变量。形参和函数体内定义的变量统称局部变量,它们对函数而言是“局部”的,仅在函数的作用域可见,同时,局部变量还会隐藏在外层作用域中同名的其他所有声明中。
函数体外定义的对象存在于程序的整个执行过程中,此类对象在程序启动时被创建,知道程序结束才会销毁。局部变量的生命周期依赖于定义方式。
自动对象:普通局部变量会在函数控制路径经过变量定义语句时创建该对象,当到达定义所在块末尾时销毁它,我们把只存在块执行期间的对象成为自动对象。当块执行结束,块中的自动对象的值就变成未定义的了。
形参即是自动对象。对局部变量对应的自动对象来说,如果变量本身含有初始值,则用初始值进行初始化,否则若变量本身不含初始值,执行默认初始化,这意味着内置类型的未初始化局部变量将产生未定义的值。
局部静态对象:有时候,有必要令局部变量是生命周期贯穿函数调用及之后的时间,可以将局部变量定义成static类型从而获得这样的对象。局部静态对象在程序的执行路径第一次定义语句初始化后,直到程序终止才被销毁,在此期间,对象的函数结束执行也不会对它有何影响。
// 一个没有太大实际意义的例子
size_t count_calls() {
static size_t ctr = 0; // 调用结束后,这个值仍有效
return ++ctr;
}
int main() {
for (size_t i = 0; i != 10; ++i)
cout << count_calls() << endl;
return 0;
}
若局部静态变量没有显式初始化,它将执行值初始化,内置类型的局部静态变量初始化为0.
1.2 函数声明
类似与变量,函数只能定义一次,但可以声明多次,如果一个函数永远不会被用到,那么可以只有声明没有定义。
函数声明和定义比较类似,唯一的区别是函数声明无须函数体,用一个分号代替即可。
因函数声明不包含函数体,也就无须形参的名字,事实上,函数声明中经常省略形参的名字,尽管如此,写上形参的名字还是有好处的,它能帮助使用者更好地理解函数功能。
函数的三要素(返回类型,函数名,形参类型)描述了函数的接口,说明了调用该函数所需的全部信息。
我们记得变量建议在头文件中声明,在源文件中定义,与之类似,函数也应该在头文件中声明,在源文件中定义。
定义函数的源文件应该把函数声明的头文件包含进来,编译器负责验证函数的定义和声明是否匹配。
1.3 分离式编译
C++语言支持所谓的分离式编译,分离式编译允许我们把程序分割到几个文件中去,每个文件独立编译。
2. 参数传递
与变量一样,形参的类型决定了形参和实参交互的方式,如果形参是引用类型,它将绑定到对应的实参上;否则,将实参的值拷贝后赋给形参。
2.1 传值和传引用参数
当初始化一个非引用类型的变量时,初始值被拷贝给变量,此时对变量的改动不会影响初始值:
int n = 0;
int i = n; // i是n的副本
i = 42; // i的值改变;n的值不变
// 类似地在前面的fact函数中
ret *= val--; // 将val的值减1,不会影响传入fact的实参,调用fact(i)不会改变i的值。
指针的行为和其他非引用类型一样,执行指针拷贝操作时,拷贝的是指针的值。
int n = 0, i = 42;
int *p = &n, *q = &i;
*p = 42; // n的值改变;p不变
p = q; // p现在指向了i; 但是i和n的值都不变
// 指针形参与之类似
void reset(int *p) {
*ip = 0; // 改变指针ip所指的对象的值
ip = 0; // 只改变ip的局部拷贝,实参未被改变
}
// 调用reset
int i = 42;
reset(&i);
cout << "i = " << i << endl; //输出i = 0
我们知道对于引用的操作实际上是作用在引用所引的对象上
int n = 0, i = 42;
int &r = n; // r绑定了n(即r是n的另一个名字)
r = 42; // 现在n的值是42
r = i; // 现在n的值和i相同
i = r; // i的值和n相同
// 引用形参与此类似
void reset(int &i) {
i = 0; // 改变了i所引用的对象的值
}
// 调用这版reset函数,我们直接传入对象而无须传入对象地址:
int j = 42;
reset(j);
cout << "j = " << j << endl; //输出j = 0;
拷贝大的类型对象或容器对象比较低效,有些类类型比如IO类型,根本就不支持拷贝操作,当某种类型不支持拷贝操作时,函数只能通过引用形参访问该类型的对象。
// 比较两个string对象的长度
bool isShorter(const string &s1, const string &s2) {
return s1.size() < s2.size();
}
// 当函数无须修改引用形参的值时,最好使用常量引用
使用引用形参返回额外信息
// 返回s中c第一次出现的位置索引
// 引用形参occurs负责统计c出现的次数
string::size_type find_char(const string &s, char c, string::size_type &occurs) {
auto ret = s.size(); // 第一次出现的位置
occurs = 0;
for (decltype(ret) i = 0; i != s.size(); ++i) {
if (s[i] == c) {
if (ret == s.size())
ret = i;
++occurs;
}
}
return ret; // 出现次数通过occurs隐式返回
}
2.2 const形参和实参
回忆关于顶层const的讨论,如前所述,顶层const作用于对象本身:
const int ci = 42; // 不能改变ci,const是顶层的
int i = ci; // 正确,当拷贝ci时,忽略了它的顶层const
int * const p = &i; // const是顶层的,不能给p赋值
*p = 0; // 正确,通过p改变所指对象的内容是允许的,现在i变成了0
和其他初始化过程类似,当用实参初始化形参时,会忽略顶层const,即形参的顶层const被忽略了,当形参有顶层const时,传给它常量或非常量对象都可以。
void func(const int i) {/*func能读取i,但是不能向i写值*/}
void func(int i) {/*...*/} // 错误,重复定义了func(int)
C++语言允许我们定义若干具有相同名字的函数,不过前提是不同函数的形参列表应该有明显区别。上例中因为顶层const被忽略掉了,所以上面代码中传入两个func函数的参数可以完全一样,因此第二个func是错误的,它重复定义了上一个func函数。
指针或引用形参与const
形参的初始化方式和变量的初始化方式一样,我们可以使用非常量初始化一个底层const对象,但是反过来不行;同时一个普通引用必须用同类型的对象初始化。
int i = 42;
const int *cp = &i; // 正确,但是cp不能改变i
const int &r = i; // 正确,但是r不能改变i
const int &r2 = 42; // 正确
int *p = cp; // 错误,p的类型和cp不匹配
int &r3 = r; // 错误,r3的类型和r不匹配
int &r4 = 42; // 错误,不能用字面值初始化一个非常量引用
// 将同样的初始化规则应用到参数传递上可知:
int i = 0;
const int ci = i;
string::size_type ctr = 0;
reset(&i); // 调用形参类型是int*的reset
reset(&ci); // 错误,不能用指向const int对象的指针初始化int*
reset(i); // 调用形参类型是int*的reset
reset(ci); // 错误,不能把普通引用绑定到const对象ci上
reset(42); // 错误,不能把普通引用绑定到字面值上
reset(ctr); // 错误,类型不匹配,ctr是无符号类型
find_char("hello world.", 'o', ctr); // 正确,第一个形参是对常量的引用
尽量使用常量引用
把函数不会改变的形参定义成普通引用是一种比较常见的错误,这不但会给调用者造成误导,即函数可以修改它的实参的值,此外还会极大地限制函数所能接受的实参类型,如上面的例子所示,我们不能把const对象,字面值或者需要类型转换的对象传递给普通的引用形参。
2.5 数组形参
数组的两个特殊性质对我们定义和使用数组上的函数有影响,即:不允许拷贝数组以及使用数组时通常会将其转换为指针,因不能拷贝数组,我们无法以值传递的方式使用数组参数。当我们为函数传递一个数组时,实际传递的是指向数组首元素的指针。
尽管不能以值传递数组,但我们可以把形参写成类似数组的形式:
// 三个等价的print函数,每个函数都有一个const int*类型的形参
void print(const int*);
void print(const int[]); // 可以看出,函数的意图是作用于一个数组
void print(const int[10]); // 这里维度表示我们期望数组含有多少元素,实际不一定
// 当编译器处理print函数的调用时,只检查传入的参数是否是const int*类型:
int i = 0, j[2] = {0, 1};
print(&i); // 正确,&i的类型是int*
print(j); // 正确,j自动转换为int*并指向j[0]
数组以指针的形式传给函数,所以一开始函数并不知道数组的确切尺寸,调用者应该为此提供一些额外信息。管理指针形参有三种常用技术:
使用标记指定数组长度
// 数组含有结束标记,比如C风格字符串
void print(const char *cp) {
if (cp) {
while (*cp) // 只要当前指针所指的字符不是空字符
cout << *cp++;
}
}
使用标准库规范
// 传递指向数组首元素和尾元素的指针
void print(const int *beg, const int *end) {
// 输出beg到end(不含end)的所有元素
while (beg != end)
cout << *beg++ << endl;
}
// 调用这个函数需要传入两个指针:一个指向首元素,一个指向尾元素的下一个位置
int j[2] = {0, 1};
print(begin(j), end(j));
显式传递一个表示数组大小的形参
// 专门定义一个表示数组大小的形参,在C程序和过去的C++程序中常用这种方法
void print(const int ia[], size_t size) { // const int ia[]等价于const int* ia
for (size_t i = 0; i != size; ++i) {
cout << ia[i] << endl;
}
}
// 调用时,需要传入这个表示数组大小的值
int j[2] = {0, 1};
print(j, end(j) - begin(j));
数组引用形参
函数形参也可以是数组的引用,此时引用形参绑定到对应的形参上,也就是绑定到数组上:
// 形参是数组的引用,维度是类型的一部分
void print(int (&arr)[10]) {
for (auto elem: arr)
cout << elem << endl;
}
// 我们只能将函数作用于大小为10的数组中
int i = 0, j[2] = {0, 1};
int k[10] = {0,1,2,3,4,5,6,7,8,9};
print(&i); // 错误,实参不是含10个整数的数组
print(j); // 错误
print(k); // 正确
传递多维数组
和所有数组一样,将多维数组传递给函数时,真正传递的是指向数组首元素的指针。因为我们处理的是多维数组,所以指针是一个指向数组的指针,数组第二维(以及后面的维度)的大小是数组类型的一部分,不能省略:
// matrix指向数组的首元素,该数组的元素是由10个整数构成的数组
void print(int (*matrix) [10], int rowSize) {/*...*/}
// 我们也可以用数组的语法定义函数,此时编译器会一如既往忽略第一个维度,故最好不要把它包括在形参列表里
void print(int matrix[][10], int rowSize) {/*...*/}
// 这里matrix的声明看起来是一个二维数组,实际上形参是指向含有10个整数的数组的指针。
2.5 main: 处理命令行选项
有时候,我们需要给main函数传递实参,一种常见情况是用户通过设置一组选项来确定函数所要执行的操作。例如,假定main函数位于可执行文件prog内,我们可以向程序传递下面的选项:
“prog -d -o ofile data0”
这些命令行选项通过两个(可选的)形参传递给main函数:
int main(int argc, char *argv[]) {...}
// 第一个形参argc表示数组中字符串的数量,第二个是一个数组,它的元素是指向C风格字符串的指针,它也可以定义为
int main(int argc, char **argv) {...} // 其中argv指向char*
// 当实参传给main函数后,argv的第一个元素指向程序的名字或一个空字符串,接下来的元素依次传递命令行提供的实参,最后一个指针之后的元素保证为0
argv[0] = "prog"; // 或者argv[0]也可以指向一个空字符串
argv[1] = "-d";
argv[2] = "-o";
argv[3] = "ofile";
argv[4] = "data0";
argv[5] = 0
2.6 含有可变形参的函数
为了编写能处理不同数量实参的函数,C++11新标准提供了两种主要方法:如果实参类型相同,可以传递一个名为Initializer_list的标准库类型;如果实参的类型不同,可以编写一种特殊函数,即可变参数模板。此外还有一种特殊的形参类型,即省略符,可以用它传递可变数量的实参,但是这种功能一般只与C函数交互的接口程序。
initializer_list形参
initializer_list是一种标准库类型,用于表示某种特定类型的值的数组,这种类型定义在同名的头文件中,它提供的操作如下:
initializer_list |
默认初始化;T类型元素的空列表 |
---|---|
initializer_list |
lst的元素和初始值一样多,是对应初始值的副本,其中的元素是const |
lst2(lst)或lst2 = lst | 拷贝或赋值一个initializer_list对象不会拷贝其中的元素;拷贝后原列表和副本共享元素 |
lst.size() | 列表中的元素数量 |
lst.begin() | 返回指向lst首元素的指针 |
lst.end() | 返回指向lst尾元素下一个位置的指针 |
和vector一样,initializer_list也是一种模板类型,定义initializer_list对象时,必须说明列表中所含元素的类型:
initalizer_list<string> ls;
initalizer_list<int> ls;
和vector不同的是,initalizer_list对象的元素永远是常量值,我们无法改变initalizer_list对象中元素的值。
// 用如下形式编写输出错误信息函数,使其可以用于可变数量的实参
void error_msg(initializer_list<string> il) {
for (auto beg = il.begin(); beg != il.end(); ++beg)
cout << *beg << " ";
cout << endl;
}
// 想要向initalizer_list形参中传递一个值的序列,则必须把序列放在一对花括号内:
// expected和actual是string对象
if (expected != actual)
error_msg({'funcX', expected, actual});
else
error_msg({'funcX', "okay"});
// initalizer_list作为形参的函数也可以拥有其他形参
// ErrCode类表示不同类型的错误
void error_msg(ErrCode e, initalizer_list<string> il) {
cout << e.msg() << ": ";
for (const auto &elem: il)
cout << elem << " ";
cout << endl;
}
// 这版本error_msg函数需要额外传递一个ErrCode实参:
if (expected != actual)
error_msg(ErrCode(42), {'funcX', expected, actual});
else
error_msg(ErrCode(0), {'funcX', "okay"});
省略符形参
省略符形参是为了便于C++程序访问某些特殊的C代码设置的,这些代码使用了名为varargs的C标准库功能,通常,省略符形参不应用于其他目的。特别应该注意的是,多数类类型的对象在传递给省略符形参时都无法正确拷贝。
// 省略符形参只能出现在形参列表的最后一个位置,它的形式无外乎以下两种
void foo(param_list, ...); // param_list的实参将进行正常类型检查,省略符对应的实参则不会
void foo(...);
3. 返回类型和return语句
return语句终止当前正在执行的函数,并将控制权交给调用它的函数的地方。
return语句有两种形式:
return;
return expressin;
3.1 无返回值的函数
无返回值的return语句只能用在返回类型是void的函数中,返回void的函数不要求一定要有return语句,因为这类函数最后一句后面会隐式地执行return。
一般地,void函数若要在它的中间提前退出,可以使用return语句。
void swap(int &v1, int &v2) {
if (v1==v2)
return
int tmp = v2;
v2 = v1;
v1 = tmp;
// 无须显式的return语句
}
一个返回类型是void的函数也能使用return语句的第二种形式,不过此时return语句的expression必须是另一个返回void的函数。
3.2 有返回值函数
若函数的返回类型不是void,则该函数的每条return语句都必须返回一个值,return语句返回值类型必须与函数返回类型相同,或能隐式地转换为函数的返回类型。
在含有return语句的循环后面应该有一条return语句,若没有,该程序就是错误的。
返回一个值的方式和初始化一个变量或形参的方式完全一样:返回的值用于初始化调用点的一个临时量,该临时量就是函数的调用结果。
// 如果ctr的值大于1,返回word的复数形式
string make_plural(size_t ctr, const string &word, const string &ending) {
return (ctr > 1) ? word + ending: word;
}
// 该函数的返回类型是string,意味着返回值将被拷贝到调用点。
和其他引用类型一样,若函数返回引用,则该引用仅是它所引对象的一个别名。
// 选出两个string中较短的那个,返回其引用
const string & shorterString(const string &s1, const string &s2) {
return s1.size() <= s2.size()? s1:s2;
}
// 这里形参和返回类型都是const string的引用,不管是调用还是返回都不会真正拷贝string对象
不要返回局部对象的引用或指针
函数调用完成后,它所占用的存储空间也被释放掉,因此,函数终止意味着局部变量的引用将指向不再有效的内存区域:
// 严重错误:这个函数试图返回局部对象的引用
const string & manip() {
string ret;
if (!ret.empty())
return ret;
else
return "Empty";
}
函数调用运输符的优先级与点运算符和箭头运算符相同,也符合左结合律。
函数的返回类型决定函数的调用返回是左值还是右值,调用一个返回引用的函数得到左值,其他返回类型得到右值。
// 为返回类型为非常量引用的函数的结果赋值
char & get_value(string & str, string::size_type ix) {
return str[ix]; // 假定索引值有效
}
int main() {
string s("a value");
cout << s << endl;
get_value(s, 0) = 'A'; // 将s[0]的值改为A
cout << s << endl; // 输出A value
return 0;
}
列表初始化返回值
C++11新标准规定,函数可以返回花括号包围的值的列表,类似其他返回结果,此处的列表也用来表示对函数返回的临时量进行初始化,如果列表为空,临时量执行值初始化,否则返回值由函数的返回类型决定。
// 返回一个vector对象,用它存放表示错误信息的string对象
vector<string> process() {
if (expected.emty())
return {}; // 返回一个空vector对象
else if (expected == actual)
return {"funcX", "okay"}; // 返回列表初始化的vector对象
else
return {"funcX", expected, actual};
}
若函数返回类型是内置类型,则花括号包围的列表最多包含一个值,且该值所占空间不应该大于目标类型空间。若函数返回类型是类类型,由类本身定义初始值如何使用。
主函数main的返回值
我们允许main函数没有return语句直接结束,若控制到达了main函数的结尾且没有return语句,编译器会隐式地插入一条返回0的return语句。main函数的返回值可以看作状态指示器,返回0表示执行成功,返回其他表示执行失败,其中非0的值的具体含义视机器而定。
递归
若一个函数调用了它的自身,不管这种调用是直接还是间接的,都成为函数为递归函数。
// 计算整数阶乘
int factorial(int val) {
if (val > 1)
return factorial(val-1) * val;
return 1;
}
在递归函数中,必须要有一条路径是不包含递归调用的,否则,函数将不断调用下去,直到程序栈空间耗尽为止。
3.3 返回数组指针
因为数组不能被拷贝,所以函数不能返回数组,不过函数可以返回数组是指针或引用。语法上,要返回一个返回数组的指针或引用比较繁琐,可以用类型别名来简化这一任务。
typedef int arrT[10]; // arrT是一个类型别名,它表示的类型是含有10个整数的数组
using arrT = int[10]; // arrT的等价声明
arrT* func(int i); // func返回一个指向含有10个整数的数组的指针
要声明一个函数时不适应类型别名,我们要牢记被定义的名字后面数组的维度
int arr[10];
int *p1[10]; // p1是一个含有10个指针的数组
int (*p2)[10] = &arr;
和这些声明一样,要定义一个返回数组的指针的函数,数组维度必须跟在函数之后:
Type (*function(parameter_list)) [dimension]
这里(*function(parameter_list))两端的括号必不可少。
此外,还可以使用尾置返回类型
尾置返回类型跟在形参列表后,并以一个->符号开头,为了表示函数真正的返回类型跟着形参列表后,我们在本应出现返回类型的地方放置一个auto:
// func接受一个int类型的实参,返回一个指针,该指针指向含10个整数的数组
auto func(int i) -> int(*) [10]
有些情况,还可以使用decltype
若我们知道函数返回在指针将指向哪个数组,就可以使用decltype关键字声明返回类型:
int odd[] = {1,3,5,7,9};
int even[] = {0,2,4,6,8};
// 返回一个指针,指针指向含有5个整数的数组
decltype(odd) *arrPtr(int i) {
return (i%2) ? &odd : &evend; // 返回一个指向数组的指针
}
// 需要注意,decltype并不负责把数组类型转换为对应的指针,所以decltype的结果是个数组,要表示返回指针,还应该在函数声明时加一个*符号。
4. 函数重载
若同一作用域的几个函数名字相同但形参列表不同,我们称之为重载函数。之前定义的几个print函数即是:
void print(const char *cp);
void print(const int *beg, const int *end);
void print(const int ia[], size_t size);
// 当调用这些函数时,编译器会根据传递实参的类型推断想要的是哪个函数
int j[2] = {0, 1};
print("Hello Word"); // 调用print(const char*)
print(j, end(j) - begin(j)); // 调用print(const int*, size_t)
print(begin(j), end(j)); // 调用print(const int*, const int*)
函数名仅仅是让编译器知道它调用的是哪个函数,而重载可以一定程度地减轻程序员起名,记名字的负担。特别地,main函数不能重载。
对于重载函数来说,它们应该在形参数量或形参类型上有所不同,不允许两个函数除了返回类型外其他所有要素都相同。
Record lookup(const Account&);
bool lookup(const Account&); //错误,与上一个函数只有返回类型不同
// 以下每对声明的是同一个函数
Record lookup(const Account &acct);
Record lookup(const Account&); // 省略了形参名字
typedef Phone Telno;
Record lookup(const Phone&);
Record lookup(const Telno&); // Telno和Phone的类型相同
重载和const形参
我们知道顶层const不影响传入函数的对象,一个拥有顶层const的形参无法和另一个没有顶层const的形参区分开来;另一方面,如果形参是某种类型的指针或引用,则通过区分其指向的是常量对象还是非常量对象可以实现函数重载,此时const是底层的。
Record lookup(Phone);
Record lookup(const Phone); // 重复声明
Record lookup(Phone*);
Record lookup(Phone* const); // 重复声明
Record lookup(Account&);
Record lookup(const Account&); // 新函数,作用于常量的引用
Record lookup(Account*);
Record lookup(const Account*); // 新函数,作用于指向常量的指针
当传递一个非常量对象或指向非常量对象的指针时,编译器会优先选用非常量版本的函数。
尽管函数重载能在一定程度上减轻我们为函数起名和记名字的负担,但最好只重载那些确实非常相似的操作。
const_cast和重载
之前提到,const_cast在重载函数的情景中最有用。
// 之前定义的比较两个string对象长度的函数
const string & shortString(const string &s1, const string &s2) {
return s1.size() <= s2.size() ? s1 : s2;
}
// 用const_case定义一个重载函数,当实参不是常量时,得到的结果是一个普通引用
string & shortString(string& s1, string& s2) {
auto &r = shortString(const_cast<const string&>(s1),
const_cast<const string&>(s2));
return const_cast<string&>(r);
}
调用重载函数
函数匹配:一个过程,这个过程中我们把函数调用与一组重载函数中的某一个关联起来。函数匹配也叫函数确定。
当调用重载函数时,可能发生三种结果:
- 编译器找不到一个与实参最佳匹配的函数,并生成调用该函数的代码。
- 找不到任何一个函数与调用的实参匹配,此时编译器发出误匹配的错误。
- 由多于一个函数可以匹配,但是每个都不是明显的最佳选择,此时也将发生错误,称为二义性调用。
重载与作用域
重载对作用域的一般性质并无改变:如果我们在内层作用域中声明名字,它将隐藏外层作用域中声明的同名实体。在不同的作用域中无法重载函数名:
// 一般,将函数声明放在局部作用域不是一个好的选择,但我们暂时违法这一原则
string read();
void print(const string&);
void print(double); // 重载print函数
void fooBar(int ival) {
bool read = flase; // 新作用域:隐藏外层read
string s = read(); // 错误,read是一个bool值,而非函数
// 不好的习惯:
void print(int); // 新作用域,隐藏了之前的print
print("Value: "); // 错误,之前的print(const string&)被隐藏了
print(ival); // 正确
print(3.14); // 正确,但调用的是print(int);
}
当调用函数时,一旦在当前作用域中找到了所需的名字,编译器就会忽略外层作用域中的同名实体,剩下的工作就是检查函数调用是否有效了。
在C++语言中,名字查找发生在类型检查之前。
假如我们把print(int)和其他print函数声明放在同一个作用域中,则它将成为另一种重载形式,此时编译器能看到所有三个函数,上述调用结果将完全不同:
void print(const string &);
void print(double); // print函数的重载形式
void print(int); // print函数的另一种重载形式
void fooBar2(int ival) {
print("Value: "); // 调用print(const string &)
print(ival); // 调用print(int)
print(3.14); // 调用print(double)
}
5. 特殊用途的语言特性
5.1 默认实参
某些函数有这样一种形参,在函数的多次调用中它们都被赋予一个相同的值,此时我们可以把反复出现的值称为默认实参。调用含有默认实参的函数时,可以包含该实参,也可以省略该实参。
默认实参作为形参的初始值出现在形参列表中。我们可以为一个或多个形参定义默认值,不过需要注意的是,一旦某个形参被赋予了默认值,它后面的所有形参都必须有默认值。
// 定义一个窗口对象,一般情况下,我们希望该窗口的高,宽和背景字符都使用默认值。
typedef string::size_type sz;
string screen(sz ht = 24, sz wid = 80, char backgrnd = ' ');
使用默认实参调用函数时,只要在调用函数的时候省略该实参即可。
string window;
window = screen();
window = screen(66);
window = screen(66, 256);
window = screen(66, 256, '#');
// 函数调用时,按其位置解析,默认实参负责填补函数调用缺少的尾部实参(靠右的位置),比如,要想覆盖backgrnd的默认值,必须为ht和wid提供实参。
当设计含有默认实参的函数时,其中一项任务是合理设置形参顺序,尽量让不怎么使用默认值的形参出现在前面,不怎么出现的默认值的形参出现在后面。
函数的声明一般放在头文件中,且只声明一次,但多次声明也是合法的。不过需要注意的是,在给定的作用域中一个形参只能被赋予一次默认实参。即默认的后续声明只能为之前那些没有默认值的形参添加默认实参,而且该形参右侧的所有形参都必须有默认值。
string screen(sz, sz, char = ' ');
string screen(sz, sz, char = '*'); // 错误,重复声明,不能修改一个以及存在的默认值
string screen(sz = 24, sz = 80, char) // 正确,添加默认实参
通常,应该在函数声明中指定默认实参,并将该声明放在头文件中。
局部变量不能作为默认实参,除此之外,只要表达式的类型能转换成所需的类型,该表达式就能作为默认实参。用作默认实参的名字在函数声明所在的作用域内解析,而这些名字的求值过程发生在函数调用时。
// wd, def和ht的声明必须出现在函数之外
sz wd = 80;
char def = ' ';
sz ht();
string screen(sz = ht(), sz = wd, char = def);
string window = screen(); // 调用screen(ht(), 80, ' ')
void f2() {
def = '*'; // 改变默认实参的值
sz wd = 100; // 隐藏了外层定义的wd,但是没有改变默认值
window = screen(); // 调用screen(ht(), 80, '*')
}
5.2 内联函数和constexpr函数
把规模较小的操作定义成函数有诸多好处,然后函数调用一般比求等价表达式的值要慢一点。在多数机器上,一次函数调用实际上包含着一系列工作:调用前要先保存寄存器,并在返回时恢复;可能需要拷贝实参;程序转向一个新的位置继续执行。
将函数指定为内联函数,通常就是将它在每个调用点内联地展开,假设我们把前面的shorterString函数定义成内联函数。
cout << shorterString(s1, s2) << endl;
// 以上调用将在编译过程中展开成类似于下面的形式,这会消除shorterString函数的运行时开销
cout << (s1.size() < s2.size() ? s1 : s2) << endl;
// shorterString的内联版本
inline const string & shorterString(const string& s1, const string& s2) {
return s1.size() < s2.size() ? s1 : s2;
}
内联说明只是向编译器发出一个请求,编译器可以选择忽略这个请求。一般来说,内联机制用于优化规模较小,流程直接,频繁调用的函数。很多编译器都不支持内联递归函数。
constexpr函数 指能用于常量表达式的函数。定义constexpr函数的方法与其他函数类似,不过要遵循以下约定:函数的返回类型及所有形参类型都得是字面值类型,而且函数体中必须有且只有一条return语句。
constexpr int new_sz() {return 42;}
constexpr int foo = new_sz(); // 正确,foo是一个常量表达式
执行以上初始化任务时,编译器把对constexpr函数的调用替换为其结果值,为了能在编译过程中随时展开,constexpr函数被隐式地指定为内联函数。
constexpr函数体也可以包含其他语句,只要这些语句在运行时不执行任何操作就行,例如,constexpr函数中可以有空语句,类型别名以及using声明。
我们允许constexpr函数的返回值并非一个常量:
// 如果arg是常量表达式,则scale(arg)也是常量表达式
constexpr size_t scale(size_t cnt) {return new_sz() * cnt;}
// 当scale的实参是常量表达式时,它的返回值也是常量表达式;反之不然:
int arr[scale(2)]; // 正确
int i = 2; // i不是常量表达式
int a2[scale(i)]; // 错误,scale(i)不是常量表达式
所以,constexpr函数不一定返回常量表达式。
和其他函数不同的是,内联函数和constexpr函数可以在程序中多次定义,但它的多个定义必须完全一致,因此,内联函数和constexpr函数通常定义在头文件中。
5.3 调试帮助
C++程序员有时会用到一种类似头文件保护的技术,以便有选择地执行调试代码,这种方法用到两项预处理功能:assert和NDEBUG。
assert预处理宏
assert是一种预处理宏,它的行为有点类似于内联函数。assert宏使用一个表达式作为它的条件:
assert (expr);
首先对expr求值,若为假,输出信息并终止程序,若为真,则assert什么也不做。
assert宏定义在cassert头文件中,如我们所知,预处理名字由预处理器而非编译器管理,因此,我们可以直接使用预处理名字而无须提供using声明。和预处理变量一样,宏名字在程序内必须唯一。
assert宏常用于检查“不可能发生”的条件。
assert的行为依赖于一个名为NDEBUG的预处理变量的状态。如果定义了NDEBUG,则assert什么也不做,默认状态下没有定义NDEBUG,此时assert将执行运行时检查。
我们可以用一个#define语句定义NDEBUG,从而关闭调试状态。
除了用于assert外,也可以用NDEBUG编写自己的条件调试代码,如果NDEBUG未定义,则将执行#ifndef和#endif之间的代码;如果定义了NDEBUG,这些代码将被忽略:
void print(const int ia[], size_t size) {
#ifndef NDEBUG
cerr << __func__ << ": array size is " << size << endl;
#endif
}
// __func__是编译器定义的一个局部静态变量,用于存放函数的名字
// 预处理定义的几个对程序调试很有用的名字:
__FILE__; // 存放文件名的字符串字面值
__LINE__; // 存放当前行号的整型字面值
__TIME__; // 存放文件编译时间的字符串字面值
__DATE__; // 存放文件编译日期的字符串字面值
// 可以用这些常量在错误消息中提供更多信息
if (word.size() < threshold)
cerr << "Error: " << __FILE__ <<
" : in function " << __func__ <<
" at line " << __LINE__ << endl
<< " Compiled on " << __DATE__
<< " at " << __TIME__ << endl
<< " Word read was \"" << word
<< "\": Length too short" << endl;
6. 函数匹配
当重载函数的形参数量相等以及某些形参的类型可以由其他类型转换得来时,如何确定某次调用该选用那个函数呢?
一般的函数匹配过程可分三步:第一步,选定本次调用对应的重载函数集,集合中的函数称为候选函数。候选函数需要于被调用的函数同名,且其声明在调用点可见。
第二步考察本次调用提供的实参,然后从候选函数中选出能被这组实参调用的函数,这些新选出的函数称为可行函数,可行函数需要满足两个特征:一是形参数量与本次调用提供的实参数量相等,二是每个实参的类型与对应的形参类型相同,或者能转换成形参的类型。如果没有找到可行函数,编译器将报告无匹配函数。
第三步是从可行函数中选择与本次调用最匹配的函数,在这一过程中,逐一检查函数调用提供的实参,寻找形参类型与实参最匹配的那个可行函数。实参类型与形参类型越接近,它们匹配得越好。
void f();
void f(int);
void f(int, int);
void f(double, double=3.14);
f(5.6); // 调用void f(double, double)
当实参的数量由两个或更多时,函数匹配就比较复杂了,考虑
f(42, 2.56);
此时,可行函数包括f(int, int)和f(double, double)。接下来编译器依次检查每个实参以确定哪个函数是最佳匹配,若有且只有一个函数满足下列条件,则匹配成功:
- 该函数每个实参的匹配都不劣于其他可行函数需要的匹配
- 至少有一个实参的匹配优于其他可行函数提供的匹配
若在检查了所有实参后没有任何一个函数脱颖而出,则该调用是错误的,编译器将报二义性调用的信息。
因此上述调用f(42, 2.56)具有二义性,会被编译器拒绝调用请求。
调用重载函数时应尽量避免强制类型转换,如果在实际应用中确实需要强制类型转换,则说明我们设计的形参集合不合理。
实参类型转换
为确定最佳匹配,编译器将实参类型到形参类型的转换划分为几个等级:具体排序如下:
- 精确匹配,包括实参和形参类型相同,实参从数组类型或函数类型转换为对应指针类型,向实参添加或删除顶层const。
- 通过const转换实现的匹配。
- 通过类型提升实现的匹配。
- 通过算术类型转换或指针转换实现的匹配。
- 通过类类型转换实现的匹配。
有时候,实参是一个很小的值,也会直接将它提升成Int类型,此时使用short版本反而会导致类型转换:
void ff(int);
void ff(short);
ff('a'); // char提升成int, 调用f(int)
所有算术类型转换的级别都一样:
void manip(long);
void manip(float);
manip(3.14); // 错误,二义性调用
如果重载函数的区别在于它们的引用类型的形参是否引用了const,或指针的形参是否指向const,则当调用发生时,编译器通过实参是否是常量来决定选择哪个函数。指针类型的形参也与此类似。
Record lookup(Account &);
Record lookup(const Account &);
const Account a;
Account b;
lookup(a); // 调用lookup(const Account &)
lookup(b); // 调用lookup(Account &)
7. 函数指针
函数指针指向的是函数而非对象,它指向某种特定的类型。函数的类型由它的返回类型和形参类型共同决定,与函数名无关。
bool lengthCompare(const string &, const string &);
// 这个函数的类型是bool(const string&, const string&)
若要声明一个可以指向该函数的指针,只要用指针替换函数名即可。
// pf指向一个函数,该函数的参数是两个const string的引用,返回值是bool类型
bool (*pf)(const string&, const string&); // 未初始化
// 这里,*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的整型常量表达式,表示该指针没有指向任何函数
string::size_type sumLength(const string&, const string&);
bool cstringCompare(const char*, const char*);
pf = 0; // 正确,pf不指向任何函数
pf = sumLength; // 错误,返回类型不匹配
pf = cstringCompare; // 错误,形参类型不匹配
pf = lengthCompare; // 正确,函数和指针的类型精确匹配
重载函数的指针时,上下文必须清晰界定该选用哪个函数:
void ff(int*);
void ff(unsigned int);
void (*pf1)(unsigned int) = ff; // pf1指向ff(unsigned)
// 编译器通过指针类型决定选用哪个函数,指针类型必须与重载函数中的某一个精确匹配
void (*pf2)(int) = ff; // 错误,没有任何一个ff与该形参列表匹配
double (*pf3)(int*) = ff; // 错误,ff和pf3的返回类型不匹配
函数指针形参
和数组类似,不能定义函数类型的形参,但形参可以是指向函数的指针,此时形参看起来是函数类型,实际上却当指针使用:
// 第三个形参是函数类型,它自动转换成指向函数的指针
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 &));
// 可以把函数作为实参使用,此时,它会自动转换为指针:
useBigger(s1, s2, lengthCompare);
// 类型别名能让我们简化使用了函数指针的代码:
// 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; // 等价的类型
// 需要注意的是,decltype返回函数类型,此时不会将函数类型自动转换为指针类型,因为decltype的结果是函数类型,故在结果前面加上*才能得到指针
// useBigger的等价声明,其中使用了类型别名
void useBigger(const string&, const string&, Func);
void useBigger(const string&, const string&, FuncP2);
返回指向函数的指针
还是和数组类似,不能返回一个函数,但是能返回指向函数类型的指针。然而,我们必须把返回类型写成指针形式,编译器不会自动将函数返回类型当成对应的指针类型处理。要想声明一个返回函数指针的函数,最简单的办法是使用类型别名:
using F = int(int*, int); // F是函数类型,不是指针
using PF = int(*)(int*, int); // PF是指针类型
PF f1(int); // 正确,PF是指向函数的指针
F f1(int); // 错误,F是函数指针,f1不能返回一个函数
F* f1(int); // 正确,显式地指定返回类型是指向函数的指针
// 当然,也可以使用尾置返回类型的方式声明一个返回函数指针的函数:
auto f1(int) -> int (*)(int*, int);
如果我们明确指定返回的函数是哪一个,就能使用decltype简化书写函数指针返回类型的过程:
string::size_type sumLength(const string&, const string &);
string::size_type largerLength(const string&, const string&);
// 根据形参的取值,getFcn函数返回指向sumLength或者largerLength的指针
decltype(sumLength)* getFcn(const string &);