函数是一个命了名的代码块,我们通过调用函数执行相应的代码。函数可以有零个或者多个参数,而且(通常)会产生一个结果。可以重载函数,也就是说,同一个名字可以对应几个不同的函数。
6.1 函数基础
典型的函数定义包含以下部分:
- 返回类型。
- 函数名。
- 由零个或者多个形参组成的列表。
- 函数体。
每个形参使用逗号隔开,形参的列表位于一对圆括号内。函数执行的操作在语句块中,这个语句块被称为函数体。
通过调用运算符来执行函数。调用运算符的形式是一对圆括号,该符号作用于一对表达式,该表达式是函数或者指向函数的指针:圆括号里面是一个用逗号隔开的实参列表,我们使用实参初始化函数的形参。调用表达式的类型就是函数的返回类型。
编写函数
调用函数
函数的调用完成两项工作:首先用实参初始化函数对应的形参,然后将控制权转移给被调用函数。这时主调函数的执行被暂时中断,被调函数开始执行。
return语句完成两项工作:返回return语句中的值(如果有的话),将控制权从被调函数转移回主调函数。函数的返回值用于初始化调用表达式的结果,之后继续完成调用所在的表达式的剩余部分。
形参和实参
实参是形参的初始值。实参初始化位置对应的形参。尽管这种对应关系存在,但是并没有规定实参的求值顺序。编译器能以任意可行的顺序对实参求值。
实参的类型必须与对应的形参类型匹配。函数有几个形参,我们就必须提供相同数量的实参。
函数的形参列表
函数的形参列表可以为空,但是不能省略。要想定义一个不带形参的函数,最常用的办法就是书写一个空的形参列表,当然也可以使用关键字void表示函数没有形参。
每个形参都必须含有一个类型声明符。
任意两个形参都不能同名,而且函数最外层作用域中的局部变量也不能使用与函数形参一样的名字。
形参名是可选的,但是由于我们无法使用未命名的形参,所以形参必须起名。
函数返回类型
返回类型void表示函数不返回任何值。函数的返回类型不能是数组类型或者函数类型,但是可以是指向函数或者数组的指针。
6.1节练习
练习 6.1:实参和形参的区别是什么?
形参是在函数定义时写在函数名之后的列表,表明该函数接受几个什么类型的参数。实参是函数调用时函数用户写在调用运算符中的值,是形参的初始值。
练习 6.2:请指出下列函数哪个有错误,为什么?应该如何修改这些错误呢?
// a:
int f() {
std::string s;
// ...
return s;
}
// 返回值的类型和函数返回类型不匹配
// 修改后:
std::string f() {
std::string s;
// ...
return s;
}
// b:
f2(int i) { /* ... */ }
// 缺少返回类型,如果没有返回值,那么返回类型为void
// 修改后:
void f2(int i) { /* ... */ }
// c:
int calc(int v1, int v2) /* ... */ }
// 函数体花括号不成对
// 修改后:
int calc(int v1, int v2) { /* ... */ }
// d:
double square(double x) return x * x;
// 函数体必须使用花括号括起来
// 修改后:
double square(double x) { return x * x; }
练习 6.3:编写你自己的fact函数,上机检查是否正确。
int fact(int n)
{
int ret = 1;
for (int i = 1; i <= n; ++i) ret *= i;
return ret;
}
练习 6.4:编写一个与用户交互的函数,要求用户输入一个数字,计算生成该数字的阶乘。在main函数中调用该函数。
#include <iostream>
void user_fact();
int main(int argc, char const *argv[])
{
user_fact();
return 0;
}
void user_fact()
{
std::cout << "Enter a positive integer: ";
int input_number;
std::cin >> input_number;
int res = 1;
for (int i = 1; i <= input_number; ++i) res *= i;
std::cout << "The result is: " << res << std::endl;
}
练习 6.5:编写一个函数输出其实参的绝对值。
#include <iostream>
void abs(double val)
{
if (val >= 0) std::cout << val << std::end;
else std::cout << -val < std::endl;
}
6.1.1 局部对象
名字有作用域,对象有生命周期。
- 名字的作用域是程序文本的一部分,名字在其中可见。
- 对象的生命周期是程序执行过程中该对象存在的一段时间。
函数体是一个语句块。块构成一个新的作用域,我们可以在其中定义变量。形参和函数体内部定义的变量统称为局部变量。它们仅仅在函数的作用域内可见,同时局部变量还会覆盖在外层作用域中同名的其他所有同名的声明。
在所有函数体之外定义的对象存在于程序的整个执行过程之中。此类对象在程序启动时被创建,直到程序结束才会被销毁。局部变量的生命周期依赖于定义的方式。
自动对象
自动对象指的是:只存在于块执行期间的对象。当块的执行结束后,块中创建的自动对象的值就变成未定义的了。
形参就是一种自动对象。函数开始时为形参申请存储空间,因为形参定义在函数体作用域之内,所以一旦函数终止,形参也就被销毁。
使用传递给函数的实参初始化形参对应的自动对象。对于局部变量,也就是声明定义在函数体内的变量来说分两种情况:如果变量定义本身含有初始值,就用这个初始值进行初始化;否则,如果变量定义本身不含初始值,执行默认初始化。这里表明:内置类型的未初始化局部变量将产生未定义的值。
局部静态对象
局部静态对象在程序的执行路径第一次经过对象定义语句时初始化,直到程序终止才被销毁。在此期间即使对象所在的函数结束执行也不会对它产生影响。
如果局部静态变量没有显式的初始值,它将执行值初始化,内置类型的局部静态变量初始化为0。
6.1.1节练习
练习 6.6:说明形参、局部变量以及局部静态变量的区别。编写一个函数,同时用到这三种形式。
形参是一种自动对象,必须由调用者提供相对应的实参进行初始化,如果不能提供足够数量的实参,那么编译器会报错。局部变量是声明在块内部的变量,生命周期是变量声明到块作用域结束,内置类型变量如果不提供初始值,将执行值初始化,也是一种未定义的行为。局部静态变量存在于整个程序执行过程之中,也就是说一旦声明,直到程序结束之前都是存在的,块的执行完成与否不影响其生命周期。
int func(int val)
{
static int times_of_calling = 0;
int local_var = 10;
}
练习 6.7:编写一个函数,当它第一次被调用时返回0,以后每次被调用返回值加1。
int func()
{
static int ret = -1;
ret += 1;
return ret;
}
6.1.2 函数声明
函数的名字必须在使用前声明。函数只能定义一次,但是可以声明多次。如果一个函数永远不会被用到,那么它可以只有声明没有定义。
函数的声明无须函数体,使用一个分号代替即可。
函数的声明不包含函数体,所以也就无须形参的名字。当然,我们建议写出形参的名字,它可以帮助使用者或者程序员更好的理解函数的功能。
函数的三要素(返回类型,函数名,形参类型)描述了函数的接口,说明了调用该函数所需的全部信息。函数声明也称作函数原型。
在头文件中进行函数声明
我们建议变量和函数在头文件中声明,在源文件中定义。
含有函数声明的头文件应该被包含到定义函数的源文件中。
6.1.2节练习
练习 6.8:编写一个名为Chapter6.h的头文件,令其包含6.1节练习(第184页)中的函数声明。
// Chapter6.h
int f();
void f2(int i);
int calc(int v1, int v2);
double square(double x);
int fact(int n);
int user_fact();
double abs(double val);
6.1.3 分离式编译
C++语言支持所谓的分离式编译。分离式编译允许我们把程序分割到几个文件中去,每个文件独立编译。
编译和链接多个源文件
6.1.3节练习
练习 6.9:编写你自己的fact.cc和factMain.cc,这两个文件都应该包含上一小节的练习中编写的Chapter6.h头文件。通过这些文件,理解你的编译器是如何支持分离式编译的。
6.2 参数传递
每次调用函数时都会重新创建它的形参,并用传入的实参对形参进行初始化。形参的初始化机理和变量初始化一样。
形参的类型决定了形参和实参交互的方式。如果形参是引用类型,它将绑定到对应的实参上;否则,将实参的值拷贝后赋给形参。
当形参是引用类型时,我们说它对应的实参被引用传递或者函数被传引用调用。引用形参也是它绑定的对象的别名;也就是说,引用形参是它对应的实参的别名。
当实参的值被拷贝给形参时,形参和实参是两个相互独立的对象。这样的实参被值传递或者函数被传值调用。
6.2.1 传值参数
当初始化一个非引用类型的变量时,初始值被拷贝给变量。此时,对变量的改动不会影响初始值。如果发生的是值传递,函数对形参做的所有操作都不会影响实参。
指针形参
指针的行为和其他非引用类型一样。当执行指针拷贝操作时,拷贝的是指针的值。拷贝之后,两个指针是不同的指针。因为指针使我们可以间接访问它所指向的对象。所以通过指针可以修改它所指向对象的值。指针的传递本质上还是值传递。
在C++中,一般情况下建议使用引用类型的形参来代替指针传递。
6.2.1节练习
练习 6.10:编写一个函数,使用指针形参交换两个整数的值。在代码中调用该函数并输出交换后的结果,以此验证函数的正确性。
#include <iostream>
void swap(int* vp1, int* vp2)
{
int temp = *vp1;
*vp1 = *vp2;
*vp2 = temp;
}
int main(int argc, char const *argv[])
{
int val1 = 10;
int val2 = 15;
std::cout << val1 << " " << val2 << std::endl;
swap(&val1, &val2);
std::cout << val1 << " " << val2 << std::endl;
return 0;
}
6.2.2 传引用参数
对于引用的操作,实际上是作用在“引用”所引用的对象上。所以通过引用形参,允许函数改变一个或者多个实参的值。引用形参绑定传入的实参,而非拷贝实参。
使用引用避免拷贝
拷贝大的类类型对象或者容器对象非常低效,甚至有的类型根本不支持拷贝操作。当某种类型不支持拷贝操作时,函数只能通过引用形参访问该类的对象。
如果函数无须改变引用形参的值,最好将其声明为常量引用。
使用引用形参返回额外信息
一个函数只能有一个返回类型,一般情况下只能返回一个值。但是有时需要返回多个值,一种方法是定义一个新的数据类型,让它包含多个数据成员。还有一种就是传入引用,在函数中修改传入的实参。
6.2.2节练习
练习 6.11:编写并验证你自己的reset函数,使其作用于引用类型的参数。
void reset(int& val) { val = 0; }
练习 6.12:改写6.2.1节中练习6.10(第188页)的程序,使用引用而非指针交换两个整数的值。你觉得哪种方法更易于使用呢?为什么?
void swap(int& v1, int& v2)
{
int temp = v1;
v1 = v2;
v2 = temp;
}
显然引用版本的值交换函数更易于使用,指针版本的交换过程中还需要涉及解引用。
练习 6.13:假设T是某种类型的名字,说明以下两个函数声明的区别:一个是void f(T)
,另一个是void f(T&)
。
第一个是T类型的值传递。第二个是T类型的引用传递。
练习 6.14:举一个形参应该是引用类型的例子,再举一个形参不能是引用类型的例子。
当传递标准输入输出流对象的时候必须设置成引用类型,因为iostream类不支持拷贝操作;当需要将一棵二叉树遍历然后将节点存入容器时,引用类型不能当作对象存在容器里,那么就必须使用指针。
练习 6.15:说明find_char函数中的三个形参为什么是现在的类型,特别说明为什么s是常量引用而occurs是普通引用?为什么s和occurs是引用类型而c不是?如果令s是普通引用会发生什么情况?如果令occurs是常量引用会发生什么情况?
// 返回s中c第一次出现的位置索引
// 引用形参occurs负责统计c出现的总次数
using index = std::string::size_type;
index find_char(const std::string& s, char c, int& occurs)
{
auto ret = s.size();
occurs = 0;
for (index i = 0; i != s.size(); ++i) {
if (s[i] == c) {
if (ret == s.size()) { ret = i; }
occurs += 1;
}
}
return ret;
}
字符串s因为不需要修改所以使用const进行修饰;并且因为是复杂类型所以使用引用传递来减少开销。
字符c不需要任何的修改且是内置类型所以使用普通的值传递。
计数器occurs因为需要修改所以传递引用,这里和书上不同的是我们使用int来计数。
如果令s为普通引用我们就无法传入字符串字面值进行转换;如果令occurs为常量引用会导致occurs无法修改导致计数失败。
6.2.3 const形参和实参
这里我们主要强调的是顶层和底层const的区别。顶层const作用于当前对象本身。当使用实参初始化形参时会忽略掉顶层const。换句话说,形参的顶层const被忽略掉了。<u>当形参有顶层const时,传给它常量对象或者非常量对象都是可以的</u>。
C++中,顶层const会被编译器忽略掉,所以仅仅在顶层const上存在差异的函数重载是错误的。
指针或者引用形参与const
形参的初始化方式和变量的初始化方式是一样的。我们可以使用非常量初始化一个底层const对象,然而反之不行。一个普通的引用必须使用同类型的对象初始化。
尽量使用常量引用
把函数不会改变的形参定义成普通引用是一种比较常见的错误,这么做带给函数的调用者是一种误导,即函数可以修改它的实参的值。我们不能把const对象、字面值或者需要类型转换的对象传递给普通的引用形参。
6.2.3节练习
练习 6.16:下面的这个函数虽然合法,但是不算特别有用。指出它的局限性并设法改善。
bool is_empty(std::string& s) { return s.empty(); }
这个函数只能传入普通的string对象,const对象和字符串字面值都是无法进行传递的,应该修改为:
bool is_empty(const std::string& s) { return s.empty(); }
练习 6.17:编写一个函数,判断string对象中是否有大写字母。编写另一个函数,把string对象全都改成小写形式。在这两个函数中你使用的形参类型相同吗?为什么?
bool is_contains_upper(const std::string& s)
{
for (char ch : s) {
if (ch >= 'A' && ch <= 'Z') return true;
}
retrn false;
}
void to_lower(std::string& s)
{
for (char& ch : s) { tolower(ch); }
}
这两个函数的形参列表不一样,第一个函数的形参需要设定为const,这样就可以传递常量对象和字面值。第二个需要修改字符串中的内容,于是应当设置成为非常量引用。
练习 6.18:为下面的函数编写函数声明,从给定的名字中推测函数具备的功能。
bool compare(const matrix& lhs, const matrix& rhs);
// 比较两个矩阵是否相同,相同返回true,否则返回false
vector<int>::iterator change_val(int val, vector<int>::iterator targ);
// 传入一个迭代器和一个值,将该迭代器的内容改为传入的值之后返回当前的迭代器
练习 6.19:假定有如下声明,判断哪个调用合法,哪个调用不合法。对于不合法的函数调用,说明原因。
#include <string>
double calc(double);
int count(const std::string&, char);
int sum(vector<int>::iterator, vector<int>::iterator, int);
int main(int argc, char const *argv[])
{
std::vector<int> vec(10);
// a:
calc(23.4, 55.1); // 错误,传入了太多的实参
calc(23.4);
// b:
count("abcda", 'a'); // 正确
// c:
calc(66); // 正确
// d:
sum(vec.begin(), vec.end(), 3.8); // 正确
return 0;
}
练习 6.20:引用形参什么时候应该是常量引用?如果形参应该是常量引用,而我们将其设为了普通引用,将会发生什么情况?
当确定函数不会改变形参绑定的实参时,就应当设为常量引用。如果形参应当是常量引用,而我们将其设为了普通引用,首先会对用户产生误导;其次无法使用常量对象进行调用。
6.2.4 数组形参
数组的两个特殊性质对我们定义和使用作用在数组上的函数有影响,这两个性质分别是:不允许拷贝数组;使用数组时会将其转换为指针。
因为不能拷贝数组,所以我们无法以值传递的方式使用数组参数。因为数组会被转换为指针,所以当我们为函数传递一个数组时,实际上传递的是指向数组首元素的指针。
尽管不能以值传递的方式传递数组,但是我们可以把形参写成类似数组的形式:
// 尽管形式不同,但这三个print函数是等价的
// 每个函数都有一个const int*类型的形参
void print(const int*);
void print(const int[]); // 作用于一个数组
void print(const int[10]); // 这里的维度仅仅是一个期望,实际有多少不一定
如果我们传给print函数的是一个数组,则实参自动地转换成指向数组首元素的指针,数组的大小对函数的调用没有影响。
和其他使用数组的代码一样,以数组作为形参的函数也必须确保使用数组时不会越界。
因为数组是以指针的形式传递给函数的,所以函数无法得知数组的确切尺寸,所以调用者也应当提供数组尽头信息。管理指针形参有三种常用的技术:
- 使用标记指定数组的长度:数组本身包含一个结束标记。
- 使用标准库规范:传递指向数组首元素和尾后元素的指针。
- 显式传递一个表示数组大小的形参:额外增加一个数组长度参数。
数组形参和const
当函数不需要对数组元素执行写操作的时候,数组形参应该是指向const的指针。
数组引用形参
C++允许将变量定义成为数组的引用。形参也可以是数组的引用。这时,引用形参绑定到对应的实参上,也就是绑定到数组上:
// 形参是数组的引用,维度是类型的一部分
void print(int (&arr)[10]) // 这里的括号不可少,而且引用中的维度是有意义的
{
for (int elem : arr)
std::cout << elem << " ";
std::cout << std::endl;
}
传递多维数组
所谓的多维数组其实是数组的数组。
当多维数组传递给函数时,真正传递的是指向数组首元素的指针,但是这里进行传递时一定要给出数组维度的第二维:数组第二维(以及后面的所有维度)的大小都是数组类型的一部分,不能省略。
void print(int mat[][10], int row_size) { /* ... */ }
6.2.4节练习
练习 6.21:编写一个函数,令其接受两个参数:一个是int型的数,另一个是int指针。桉树比较int值和指针所指的值,返回较大的那一个。在该函数中指针的类型应该是什么?
int func(int val, const int* int_ptr)
{
if (val > *int_ptr) return val;
else return *int_ptr;
}
练习 6.22:编写一个函数,令其交换两个int指针。
void swap_ptr(int*& ptr1, int*& ptr2)
{
int* temp = ptr1;
ptr1 = ptr2;
ptr2 = temp;
}
练习 6.23:参考本节介绍的几个print函数,根据理解编写你自己的版本。依次调用每个函数使其输入下面定义的i和j:
int i = 0, j[2] = {0, 1};
#include <iostream>
void print(int val);
void print(int arr[], int len);
int main(int argc, char const *argv[])
{
int i = 0, j[2] = {0, 1};
print(i);
print(j, 2);
return 0;
}
void print(int val)
{
std::cout << val << std::endl;
}
void print(int arr[], int len)
{
for (int i = 0; i < len; ++i) {
std::cout << arr[i] << " ";
}
std::cout << std::endl;
}
练习 6.24:描述下面这个函数的行为。如果代码中存在问题,请指出并改正。
void print(const int ia[10])
{
for (size_t i = 0; i != 10; ++i)
std::cout << ia[i] << std::endl;
}
因为我们不能将数组进行值传递,所以当我们向一个函数传递数组时,我们实际上传递的是一个指向数组首元素的指针。
在这个问题中,const int ia[10]
实际上等价于const int*
,并且其中的维度是无关紧要的。我们可以传递const int ia[3]
或者const int ia[255]
。如果我们的确想传递一个长度为10的数组,我们可以传递它的引用:
void print10(const int (&arr)[10]) { ... }
6.2.5 main:处理命令行选项
当使用argv中的参数时,一定要记得可选的实参是从argv[1]开始;argv[0]保存程序的名字,而非用户输入。
6.2.5节练习
练习 6.25:编写一个main函数,令其接受两个实参。把实参的内容连成一个string对象并且输出出来。
#include <iostream>
#include <string>
int main(int argc, char const *argv[])
{
std::string s;
for (int i = 1; i < 3; ++i) {
s += argv[i];
}
std::cout << s << std::endl;
return 0;
}
练习 6.26:编写一个程序,使其接受本节所示的选项;输出传递给main函数的实参的内容。
#include <iostream>
int main(int argc, char const *argv[])
{
for (int i = 0; i < argc; ++i) {
std::cout << argv[i] << " ";
}
std::cout << std::endl;
return 0;
}
6.2.6 含有可变形参的函数
为了编写能处理不同数量实参的函数,C++11标准提供了两种主要的方法:
- 如果所有实参类型类型相同,传递一个名为
initializer_list
的标准库类型。 - 如果实参的类型不同,编写可变参数模板。
C++还有一种特殊的形参类型(省略符),可以用它传递可变数量的实参。
initializer_list形参
如果函数的实参数量未知但是全部实参的类型都相同,我们可以使用initializer_list类型的形参。initializer_list是一种标准库类型,用于表示某种特定类型的数组。initializer_list类型定义在同名头文件中。
该容器可以使用迭代器进行操作。
省略符形参
省略符形参是为了便于C++程序访问某些特殊的C代码而设置的。省略符形参应该仅仅用于C和C++通用的类型。
6.2.6节练习
练习 6.27:编写一个函数,它的参数是initializer_list<int>
类型的对象,函数的功能是计算列表中所有元素的和。
int sum(std::initializer_list<int> vals)
{
int sum = 0;
for (int num : vals) {
sum += num;
}
return sum;
}
练习 6.28:在error_msg函数的第二个版本中包含ErrCode类型的参数,其中循环内的elem是什么类型?
elem的类型是const std::string&
。
练习 6.29:在范围for循环中使用initializer_list对象时,应该将循环控制变量声明成引用类型吗?为什么?
如果在循环中不涉及任何的修改,那么就应该将控制变量声明为引用类型。
6.3 返回类型和return语句
return语句终止当前正在执行的函数并将控制权返回到调用该函数的地方。注意这里首先终止当前函数,然后将控制权转移回调用当前函数的地方,有两步。
6.3.1 无返回值函数
没有返回值的return语句只能用在返回类型是void的函数中。返回void的函数并要求非得是return语句,因为在这类函数的最后会隐式的执行return。
void函数如果想在它的中间位置提前退出,可以使用return语句。
强行令void函数返回其他类型的表达式会产生编译错误。
6.3.2 有返回值函数
只要函数的返回类型不是void,则该函数内的每条return语句必须返回一个值。return语句返回值的类型必须与函数的返回类型相同,或者能隐式的转换成函数的返回类型。
在含有return语句的循环后面应该也有一条循环语句,如果没有的话该程序就是错误的。
值是如何被返回的
返回的值用于初始化调用点的接收变量,该接收变量是函数调用的结果。
不要返回局部对象的引用或者指针
函数执行完毕之后,它所占用的储存空间也随之被释放掉。因此,函数终止意味着局部变量的引用将指向不再有效的内存区域。
返回类类型的函数和调用运算符
调用运算符的优先级与点运算符和箭头运算符相同,并且符合左结合律。
引用返回左值
函数的返回类型决定函数调用是否是左值。调用一个返回引用的函数得到左值,其他返回类型为右值。
列表初始化返回值
C++11新标准规定,函数可以返回花括号包围的值的列表。这里注意,返回类型是可以被列表初始化的类型。
如果函数返回的是内置类型,则花括号包围的列表最多包含一个值。如果函数返回的是类类型,由类本身定义初始值如何使用。
主函数main的返回值
允许main函数没有return语句直接结束。如果控制到达了main函数的结尾处而且没有return语句,编译器将隐式的插入一条返回0的return语句。
main函数的返回值可以是看作状态指示器。返回0表示执行成功,返回其他值表示执行失败。
递归
如果一个函数调用了它自身,不管这种调用是直接的还是间接的,都称该函数为递归函数。
在递归函数中,一定有某条路径是不包含递归调用的。无穷递归的情况被称为递归循环。
main函数不能调用它自己。
6.3.2节练习
练习 6.30:编译第200页的str_subrange函数,看看你的编译器是如何处理函数中得错误的。
#include <iostream>
bool str_subrange(const std::string& s1, const std::string& s2)
{
int sz1 = s1.size();
int sz2 = s2.size();
if (sz1 == sz2) return s1 == s2;
int min_sz = sz1 < sz2 ? sz1 : sz2;
for (int i = 0; i < min_sz; ++i) {
if (s1[i] != s2[i]) return;
}
}
// a.cc: In function 'bool str_subrange(const string&, const string&)':
// a.cc:10:29: error: return-statement with no value, in function returning 'bool' [-fpermissive]
// if (s1[i] != s2[i]) return;
// ^~~~~~
练习 6.31:什么情况下返回的引用无效?什么情况下返回常量的引用无效?
返回局部变量的引用无效;返回局部变量的常量引用无效。
练习 6.32:下面的函数合法吗?如果合法,说明其功能,如果不合法,修改其中的错误并解释原因。
int& get(int* arry, int index) { return arry[index]; }
int main(int argc, char const *argv[])
{
int ia[10]; // 声明一个维度为10的数组
for (int i = 0; i != 10; ++i) get(ia, i) = i; // 循环调用函数来设定每个元素的值
return 0;
}
练习 6.33:编写一个递归函数,输出vector对象的内容。
void factorial_print(std::vector<int> v, int index)
{
if (index == v.size()) {
return;
} else {
std::cout << v[index] << " ";
factorial_print(v, index + 1);
}
}
练习 6.34:如果factorial函数的停止条件如下所示,将发生什么情况?if (val != 0)
函数依然正常运作。
练习 6.35:在调用factorial函数时,为什么我们传入的值是val - 1而非val--?
因为当传入val--时,相当于先传值再减1,而且减掉的1是在当前层内,结果就会发生错误。
6.3.3 返回数组指针
函数可以返回数组的指针或者引用。
声明一个返回数组指针的函数
要想在声明func时不使用类型别名,我们必须牢记被定义的名字后面数组的维度:
int arr[10]; // arr是一个含有10个整数的数组
int* arr[10]; // arr是一个含有10个指针的数组
int (*arr)[10] // arr是一个含有10个整数的数组的指针
当需要返回一个数组的时候,我们需要将函数声明成如下的形式:
int (*func(int i))[10];
可以按照以下的顺序来逐层理解该声明的含义:
-
func(int i)
表示调用函数时需要一个int类型的形参。 -
(*func(int i))
意味着我们可以对函数调用的结果执行解引用操作。 -
(*func(int i))[10]
表示解引用func的调用将得到一个大小是10的数组。 -
int (*func(int i))[10]
表示数组中的元素是int类型。
使用尾置返回类型
C++11中有一种简明的方法,就是尾置返回类型。任何函数的定义都能使用尾置返回,但是这种形式对于返回类型比较复杂的函数最有效。
位置返回类型跟在形参列表后并以一个->
符号开头。为了表示函数真正的返回类型跟在形参列表之后,我们在本应该出现返回类型的地方放置一个auto:
// func接受一个int类型的实参,返回一个指针,该指针指向含有10个整数的数组
auto func(int i) -> int(*)[10];
使用decltype
如果我们知道函数返回的指针将指向哪个数组,就可以使用decltype关键字声明返回类型。
6.3.3节练习
练习 6.36:编写一个函数的声明,使其返回数组的引用并且该数组包含10个string对象。不要使用尾置返回类型、decltype或者类型别名。
std::string (&func())[10];
练习 6.37:为上一题的函数再写三个声明,一个使用类型别名,另一个使用位置返回类型,最后一个使用decltype关键字。你觉得哪种形式最好?为什么?
using string_array_refer = std::string(&)[10];
auto func() -> std::string(&)[10];
std::string arr[10];
decltype(arr)& func();
个人认为第一种方式比较好。
练习 6.38:修改arrPtr函数,使其返回数组的引用。
using arr = std::string[10];
arr& arrPtr(int i) { return i % 2 ? odd : even; }
6.4 函数重载
如果同一个作用域内的几个函数名字相同但是形参列表不同,我们称之为重载函数。当调用函数时,编译器会根据传递的实参来推断想要的时哪个函数。
main函数不能重载。
定义重载函数
对于重载函数来说,它们应该在形参数量或者形参类型上有所不同。
不允许两个函数除了返回类型外其他所有的要素都相同。
判断两个形参的类型是否相同
函数声明中,编译器会忽略掉顶层const。
重载和const形参
顶层const不影响传入函数的对象。一个拥有顶层const的形参无法和另一个没有顶层const的形参区分开来。
如果形参是某种类型的指针或者引用,则通过区分其指向的是常量对象还是非常量对象可以实现函数重载,此时的const是底层的。
// 对于接受引用或者指针的函数来说,对象是常量还是非常量对应的形参不同
// 定义了4个独立的重载函数
Record lookup(Account& rhs); // 函数作用于Account的引用
Record lookup(const Account& rhs); // 新函数,作用于常量引用
Record lookup(Account* rhs); // 新函数,作用于指向Account的指针
Record lookup(const Account* rhs); // 新函数,作用于指向常量的指针,底层const
编译器可以通过实参是否是常量来推断应该调用哪个函数。因为const不能转换成其他类型,所以我们只能把const对象或者指向const的指针传递给const形参。相反的,因为非常量可以转换成const,所以上面的4个函数都能作用于非常量对象或者指向非常量对象的指针。当我们传递一个非常量对象或者指向非常量对象的指针时,编译器会优先选用非常量版本的函数。
何时不应该重载函数
尽管函数重载能在一定程度上减轻我们为函数起名字、记名字的负担,但是最好只重载那些确实非常相似的操作。
const_cast和重载
调用重载的函数
函数匹配是指一个过程,在这个过程中我们把函数调用与一组重载函数中的某一个关联起来,函数匹配也叫做重载确定。编译器首先将调用的实参与重载集合中每一个函数的形参进行比较,然后根据比较的结果决定到底调用哪个函数。
现在我们需要掌握的是,当调用重载函数时有三种可能的结果:
- 编译器找到一个与实参最佳匹配的函数,并生成调用该函数的代码。
- 找不到任何一个函数与调用的实参匹配,此时编译器发出无匹配的错误信息。
- 有多于一个函数可以匹配,但是每一个都不是明显的最佳选择。此时也将发生错误,称为二义性调用。
6.4节练习
练习 6.39:说明在下面的每组声明中第二条声明语句是何含义。如果有非法的声明,请指出来。
// a:
int calc(int, int);
int calc(const int, const int);
// 编译器会忽略顶层const,重复声明
// b:
int get();
double get();
// 函数重载不允许仅仅在返回类型上有区别,重复声明
// c:
int* reset(int*);
double* reset(double*);
// 重载成功
6.4.1 重载与作用域
如果我们在内层作用域中声明名字,它将隐藏外层作用域中声明的同名实体。在不同的作用域中无法重载函数名。
在C++中,名字查找发生在类型检查之前。即:先找到名字,再检查类型。
6.5 特殊用途语言特性
默认实参、内联函数和constexpr函数,以及再程序调试过程中常用的一些功能。
6.5.1 默认实参
我们可以为一个或者多个形参都定义默认值,一旦某个形参被赋予了默认值,它右边的所有形参都必须默认值。
使用默认实参调用函数
如果我们想使用默认实参,只要在调用函数的时候省略该实参就可以了。
函数调用时实参按位置解析,默认实参负责填补函数调用缺少的尾部实参(右侧位置)。
当设计含有默认实参的函数时,其中一项任务是合理设置形参的顺序,尽量让不怎么使用默认值的形参出现在前面,而让那些经常使用默认值的形参出现在后面(左边的没有默认值可以,但是有默认值的右边不能没有默认值)。
默认实参声明
在给定的作用域中一个形参只能被赋予一次默认实参。换句话说,函数的后续声明只能为之前那些没有默认值的形参添加默认实参,而且该形参右侧的所有形参必须都有默认值。
通常,应该在函数声明中指定默认实参,并将该声明放在合适的头文件中。
默认实参初始值
局部变量不能作为默认实参。除此之外,只要表达式的类型能转换成形参所需的类型,该表达式就能作为默认实参。
用作默认实参的名字在函数生命所在的作用域内解析,而这些名字的求值过程发生在函数调用时。
6.5.1节练习
练习 6.40:下面的哪个声明是错误的?为什么?
int ff(int a, int b = 0, int c = 0); // 正确
char* init(int ht = 24, int wd, char bakgrnd); // 错误:第一个默认值形参的右边的形参必须有默认值
练习 6.41:下面的哪个调用是非法的?为什么?哪个调用虽然合法但显然与程序员的初衷不符?为什么?
char* init(int ht, int wd = 80, char bakgrnd = ' ');
// a:
init(); // 非法,没有足够的实参初始化形参
// b:
init(24, 10); // 合法
// c:
init(14, '*'); // 合法,但是结果为:init(14, '*', ''); 其中'*'被转换成整数
练习 6.42:给make_plural函数的第二个形参赋予默认实参's',利用新版本的函数输出单词success和failure的单数和复数形式。
#include <iostream>
#include <string>
std::string make_plural(size_t ctr, const std::string& word, const std::string& ending = "s")
{
return (ctr > 1) ? word + ending : word;
}
int main(int argc, char const *argv[])
{
std::cout << make_plural(1, "success") << std::endl;
std::cout << make_plural(10, "success", "es") << std::endl;
std::cout << make_plural(1, "failure") << std::endl;
std::cout << make_plural(10, "failure") << std::endl;
return 0;
}
6.5.2 内联函数和constexpr函数
调用函数一般比求等价表达式的值要慢一些。在大多数机器上,一次函数调用其实包含着一系列工作:调用前要先保存寄存器,并在返回时恢复;可能需要拷贝实参;程序转向一个新的位置继续执行。
内联函数可避免函数调用的开销
将函数指定为内联函数,通常就是将它在每个调用点上“内联的”展开。从而消除函数运行时的开销。
在函数的返回类型前面加上关键字inline,这样就可以将它声明成内联函数了。
内联说明只是向编译器发出的一个请求,编译器可以选择忽略这个请求。
constexpr函数
constexpr函数是指能用于常量表达式的函数。定义constexpr函数的方法与其他函数类似,不过要遵循几项约定:
- 函数的返回值以及所有形参的类型都得是字面值类型。
- 函数体中必须有且仅有一条return语句。
constexpr int new_size() { return 42; }
constexpr int foo = new_size();
constexpr函数被隐式的指定为内联函数。
把内联函数和constexpr函数放在头文件内
内联函数和constexpr函数可以在程序中多次定义。
6.5.2节练习
练习 6.43:你会把下面的哪个声明和定义放在头文件中?哪个放在源文件中?为什么?
inline bool eq(const BigInt&, const BigInt&) { } // 头文件,因为有内联关键字
void putValues(int* arr, int size); // 头文件,仅仅是一条函数的声明
练习 6.44:将6.2.2节(第189页)的isShorter函数改写成内联函数。
inline bool isShorter(const std::string& s1, const std::string& s2) { return s1.size() < s2.size(); }
练习 6.45:回顾在前面的练习中编写的那些函数,它们应该是内联函数吗?如果是,将他们改写成内联函数;如果不是,说明原因。
是否内联遵循一个准则,一般复杂程度不超过两行的程序可以比较方便的内联展开,只要超过两行就不建议内联。
练习 6.46:能把isShorter函数定义成constexpr函数吗?如果能,将它改写成constexpr函数;如果不能,说明原因。
不能,因为constexpr函数要求返回类型是字面值类型。
6.5.3 调试帮助
当应用程序编写完成准备发布时,要先屏蔽调试代码。这种方法用到两项预处理功能:assert和NDEBUG。
assert预处理宏
assert是一种预处理宏。所谓预处理宏其实是一个预处理变量,它的行为有点类似内联函数。assert宏使用一个表达式作为其条件:assert(expr);
。
首先对expr求值,如果表达式为假,assert输出信息并终止程序。如果表达式为真,assert什么也不做。
NDEBUG预处理变量
assert的行为依赖于一个名为NDEBUG的预处理变量的状态:如果定义了NDEBUG,则assert什么也不做。默认状态下没有定义NDEBUG,此时assert将执行运行检查。
我们可以使用#define语句定义NDEBUG,从而关闭调试状态。
6.5.3节练习
练习 6.47:改写6.3.2节(第205页)练习中使用递归输出vector内容的程序,使其有条件的输出与执行过程有关的信息。例如,每次调用时输出vector对象的大小。分别在打开和关闭调试器的情况下编译并执行这个程序。
#include <iostream>
#include <vector>
void factorial_print(std::vector<int> v, int index)
{
#ifndef NDEBUG
std::cout << __func__ << ": vector size is " << v.size() << " ";
#endif
if (index == v.size()) {
return;
} else {
std::cout << v[index] << std::endl;
factorial_print(v, index + 1);
}
}
int main(int argc, char const *argv[])
{
std::vector<int> v = {4,3,6,4,5,7,8,2,4,6};
factorial_print(v, 0);
return 0;
}
练习 6.48:说明下面这个循环的含义,他对assert的使用合理吗?
std::string s;
while (std::cin >> s && s != sought) { }
assert(std::cin);
这个程序想表达的是,只要输入合法,或者输入的内容和sought不相同,就向字符串s中不停的输入。有两种情况,首先输入合法但是内容等于sought,退出循环,然后assert语句什么都不做。其次输入不合法,退出循环,然后std::cin判定为false,终止程序。所以是合理的。
6.6 函数匹配
确定候选函数和可行函数
函数匹配的第一步是选定本次调用对应的重载函数集,集合中的函数称为候选函数。候选函数具备两个特征:
- 与被调用的函数同名。
- 其声明在调用点可见。
第二步是考察本次调用提供的实参,然后从候选函数中选出能被这组实参调用的函数,这些新选出的函数称为可行函数。可行函数也有两个特征:
- 形参数量与本次调用提供的实参数量相等。
- 每个实参的类型与对应的形参类型相同,或者能转换成形参的类型。
如果没有找到可行函数,编译器将报告无匹配函数的错误。
寻找最佳匹配(如果有的话)
函数匹配的第三步是从可行函数中选择与本次调用最匹配的函数。它的基本思想是:实参类型与形参类型越接近,它们匹配的越好。
含有多个形参的函数匹配
选择可行函数的方法和只有一个实参时一样,编译器选择那些形参数量满足要求且实参类型和形参类型能够匹配的函数。接下来,编译器依次检查每个实参以确定哪个函数是最佳匹配。如果有且仅有一个函数满足条件,则匹配成功:
- 该函数每个实参的匹配都不劣于其他可行函数需要的匹配。
- 至少有一个实参的匹配优于其他可行函数提供的匹配。
如果在检查了所有实参之后没有选中任何一个函数,则该调用是错误的。
调用重载时应尽量避免强制类型转换。如果在实际应用中确实需要强制类型转换,则说明我们设计的形参集合不合理。
6.6节练习
练习 6.49:什么是候选函数?什么是可行函数?
候选函数:和被调用函数同名;声明在调用点可见。
可行函数:形参数量与本次调用所提供的实参数量相等;每个实参类型与形参类型匹配。
练习 6.50:已知有第217页对函数f的声明,对于下面的每一个调用列出可行函数。其中哪个函数是最佳匹配?如果调用不合法,是因为没有可匹配的函数还是因为调用具有二义性?
void f(); // 1
void f(int); // 2
void f(int, int); // 3
void f(double, double = 3.14); // 4
f(2.56, 42); // 4
f(42); // 2
f(42, 0); // 3
f(2.56, 3.14) // 4
练习 6.51:编写函数f的4个版本,令其各输出一条可以区分的消息。验证上一个练习的答案,如果你回答错了,反复研究本节的内容直到你弄清自己错在何处。
void f(); // 1
void f(int); // 2
void f(int, int); // 3
void f(double, double = 3.14); // 4
f(2.56, 42); // 这个调用其实是二义性,因为3和4都涉及一个类型转换
6.6.1 实参类型转换
-
精确匹配,包括以下情况:
- 实参类型和形参类型相同。
- 实参从数组类型或函数类型转换成对应的指针类型。
- 向实参添加顶层const或者从实参中删除顶层const。
- 通过const转换实现的匹配。
- 通过类型提升实现的匹配。
- 通过算术类型转换或者指针转换实现的匹配。
- 通过类类型转换实现的匹配。
需要类型提升和算术类型转换的匹配
内置类型的提升和转换可能在函数匹配时产生意想不到的结果。
小整型一般都会提升到int类型或者更大的整数类型。
所有算术类型转换的级别都一样。
参数匹配和const实参
如果重载函数的区别在于它们的引用类型的形参是否引用了const,或者指针类型的形参是否指向const,则当调用发生时编译器通过实参是否是常量来决定选择哪个函数。
6.6.1节练习
练习 6.52:已知有以下声明:
void manip(int, int);
double dobj;
请指出下列调用中每个类型转换的等级。
// a:
manip('a', 'z'); // 第3级
// b:
manip(55.4, dobj); // 第3级
练习 6.53:说明下列每组声明中的第二条语句会产生什么影响,并指出哪些不合法(如果有的话)。
int calc(int&, int&);
int calc(const int&, const int&); // 合法,传入常量引用
int calc(char* char*);
int calc(const char*, const char*); // 合法,传入底层const指针
int calc(char*, char*);
int calc(char* const, char* const); // 不合法,编译器会忽略掉顶层const,重复声明
6.7 函数指针
函数指针指向的是函数而非对象。函数指针指向某种特定的类型。函数的类型由它的返回类型和形参共同决定。
要想声明一个可以指向函数的指针,只需要用指针替换函数名即可。
使用函数指针
当我们把函数名作为一个值使用时,该函数自动的转换成指针,也就是说,取地址符是可选的。
我们可以直接使用指向函数的指针调用该函数,无须提前解引用指针。
重载函数的指针
重载函数的指针需要在指针声明时确定形参列表,这样就可以进行精确重载了。
函数指针形参
返回指向函数的指针
int (*func(int))(int*, int);
func函数的形参列表是int
,返回类型是int(*)(int*, int*)
:一个指向函数的指针。
将auto和decltype用于函数指针类型
6.7节练习
练习 6.54:编写函数的声明,令其接受两个int形参并且返回类型也是int;然后声明一个vector对象,令其元素是指向该函数的指针。
int func(int, int);
std::vector<int(*)(int, int)> func_ptr_vec;
练习 6.55:编写4个函数,分别对两个int值执行加、减、乘、除运算;在上一题创建的vector对象中保存指向这些函数的指针。
int add(int n1, int n2) { return n1 + n2; }
int minus(int n1, int n2) { return n1 - n2; }
int multiply(int n1, int n2) { return n1 * n2; }
int divide(int n1, int n2) { return n1 / n2; }
std::vector<int(*)(int, int)> func_ptr_vec = {add, minus, multiply, divide};
练习 6.56:调用上述vector对象中的每个元素并输出结果。
#include <iostream>
#include <vector>
int add(int n1, int n2) { return n1 + n2; }
int minus(int n1, int n2) { return n1 - n2; }
int multiply(int n1, int n2) { return n1 * n2; }
int divide(int n1, int n2) { return n1 / n2; }
int main(int argc, char const *argv[])
{
std::vector<int(*)(int, int)> func_ptr_vec = {add, minus, multiply, divide};
int v1 = 18;
int v2 = 6;
for (auto func : func_ptr_vec) {
std::cout << func(v1, v2) << std::endl;
}
return 0;
}
// 24
// 12
// 108
// 3
网友评论