const
const
initializer_list
形参main
的返回值decltype
const
形参const_cast
和重载constexpr
函数constexpr
函数放在头文件内assert
预处理宏NDEBUG
预处理变量const
实参auto
和decltype
用于函数指针类型举例:编写一个求数的阶乘的程序。n
的阶乘是从1
到n
所有数字的乘积,例如5
的阶乘是120
:1*2*3*4*5 = 120
。
// 用 while 循环
int fact (int val)
{
int ret = 1; // 局部变量,用于保存计算结果
while (val > 1)
ret *= val--; // 把 ret 和 val 的乘积赋给 ret,然后将 val 减 1
return ret; // 返回结果
}
// 普通 for 循环
int fact (int val)
{
if (val < 0)
return -1;
int ret = 1;
// 从 1 连乘到 val
for (int i = 1; i != val + 1; ++i)
ret *= i;
return ret;
}
int main()
{
int j = fact(5); // j 等于 120,即 fact(5) 的结果
cout << "5! is " << j << endl;
return 0;
}
函数的调用完成两项工作:
一、用实参初始化函数对应的形参;
二、将控制权转移给被调用函数,此时主调函数(calling function)的执行被暂时中断,被调函数(called function)开始执行。
return
语句完成的两项工作:
一、返回return
语句中的值(如果有的话);
二、将控制权从被调函数转移回主调函数。
函数的形参列表可以为空,要想顶一个不带形参的函数,最常用的办法是书写一个空的形参列表。不够为了与C语言兼容,也可以使用关键字void
表示函数没有形参:
void f1() {
/* ...*/ } // 隐式地定义空形参列表
void f2(void) {
/* ...*/} // 显式地定义空形参列表
每个形参都是含有一个声明符的声明,即使两个形参的类型一样,也必须把两个类型都写出来:
int f3(int v1, v2) {
/* ...*/ } // 错误
int f4(int v1, int v2) {
/* ...*/ } // 正确
一种特殊的返回类型是void
,它表示函数不返回任何值。
函数的返回类型不能是数组类型或函数类型,但可以是指向数组或函数的指针。
在C++语言中,名字有作用域,对象有生命周期(lifetime)。
函数体是一个语句块,块构成一个新的作用域,形参和函数体内部定义的变量称为局部变量(local variable)。
我们把只存在于块执行期间的对象称为自动对象(automatic object)。
形参是一种自动对象。函数开始时为形参申请存储空间,因为形参定义在函数题作用域之内,所以一旦函数终止,形参也就被销毁。
有些时候,有必要令局部变量的生命周期贯穿函数调用及之后的时间。可以将局部变量定义成static
类型从而获得这样的对象。
局部静态对象(local static object)在程序执行路径第一次经过对象定义语句时初始化,并且直到程序终止才被销毁,在此期间即使对象所在的函数结束执行也不会对它有影响。
// 这段程序将输出从 1 到 10(包括 10 在内)的数组
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;
}
/* Output:
1
2
3
4
5
6
7
8
9
10
*/
在控制流第一次经过ctr
的定义之前,ctr
被创建并初始化为0
。每次调用将ctr
加1
并返回新值。每次执行count_calls
函数时,变量ctr
的值都已经存在并且等于函数上一次推出时ctr
的值。因此,第二次调用时ctr
的值是1
,第三次调用时ctr
的值是2
,以此类推。
练习6.7:编写一个函数,当它第一次被调用时返回 0,以后每次被调用返回值加 1。
/* 练习6.7:编写一个函数,当它第一次被调用时返回 0,以后每次被调用返回值加 1。*/
#include
using namespace std;
// 这段程序将输出从 1 到 10(包括 10 在内)的数组
unsigned myCnt()
{
// static size_t ctr = 0; // 调用结束后,这个值仍然有效
// return ++ctr;
static unsigned iCnt = -1; // iCnt 是静态局部变量
++iCnt;
return iCnt;
}
int main()
{
cout << "Please enter any char and press enter to continue." << endl;
char ch;
while (cin >> ch)
{
cout << "The function myCnt() has been called: " << myCnt() << " times." << endl;
}
return 0;
}
/* Output:
Please enter any char and press enter to continue.
s
The function myCnt() has been called: 0 times.
1
The function myCnt() has been called: 1 times.
t
The function myCnt() has been called: 2 times.
-
The function myCnt() has been called: 3 times.
^Z
*/
函数声明无须函数体,定义需要函数体。
函数声明也称作函数原型(function prototype)。
函数应该在头文件中声明而在源文件中定义。
练习6.8:编写一个名为Chapter6.h的头文件,令其包含6.1节练习(第184页)中的函数声明。
#ifndef CHAPTER6_H_INCLUDED
#define CHAPTER6_H_INCLUDED
int fact(int );
double myABS(double );
double myABS(double );
#endif // CHAPTER6_H_INCLUDED
补充知识漏洞:
#ifndef
和 #endif
要一起使用,如果丢失#endif
,可能会报错。
在c
语言中,对同一个变量或者函数进行多次声明是不会报错的。所以如果h
文件里只是进行了声明工作,即使不使用# ifndef
宏定义,多个c
文件包含同一个h
文件也不会报错。
但是在c++
语言中,#ifdef
的作用域只是在单个文件中。所以如果h
文件里定义了全局变量,即使采用#ifdef
宏定义,多个c文件包含同一个h文件还是会出现全局变量重定义的错误。
使用#ifndef
可以避免下面这种错误:如果在h
文件中定义了全局变量,一个c
文件包含同一个h
文件多次,如果不加#ifndef
宏定义,会出现变量重复定义的错误;如果加了#ifndef
,则不会出现这种错误。
分离式编译允许我们把程序分割到几个文件中去,每个文件独立编译。
不熟悉,待整理 P187
当形参是引用类型时,我们说它对应的实参被引用传递(passed by reference)或者函数被传引用调用(called by reference)。和其他引用一样,引用形参也是它绑定的对象的别名;也就是说,引用形参是它对应的实参的别名。
当实参的值被拷贝给形参时,形参和实参是两个相互独立的对象。我们说这样的实参被值传递(passed by value)或者函数被传值调用(called by value)。
练习6.13:假设 T 是某种类型的名字,说明以下两个函数声明的区别:一个是 void f(T),另一个是 void f(&T)。
/* 练习6.13:假设 T 是某种类型的名字,说明以下两个函数声明的区别:
一个是 void f(T),另一个是 void f(&T)。*/
#include
using namespace std;
void a(int); // 传值参数
void b(int &); // 传引用参数
int main()
{
int s = 0, t = 10;
a(s);
cout << s << endl;
b(t);
cout << t << endl;
return 0;
}
void a(int i)
{
++i;
cout << i << endl;
}
void b(int &j)
{
++j;
cout << j << endl;
}
/*Output:
1
0
11
11
*/
指针的行为和其他非引用类型一样。当执行指针拷贝操作时,
int n = 0, i = 42;
int *p = &n, *q = &i; // p 指向 n;q 指向 i
*p = 42; // n 的值改变;p 不变
p = q; // p 现在指向了 i;但是 i 和 n 的值都不变
指针形参的行为与之类似:
// 该函数接受一个指针,然后将指针所指的位置为 0
void reset(int *ip)
{
*ip = 0; // 改变指针 ip 所指对象的值
ip = 0; // 只改变了 ip 的局部拷贝,实参未被改变
}
调用reset
函数之后,实参所指的对象被置为 0
,但是实参本身并没有改变:
int i = 42;
reset(&i); // 改变 i 的值而非 i 的地址
cout << "i = " << i << endl; // 输出 i = 0
熟悉C的程序员常常使用指针类型的形参访问函数外部的对象。
在C++语言中,建议使用引用类型的形参替代指针。
练习6.10:编写一个函数,使用指针形参交换两个整数的值。在代码中调用该函数并输出交换后的结果,以此验证函数的正确性。
/* 练习6.10:编写一个函数,使用指针形参交换两个整数的值。
在代码中调用该函数并输出交换后的结果,以此验证函数的正确性。*/
#include
using namespace std;
// 在函数体内部通过解引用操作改变指针所指的内容
void mySWAP(int *p, int *q)
{
int tmp = *p; // tmp 是一个整数
*p = *q;
*q = tmp;
}
int main()
{
int a = 5, b = 10;
int *r = &a, *s = &b;
cout << "Before the exchange: a = " << a << ", b = " << b << endl;
mySWAP(r, s);
cout << "After the exchange: a = " << a << ", b = " << b << endl;
return 0;
}
/*
Before the exchange: a = 5, b = 10
After the exchange: a = 10, b = 5
*/
练习6.12:改写6.2.1节中练习6.10(第 188 页)的程序,使用引用而非指针交换两个整数的值。
/* 练习6.12:改写6.2.1节中练习6.10(第 188 页)的程序,使用引用而非指针交换两个整数的值。
你觉得哪种方法更易于使用呢?为什么?*/
#include
using namespace std;
void mySWAP(int &i, int &j)
{
int tmp = i;
i = j;
j = tmp;
}
int main()
{
int a = 5, b = 10;
cout << "Before the exchange: a = " << a << ", b = " << b << endl;
mySWAP(a, b);
cout << "After the exchange: a = " << a << ", b = " << b << endl;
return 0;
}
/*
Before the exchange: a = 5, b = 10
After the exchange: a = 10, b = 5
*/
// 与使用指针相比,使用引用交换变量的内容从形式上看更简单一些,并且无须额外声明指针变量,也避免了拷贝指针的值。
// 对于引用的操作实际上是作用在所引的对象上
int n = 0, i = 42;
int &r = n; // r 绑定了 n (即 r 是 n 的另一个名字)
r = 42; // 现在 n 的值是 42
r = i; // 现在 n 的值和 i 相同
i = r; // i 的值和 n 相同
拷贝大的类类我选哪个队吸纳过或者容器对象比较低效,甚至有的类类型(包括IO
类型在内)根本就不支持拷贝操作。当某种类型不支持拷贝操作时,函数只能通过引用形参访问该类型的对象。
举例:string
对象非常长,所以应该尽量避免直接拷贝它们,这时使用引用形参是比较明智的选择。又因为比较长度无须改变string
对象的内容,所以把形参定义成对常量的引用:
// 比较两个 string 对象的长度
bool isShorter(const string &s1, const string &s2)
{
return s1.size() < s2.size();
}
注意:如果函数无须改变应用型参的值,最好将其声明为常量引用。
与值传递相比,引用传递的优势主要体现在三个方面:
一是可以直接操作引用形参所引的对象;
二是使用引用形参可以避免拷贝大的类类型对象或容器类型对象;
三是使用引用形参可以帮助我们从函数中返回多个值
当函数的目的是交换两个参数的内容时应该使用引用类型的形参;
当参数是string
对象时,为了避免拷贝很长的字符串,应该使用引用类型。
在其他情况下可以使用值传递的方式,而无须使用引用传递,例如求整数的绝对值或者阶乘的程序。
举例:定义一个名为find_char
的函数,它返回在string
对象中某个指定字符第一次出现的位置。同时,我们也希望函数能返回该字符出现的总次数。该如何定义函数使得它能够既返回位置也返回出现次数呢?
一种方法是定义一个新的数据类型,让它包含位置和数量两个成员。
还有另一种更简单的方法,我们可以给函数传入一个额外的引用实参,令其保存字符出现的次数:
// 返回 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; // 记录 c 第一次出现的位置
++occurs; // 将出现的次数加 1
}
}
return ret; // 出现次数通过 occurs 隐式地返回
}
// 调用 find_char 函数
auto index = find_char(s, 'o', ctr);
练习6.15:说明 find_char 函数中的三个形参为什么是现在的类型,特别说明为什么 s 是常量引用而 occurs 是普通引用?为什么 s 和 occurs 是引用类型而 c 不是?如果令 s 是普通引用会发生什么情况?如果令 occurs 是常量引用会发生什么情况?
find_char
函数的三个参数的类型设定与该函数的处理逻辑密切相关,原因分别如下:
s
来说,为了避免拷贝长字符串,使用引用类型;同时我们只执行查找操作,无须改变字符串的内容,所以将其声明为常量引用。c
来说,它的类型是 char
,只占1
个字节,拷贝的代价很低,而且我们无须操作实参在内存中实际存储的内容,只把它的值拷贝给形参即可,所以不需要使用引用类型。occurs
来说,因为需要把函数内对实参值的更改反映在函数外部,所以必须将其定义成引用类型;但是不能把它定义成常量引用,否则就不能改变所引的内容了。当形参是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
对象,但是反过来不行;同时一个普通的引用必须用同类型的对象初始化。
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; // 错误:不能用字面值初始化一个非常量引用
// reset() 函数
void reset(int &i) // i 是传给 reset 函数的对象的另一个名字
{
i = 0; // 改变了 i 所引对象的值
}
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 是无符号类型
练习6.22:编写一个函数,令其交换两个 int 指针。
/* 练习6.22:编写一个函数,令其交换两个 int 指针。
【出题思路】有两种理解:
一种是交换指针本身的值,即指针所指的内存地址;另一种是交换指针所指的内容。*/
#include
using namespace std;
/* 第一个函数以值传递的方式使用指针,所有改变都局限于函数内部,
当函数执行完毕后既不会改变指针本身的值,也不会改变指针所指的内容。*/
// 该函数既不交换指针,也不交换指针所指的内容
void SwapPointer1(int *p, int *q)
{
int *temp = p;
p = q;
q = temp;
}
/* 第二个函数同样以值传递的方式使用指针,
但是在函数内部通过 解引用 的方式直接访问内存并修改了指针所指的内容。*/
// 该函数交换指针所指的内容
void SwapPointer2(int *p, int *q)
{
int temp = *p;
*p = *q;
*q = temp;
}
/* 第三个函数的参数形式是 int *&,其含义是,该参数是一个引用,引用的对象是内存中的一个 int 指针,
使用这种方式可以把指针当成对象,交换指针本身的值。
需要注意的是,最后一个函数既然交换了指针,当然解引用该指针所得的结果也会相应发生改变。*/
// 该函数交换指针本身的值,即交换指针所指的内存地址
void SwapPointer3(int *&p, int *&q)
{
int *temp = p;
p = q;
q = temp;
}
int main()
{
int a = 5, b = 10;
int *p = &a, *q = &b;
cout << "Before the exchange: " << endl;
cout <<"The value of p is: " << p << ", the value of q is: " << q << endl;
cout << "p is pointing to: " << *p << ", q is pointing to: " << *q << endl;
SwapPointer1(p, q);
cout << "After the exchange: " << endl;
cout <<"The value of p is: " << p << ", the value of q is: " << q << endl;
cout << "p is pointing to: " << *p << ", q is pointing to: " << *q << endl;
/*
Before the exchange:
The value of p is: 0x61ff04, the value of q is: 0x61ff00
p is pointing to: 5, q is pointing to: 10
After the exchange:
The value of p is: 0x61ff04, the value of q is: 0x61ff00
p is pointing to: 5, q is pointing to: 10
*/
int a = 5, b = 10;
int *p = &a, *q = &b;
cout << "Before the exchange: " << endl;
cout <<"The value of p is: " << p << ", the value of q is: " << q << endl;
cout << "p is pointing to: " << *p << ", q is pointing to: " << *q << endl;
SwapPointer2(p, q);
cout << "After the exchange: " << endl;
cout <<"The value of p is: " << p << ", the value of q is: " << q << endl;
cout << "p is pointing to: " << *p << ", q is pointing to: " << *q << endl;
/*
Before the exchange:
The value of p is: 0x61ff04, the value of q is: 0x61ff00
p is pointing to: 5, q is pointing to: 10
After the exchange:
The value of p is: 0x61ff04, the value of q is: 0x61ff00
p is pointing to: 10, q is pointing to: 5
*/
int a = 5, b = 10;
int *p = &a, *q = &b;
cout << "Before the exchange: " << endl;
cout <<"The value of p is: " << p << ", the value of q is: " << q << endl;
cout << "p is pointing to: " << *p << ", q is pointing to: " << *q << endl;
SwapPointer3(p, q);
cout << "After the exchange: " << endl;
cout <<"The value of p is: " << p << ", the value of q is: " << q << endl;
cout << "p is pointing to: " << *p << ", q is pointing to: " << *q << endl;
/*
Before the exchange:
The value of p is: 0x61ff0c, the value of q is: 0x61ff08
p is pointing to: 5, q is pointing to: 10
After the exchange:
The value of p is: 0x61ff08, the value of q is: 0x61ff0c
p is pointing to: 10, q is pointing to: 5
*/
return 0;
}
非常量引用有几个缺陷:
一、容易给使用者一种误导,即程序允许修改变量的内容
二、使用引用而非常量引用会极大地限制函数所能接受的实参类型,我们不能把const
对象、字面值或者需要类型转换的对象传递给普通的引用形参。
练习6.17:编写一个函数,判断 string 对象中是否含有大写字母。编写另一个函数,把 string 对象全都改成小写形式。
/* 练习6.17:编写一个函数,判断 string 对象中是否含有大写字母。
编写另一个函数,把 string 对象全都改成小写形式。
在这两个函数中你使用的形参类型相同吗?为什么?*/
#include
#include
using namespace std;
bool HasUpper(const string &str) // 判断字符串是否含有大写字母
{
for (auto c : str)
if (isupper(c))
return true;
return false;
}
void ChangeToLower(string &str) // 把字符串中的所有大写字母转成小写
{
for (auto &c : str)
c = tolower(c);
}
int main()
{
cout << "Please enter a string: " << endl;
string str;
cin >> str;
if (HasUpper(str))
{
ChangeToLower(str);
cout << "The converted string is: " << str << endl;
}
else
cout << "The string doesn't have capital letter, no need to convert. " << endl;
return 0;
}
练习6.18:为下面的函数编写函数声明,从给定的名字中推测函数具备的功能。
(a) 名为compare
的函数,返回布尔值,两个参数都是matrix
类的引用。
bool compare( const matrix &, const matrix &)
(b) 名为change_val
的函数,返回vector
的迭代器,有两个参数:一个是int
,另一个是vector
的迭代器。
vector<int>::iterator change_val( int, vector<int>::iterator )
// 尽管形式不同,但这三个print函数是等价的
// 每个函数都有一个 const int* 类型的形参
void print(const int*);
void print(const int[]); // 可以看出来,函数的意图是作用域一个数组
void print(const int[10]); // 这里的维度表示我们期望数组含有多少元素,实际不一定,即使实参数组的真实维度不是 10,也可以正常调用 print 函数。
/* 上述 print 函数的定义存在一个潜在风险,即虽然我们期望传入的数组维度是 10,但实际上任意维度的数组都可以传入。如果传入的数组维度较大,print 函数输出数组的前 10 个元素,不至于引发错误;相反如果如果传入的数组维度不足 10,则 print 函数将强行输出一些未定的值。*/
练习6.24:描述下面这个函数的行为。如果代码中存在问题,请指出并改正。
// 题目给的代码
void print(const int ia[10])
{
for (size_t i = 0; i != 10; ++i)
cout << ia[i] << endl;
}
/*当我们把数组作为函数的形参时,有三种可供选择的方式:
一是声明为指针,
二是声明为不限维度的数组,
三是声明为维度确定的数组。
实际上,因为数组传入函数时实参自动转换成指向数组首元素的指针,所以这三种方式是等价的。不过第三种方式存在潜在风险。*/
// 修改后的代码
void print(const int ia[], const int sz)
{
for (size_t i = 0; i != sz; ++i)
cout << ia[i] << endl;
}
编译器处理对 print 函数的调用时,只检查传入的参数是否是 const int*
类型
int i = 0, j[2] = {
0, 1};
print(&i); // 正确:&i 的类型是 int*
print(j); // 正确:j 转换成 int* 并指向 j[0]
// 当传给 print 函数是一个数组,则实参自动地转换成指向数组首元素的指针,数组的大小对函数的调用没有影响
管理数组实参的第一种方法要求数组本身包含一个结束标记,使用这种方法的典型示例是C风格字符串。
C风格字符串存储在字符数组中,并且在最后一个字符后面跟着一个空字符,函数在处理C风格字符串时遇到空字符停止。
这种方法适用于那些有明显结束标记且该标记不会与普通数据混淆的情况,但是对于像 int 这样所有取值都是合法值的数据就不太有效。
void print(const char *cp)
{
if (cp) // 若 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};
// j 转换成指向它首元素的指针,第二个实参是指向 j 的尾后元素的指针
print(begin(j), end(j)); // begin 和 end 函数
第三种管理数组实参的方法是专门定义一个表示数组大小的形参。这个版本的程序通过形参 size
的值确定要输出多少个元素,调用 print
函数时必须传入这个表示数组大小的值。
// const int ia[] 等价于 const int* ia
// size 表示数组的大小,将它显示地传给函数用于控制对 ia 元素的访问
void print(const int ia[], size_t size)
{
for (size_t i = 0; i != size; ++i) {
cout << ia[i] << endl;
}
}
int j[] = {
0, 1 }; // 大小为 2 的整型数组
print(j, end(j) - begin(j));
练习6.23:参考本节介绍的几个 print 函数,根据理解编写你自己的版本。
/* 练习6.23:参考本节介绍的几个 print 函数,根据理解编写你自己的版本。
依次调用每个函数使其输入下面定义的 i 和 j:
int i = 0, j[2] = {0, 1};
【出题思路】根据参数的不同,为 print 函数设计几个版本。版本的区别主要体现在对指针参数的管理方式不同。
第一个版本不控制指针的边界,
第二个版本由调用者指定数组的维度,
第三个版本新规定的 begin 和 end 函数限定数组边界。
*/
#include
using namespace std;
// 参数是常量整型指针
void print1(const int *p)
{
cout << *p << endl;
}
// 参数有两个,分别是常量整型指针和数组的容量
void print2(const int *p, const int sz)
{
int i = 0;
while(i != sz)
{
cout << *p++ << endl;
++i;
}
}
// 参数有两个,分别是数组的首尾边界
void print3(const int *b, const int *e)
{
for (auto q = b; q != e; ++q)
cout << *q << endl;
}
int main()
{
int i = 0, j[2] = {
0, 1};
print1(&i); // 0
print1(j); // 0
print2(&i, 1); // 0
// 计算得到数组 j 的容量
cout << (sizeof(j)/sizeof(*j)) << endl; // 2
print2(j, sizeof(j)/sizeof(*j)); // 0, 1
auto b = begin(j);
auto e = end(j);
print3(b, e); // 0, 1
return 0;
}
const
Note: &arr
两端的括号必不可少:
f(int &arr[10]) // 错误:将 arr 声明成了引用的数组
f(int (&arr)[10]) // 正确:arr 是具有 10 个整数的整形数组的引用
// 正确:形参是数组的引用,维度是类型的一部分
void print(int (&arr)[10])
{
for (auto elem : arr)
cout << elem << endl;
}
再一次强调,*matrix
两端的括号必不可少:
int *matrix[10]; // 10 个指针构成的数组
int (*matrix)[10]; // 指向含有 10 个整数的数组的指针
// matrix 指向数组的首元素,该数组的元素是由 10 个整数构成的数组
void print(int (*matrix)[10], int rowSize) {
/* ...*/ }
我们也可以使用数组的语法定义函数,此时编译器会一如既往地忽略掉第一个维度,所以最好不要把它包括在形参列表内:
// 等价定义
void print(int matrix[][10], int rowSize) {
/* ... */ }
matrix
的声明看起来是一个二维数组,实际上形参是指向含有10
个整数的数组的指针。
有时我们确实需要给main
传递实参,一种常见的情况是用户通过设置一组选项来确定函数所要执行的操作。例如,确定main
函数位于可执行文件prog
之内,我们可以向程序传递下面的选项:prog -d -o ofile data0
这些命令行选项通过两个(可选的)形参传递给main
函数:
int main(int argc, char *argv[]) {
... }
第二个形参argv
是一个数组,它的元素是指向C风格字符串的指针;
第一个形参argc
表示数组中字符串的刷量。
因为第二个形参是数组,所以main
函数也可以定义成:
int main(int argc, char **argv) {
... } // 疑问:不理解 **argv
// 其中 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;
WARNING: 当使用argv
中的实参时,一定要记得可选的实参从argv[1]
开始;argv[0]
保证程序的名字,而非用户输入。
【解释】
一、char **argv
和 char *argv[]
关于 int main(int argc, char** argv)
或者 int main(int argc, char *argv[])
,二者效果相同,但实质不同。
char **argv
:argv
是个指针,指向了char*
型指针。
char **argv
means it is pointing to a space that stores char pointers.
char *argv[]
:argv
是个数组,其中数组的每一个元素都是char*
型指针。
二、int main(int argc, char **argv)
在如今的Visual Studio编译器中,main()
函数带有参数argc
和argv
或者不带,即无论是否在函数体中使用argc
和argv
,返回值为void或不为void,都是合法的。
三、int argc
和 char *argv[]
/ char **argv
int argc
:表示在Dos命令行中的输入的程序名和参数个数之和,为整型。
char *argv[]
/ char **argv
:argv[0]
记录程序名,后面的 argv[i]
记录输入的参数。
练习6.25:编写一个 main 函数,令其接受两个实参。把实参的内容连接成一个 string 对象并输出出来。
/* 练习6.25:编写一个 main 函数,令其接受两个实参。把实参的内容连接成一个 string 对象并输出出来。*/
#include
using namespace std;
// 第一个参数 argc 指明数组中字符串的数量
// 第二个参数 argv 是存有字符串的数组
int main(int argc, char **argv)
{
string str;
for (int i = 0; i != argc; ++i)
str += argv[i];
cout << str << endl;
return 0;
}
练习6.26:编写一个程序,使其接受本节所示的选项:输出传递给 main 函数的实参的内容。
/* 练习6.26:编写一个程序,使其接受本节所示的选项:输出传递给 main 函数的实参的内容。*/
#include
using namespace std;
int main(int argc, char **argv)
{
for (int i = 0; i != argc; ++i)
{
cout << "argc[" << i <<"]: " << argv[i] << endl;
}
return 0;
}
有时我们无法提前预知应该向函数传递几个实参。例如,我们想要编写代码输出程序产生的错误信息,此时最好用同一个函数实现该项功能,以便对所有错误的处理能够整齐划一。
为了编写能处理不同数量实参的函数,C++11新标准提供了两种主要的方法:
initializer_list
的标准库类型;initializer_list
形参如果函数的实参数量未知但是全部实参的类型都相同,我们可以使用 initializer_list
类型的形参。
initialzier_list
是一种标准库类型,用于表达某种特定类型的值的数组。
initialzier_list
类型定义在同名的头文件中。
表6.1: | initializer_list 提供的操作 |
---|---|
initializer_list |
默认初始化;T 类型元素的空列表 |
initializer_list |
lst 的元素数量和初始值一样多;lst 的元素是对应初始值的副本;列表中的元素是const |
lst2(lst) |
拷贝或赋值一个initializer_list 对象不会拷贝列表中的元素; |
lst2 = lst |
拷贝后,原始列表和副本共享元素 |
lst.size() |
列表中的元素数量 |
lst.begin() |
返回指向lst 中首元素的指针 |
lst.end() |
返回指向lst 中尾元素下一位置的指针 |
和vector
一样,initializer_list
也是一种模板类型。定义initialzier_list
对象时,必须说明列表中所含元素的类型:
initializer_list<string> ls; // initializer_list 的元素类型是 string
initializer_list<int> li; // initializer_list 的元素类型是 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
形参中传递一个值的序列,则必须把序列放在一对花括号内:
// expected 和 actual 是 string 对象
if (expected != actual)
error_msg({
"functionX", expected, actual});
else
error_msg({
"functionX", "okay"});
含有initializer_list
形参的函数也可以同时拥有其他形参。
例如,调试系统可能有个名为ErrCode
的类用来表示不同类型的错误,因此我们可以改写之前的程序,使其包含一个initializer_list
形参和一个ErrCode
形参:
void error_msg(ErrCode e, initializer_list<string> il)
{
cout << e.msg() << ": ";
for (const auto &elem : il)
cout << elem << " ";
cout << endl;
}
if (expected != actual)
error_msg(ErrCode(42), {
"functionX", expected, actual});
else
error_msg(ErrCode(0), {
"functionX", "okay"});
练习6.28:在error_msg
函数的第二个版本中包含ErrCode
类型的参数,其中循环内的elem
是什么类型?
initializer_list
的所有元素类型都是string
,因此const auto &elem : il
推断得到的elem
的类型是const string&
。
使用引用是为了避免拷贝长字符串,把它定义为常量的原因是我们只需读取字符串的内容,不需要修改它。
练习6.29:在范围for
循环中使用initializer_list
对象时,应该将循环控制变量声明成引用类型吗?为什么?
引用类型 vs 普通类型:
引用类型的优势主要是可以直接操作所引用的对象以及避免拷贝较为复杂的类类型对象和容器对象。
因为initializer_list
对象的元素永远是常量值,所以我们不可能通过设定引用类型来更改循环控制变量的内容。
只有当initializer_list
对象的元素类型是类类型或容器类型(比如string
时),才有必要把范围for
循环的循环控制变量设为引用类型。
省略符形参只能出现在形参列表的最后一个位置,它的形式无外乎以下两种:
void foo(parm_list, ...);
void foo(...);
练习6.27:编写一个函数,它的参数是 initializer_list 类型的对象,函数的功能是计算列表中所有元素的和。
/* 练习6.27:编写一个函数,它的参数是 initializer_list 类型的对象,函数的功能是计算列表中所有元素的和。
【出题思路】掌握 initializer_list 对象的声明和初始化方法,利用 initializer_list 对象设计形参可变的函数。
【解答】注意 iCount 的参数是 initializer_list 对象,在调用该函数时,我们使用了列表初始化的方式生成实参。*/
#include
using namespace std;
int iCount(initializer_list<int> il)
{
int count = 0;
// 遍历 il 的每一个元素
for (auto val : il)
count += val;
return count;
}
int main()
{
// 使用列表初始化的方式构建 initializer_list 对象
// 然后把它作为实参传递给函数 iCount
cout << "The sum of 1, 6, 9 is: " << iCount({
1, 6, 9}) << endl;
cout << "The sum of 4, 5, 9, 18: " << iCount({
4, 5, 9, 18}) << endl;
cout << "The sum of 10, 10, 10, 10, 10, 10, 10, 10, 10 is: "
<< iCount({
10, 10, 10, 10, 10, 10, 10, 10, 10}) << endl;
return 0;
}
返回void
的函数不要求非得有return
语句,因为在这类函数的最后一句哦后面会隐式地执行return
。
void
函数如果想在它的中间位置提前退出,可以使用return
语句。return
的这种用法有点类似于我们用break
退出循环。
例如:编写一个swap
函数,使其在参与交换的值相等时什么也不做直接退出:
void swap(int &v1, int &v2)
{
// 如果两个值是相等的,则不需要交换,直接退出
if (v1 == v2)
return;
// 如果程序执行到了这里,说明还需要继续完成某些功能
int tmp = v2;
v2 = v1;
v1 = tmp;
// 此处无须显式的 return 语句,隐式地执行 return
}
/* 这个函数首先检查值是否相等,如果相等直接退出函数;如果不相等才交换它们的值。*/
举例:假如我们书写一个函数,给定计数值、单词和结束符之后,判断计数值是否大于1
;如果是,返回单词的复数形式;如果不是,返回单词原形:
// 如果 ctr 的值大于 1,返回 word 的复数形式
string make_plural(size_t ctr, const string &word, const string &ending)
{
return (ctr > 1) ? word + ending : word;
}
// 该函数的返回类型是 string,返回值将被拷贝到调用点。
如果函数返回引用,则该引用仅是它所引对象的一个别名。
举例:假定某函数挑出两个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;
// 以某种方式改变一下 ret
if (!ret.empty())
return ret; // 错误:返回局部对象的引用!
else
return "Empty"; // 错误:"Empty"是一个局部临时量
}
返回局部对象的引用是错误的;同样,返回局部对象的指针也是错误的。
一旦函数完成,局部对象被释放,指针将指向一个不存在的对象。
// 调用 string 对象的 size 成员,该 string 对象是由 shorterString 函数返回的
auto sz = shorterString(s1, s2).size();
// 点运算符可以得到该 string 对象的 size 成员
调用一个返回引用的函数得到左值,其他返回类型得到右值。
char &get_val(string &str, string::size_type ix)
{
return str[ix]; // get_val 假定所引值是有效的
}
int main()
{
string s("a value");
cout << s << endl; // 输出 a value
get_val(s, 0) = 'A'; // 将 s[0] 的值改为 A
cout << s << endl; // 输出 A value
return 0;
}
举例:6.2.6节 P198 的error_msg
函数,该函数的输入是一组可变数量的string
实参,输出由这些string
对象组成的错误信息。
在下面的函数中,我们返回一个vector
对象,用它存放表示错误信息的string
对象:
// P198 如果想向 initializer_list 形参中传递一个值的序列,则必须把序列放在一对花括号内:
// exptect d 和 actual 是 string 对象
if (expected != actual)
error_msg({
"functionX", expected, actual});
else
error_msg({
"functionX", "okay"});
vector<string> process()
{
// expected 和 actual 是 string 对象
if (expected.empty())
return {
}; // 返回一个空 vector 对象
else if (expected == actual)
return {
"functionX", "okay"}; // 返回列表初始化的 vector 对象
else
return {
"functionX", expected, actual};
}
main
的返回值允许main
函数没有return
语句直接结束。如果控制到达了main
函数的结尾处而且没有return
语句,编译器将隐式地插入一条返回0
的return
语句。
main
函数的返回值可以看做是状态指示器。返回0
表示执行成功,返回其他值表示执行失败,其中非0
值的具体含义依机器而定。
为了使返回值与机器无关,cstdlib
头文件定义了两个预处理变量,我们可以使用这两个变量分别表示成功与失败:
int main()
{
if (some_failure)
return EXIT_FAILURE; // 定义在 cstdlib 头文件中
else
return EXIT_SUCCESS; // 定义在 cstdlib 头文件中
}
因为它们是预处理变量,所以既不能在前面加上std::
,也不能在using
声明中出现。
// 计算 val 的阶乘,即 1 * 2 * 3 ... * val
int factorial(int val)
{
if (val > 1)
return factorial(val-1) * val;
return 1; // 当 val 递减到 1 时,递归终止,返回 1
}
在上面的代码中,我们递归地调用factorial
函数以求得从val
中减去1
后新数字的阶乘。当val
递减到1
时,递归终止,返回1
。
练习6.34:如果 factorial 函数的停止条件如下所示,将发生什么情况?
if (val != 0)
解答:因为原文中递归函数的参数类型是int
,所以理论上用户传入factorial
函数的参数可以是负数。按照原程序的逻辑,参数为负数时函数的返回值是1
。
如果修改递归函数的停止条件,则当参数的值为负时,会依次递归下去,执行连续乘法操作直至溢出。因此,不能把if
语句的条件改成上述形式。
练习6.35:在调用factorial
函数时,为什么我们传入的值是val-1
而非val--
?
如果把val-1
改成val--
,则出现一种我们不期望看到的情况,即变量的递减操作与读取变量值的操作共存于同一条表达式中,这时有可能产生未定义的值。
练习6.34:编写一个递归函数,输出 vector 对象的内容。
/* 练习6.34:编写一个递归函数,输出 vector 对象的内容。*/
#include
#include
using namespace std;
void print(vector<int> vInt, unsigned index)
{
unsigned sz = vInt.size();
if (!vInt.empty() && index < sz)
{
cout << vInt[index] << endl;
print(vInt, index + 1);
}
}
int main()
{
vector<int> v = {
1, 3, 5, 7, 9, 11, 13, 15};
print(v, 0);
return 0;
}
/* Output:
1
3
5
7
9
11
13
15
*/
练习6.32:下面的函数合法吗?如果合法,说明其功能;如果不合法,修改其中的错误并解释原因。
/* get 函数接受一个整形指针,该指针实际指向一个整型数组的首元素,
另外还接受一个整数表示数组中某个元素的索引值。
它的返回值类型是整型引用,引用的对象是 arry 数组的某个元素。
当 get 函数执行完毕后,调用者得到实参数组 arry 中索引为 index 的元素的引用。*/
int &get(int *arry, int index)
{
return arry[index]; }
/* 在 main 函数中,首先创建一个包含 10 个整数的数组,名字是 ia。
由于 ia 定义在 main 函数的内部,所以 ia 不会执行默认初始化操作,如果此时我们直接输出 ia 每个元素的值,则这些值都是未定义的。
接下来进入循环,每次循环使用 get 函数得到数组 ia 中第 i 个元素的引用,为该引用赋值 i,也就是说,为第 i 个元素赋值 i。
循环结束时,ia 的元素依次被赋值为 0~9。*/
int main()
{
int ia[10];
for (int i = 0; i != 10; ++i)
get(ia, i) = i;
}
因为数组不能被拷贝,所以函数不能返回数组。不够函数可以返回数组的指针或引用。
要想定义一个返回数组的指针或引用的函数比较繁琐,但可以使用类型别名简化:
typedef int arrT[10]; // arrT 是一个类型别名,它表示的类型是含有 10 个整数的数组
using arrT = int[10]; // arrT 的等价声明
arrT* func(int i); // func 返回一个指向含有 10 个整数的数组的指针
arrT
是含有10
个整数的数组的别名。
因为我们无法返回数组,所以将返回类型定义成数组的指针。
因此,func
函数接受一个int
实参,返回一个指向包含10
个整数的数组的指针。
练习6.36:编写一个函数的声明,使其返回数组的引用并且该数组包含 10 个 string 对象。不要使用尾置返回类型、decltype 或者类型别名。
string (&func())[10];
func()
表示调用 func
函数无须任何实参,(&func( ))
表示函数的返回结果是一个引用,(&func( ))[10]
表示引用的对象是一个维度为 10
的数组,string (&func( ))[10]
表示数组的元素是 string
对象。要想在声明func
时不使用类型别名,我们必须牢记被定义的名字后面数组的维度:
int arr[10]; // arr 是一个含有 10 个整数的数组
int *p1[10]; // p1 是一个含有 10 个指针的数组
int (*p2)[10] = &arr; // p2 是一个指针,它指向含有 10 个整数的数组
返回数组指针的函数形式如下所示:
Type (*function(parameter_list))[dimension]
Type
表示元素的类型,dimension
表示数组的大小。
(*function(parameter_list))
两端的括号必须存在,就像我们定义p2
时两端必须有括号一样。
如果没有这对括号,函数的返回类型将是指针的数组。
下面的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
类型。使用尾置返回类型(trailing return type),用来返回类型比较复杂的函数,比如返回类型是数组的指针或者数组的引用。
尾置返回类型跟在形参列表后面并以一个->
符号开头。
为了表示函数真正的返回类型跟在形参列表之后,我们在本应该出现返回类型的地方放置一个auto
:
// func 接受一个 int 类型的实参,返回一个指针,该指针指向含有 10 个整数的数组
auto func(int i) -> int(*)[10];
因为我们把函数的返回类型放在了形参列表之后,所以可以清楚地看到func
函数返回的是一个指针,并且该指针指向了含有10
个整数的数组。
decltype
我们知道函数返回的指针将指向哪个数组,就可以使用decltype
关键字声明返回类型。
例如,下面的函数返回一个指针,该指针根据参数i
的不同指向两个已知数组中的某一个:
int odd[] = {
1,3,5,7,9};
int even[] = {
0,2,4,6,8};
// 返回一个指针,该指针指向含有 5 个整数的数组
decltype(odd) *arrPtr(int i)
{
return (i % 2) ? &odd : &even; // 返回一个指向数组的指针
}
arrPtr
使用关键字decltype
表示它的返回类型是个指针,并且该指针所指的对象与odd
的类型一致。
因为odd
是数组,所以arrPtr
返回一个指向含有5
个整数的数组的指针。
有一个地方需要注意:decltype
并不负责把数组类型转换成对应的指针,所以decltype
的结果是个数组,要想表示arrPtr
返回指针还必须在函数声明时加一个*
符号。
修改arrPtr
函数,使其返回数组的引用。
int odd[] = {
1,3,5,7,9};
int even[] = {
0,2,4,6,8};
// 返回一个引用,该引用所引的对象是一个含有5个整数的数组
decltype(odd) &arrPtr(int i)
{
return (i % 2) ? &odd : &even; // 返回数组的引用
}
练习6.37:为(练习6.36)的函数再写三个声明,一个使用类型别名,另一个使用尾置返回类型,最后一个使用decltype
关键字。你觉得哪种形式最好?为什么?
【出题思路】
直接编写返回数组引用的函数比较繁琐且不易理解,使用类型别名、尾置返回类型和decltype
关键字都可以简化这一过程。
// 本身的形式声明函数
string (&func( ))[10];
// [1] 使用类型别名:
typedef string arr[10];
arr& func();
// [2] 使用尾置返回类型:
auto func() -> string(&) [10];
// [3] 使用 decltype 关键字:
string str[10];
decltype(str) &func();
如果同一作用域内的几个函数名字相同但形参列表不同,我们称之为重载函数(overloaded)。
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 World"); // 调用 print(const char*)
print(j, end(j) - begin(j)); // 调用 print(const int*, size_t)
print(begin(j), end(j)); // 调用 print(const int*, const int*)
有一种典型的数据库应用,需要创建几个不同的函数分别根据名字、电话、账户号码等信息查找记录。
我们能铜鼓以下形式中的任意一种调用lookup
函数:
Record lookup(const Account&); // 根据 Account 查找记录
Record lookup(const Phone&); // 根据 Phone 查找记录
Record lookup(const Name&); // 根据 Name 查找记录
Account acct;
Phone phone;
Record r1 = lookup(acct); // 调用接受 Account 的版本
Record r2 = lookup(phone); // 调用接受 Phone 的版本
虽然我们定义的三个函数各不相同,但它们都有同一个名字,编译器根据实参的类型确定应该调用哪一个函数。
有时候两个形参列表看起来不一样,但实际上是相同的:
// 每对声明的是同一个函数
// 第一对声明中,第一个函数给它的形参起了名字,第二个函数没有。形参的名字仅仅起到帮助记忆的作用,有没有它并不影响形参列表的内容。
Record lookup(const Account &acct);
Record lookup(const Account&); // 省略了形参的名字
typedef Phone Telno;
Record lookup(const Phone&);
Record lookup(const Telno&); // Telno 和 Phone 的类型相同
// 第二对声明看起来类型不同,但事实上 Telno 不是一种新类型,它只是 Phone 的别名而已。
const
形参// 对于接受引用或指针的函数来说,对象是常量还是非常量对应的形参不同
// 定义了 4 个独立的重载函数
Record lookup(Account&); // 函数作用域 Account 的引用
Record lookup(const Account&); // 新函数,作用于常量引用
Record lookup(Account*); // 新函数,作用于指向 Account 的指针
Record lookup(const Account*); // 新函数,作用于指向常量的指针
建议:何时不应该重载函数:
最好只重载那些确实非常相似的操作。
有些情况下,给函数起不同的名字能使得程序更易理解。
const_cast
和重载P209
const_cast
在重载函数的情景中最有用。
举例:
// 比较两个 string 对象的长度,返回较短的那个引用
const string &shorterString(const string &s1, const string &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&
,这显然是安全的。
函数匹配(function matching)是指一个过程,在这个过程中我们把函数调用与一组重载函数中的某个关联起来,函数匹配也叫做重载确定(overload resolution)。
编译器首先将调用的实参与重载集合中每一个函数的形参进行比较,然后根据比较的结果决定到底调用哪个函数。
string read();
void print(const string &);
void print(double);
void fooBar(int val)
{
bool read = false; // 新作用域:隐藏了外层的 read
string s = read(); // 错误:read是一个布尔值,而非函数
// 不好的习惯:通常来说,在局部作用域中声明函数不是一个好的选择
void print(int); // 新作用域:隐藏了之前的 print
print("Value: "); // 错误:print(const string &) 被隐藏掉了
print(ival); // 正确:当前 print(int) 可见
print(3.14); // 正确:调用 print(int); print(double) 被隐藏掉了
}
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)
}
constexpr
函数例如,我们使用string
对象表示窗口的内容。
我们希望该窗口的高、宽和背景字符都使用默认值。
但是同时我们也应该允许用户为这几个参数自由指定与默认值不同的数值。
为了使得窗口函数既能接纳默认值,也能接受用户指定的值,我们把它定义成如下形式:
typedef string::size_type sz;
string screen(sz ht = 24, sz wid = 80, char backgrnd = ' ');
如果我们想使用默认实参,只要在调用函数的时候省略该实参就可以了。
// screen 函数为它的所有形参都提供了默认实参
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, '#')
window = screen(, , '?'); // 错误:只能省略尾部的实参
window = screen('?'); // 调用 screen('?',80,' ')
/*注意:'?'是个 char,而函数最左侧形参的类型 string::size_type 是一种无符号整数类型,所以 char 类型可以转换成函数最左侧形参的类型。
当该调用发生时,char 类型的实参隐式地转换成 string::size_type,然后作为 height 的值传递给函数。
'?' 对应的十六进制数是 Ox3F,也就是十进制数 63。*/
练习6.41:下面的哪个调用是非法的?为什么?哪个调用虽然合法但显然与程序员的初衷不符?为什么?
/* 练习6.41:下面的哪个调用是非法的?为什么?哪个调用虽然合法但显然与程序员的初衷不符?为什么?*/
char *init(int ht, int wd = 80, char bckgrnd = ' ');
(a) init();
/*(a)是非法的,该函数有两个默认实参,但是总计有三个形参,其中第一个形参并未设定默认实参,
所以要想调用该函数,至少需要提供一个实参。*/
(b) init(24, 10);
/*(b)是合法的,本次调用提供了两个实参,第一个实参对应第一个形参 ht,
第二个实参对应第二个形参 wd,其中 wd 的默认实参没有用到,
第三个形参 bckgrnd 使用它的默认实参。*/
(c) init(14, '*');
/*(c)在语法上是合法的,但是与程序的原意不符。
从语法上来说,第一个是残对应第一个形参 ht,
第二个实参的类型虽然是 char,但是它可以自动转换为第二个形参 wd 所需的 int 类型,
所以编译时可以通过,但这显然违背了程序的原意,
正常情况下,字符* 应该被用来构成背景。/
练习6.42:给 make_plural 函数的第二个形参赋予默认实参 's'
,利用新版本的函数输出单词 success 和 failure 的但书和复数形式。
/* 练习6.42:给 make_plural 函数的第二个形参赋予默认实参 's',
利用新版本的函数输出单词 success 和 failure 的但书和复数形式。
// P201 make_plural 函数
string make_plural(size_t ctr, const string &word, const string &ending)
{
return (ctr > 1) ? word + ending : word;
}
【出题思路】
对于英语单词来说,大多数名词的复数是在单词末尾加's'得到的,
也有一部分名词在单数转变为复数时需要在末尾加'es'。
我们可以把's'作为默认实参,大多数情况下不必考虑这个参数,
只有在遇到末尾是'es'的单词时才专门处理。
*/
#include
#include
using namespace std;
// 最后一个形参赋予了默认实参
string make_plural(size_t ctr, const string &word, const string &ending = "s")
{
return (ctr > 1) ? word + ending : word;
}
int main()
{
cout << "The singular form of success is: " << make_plural(1, "success", "es") << endl;
cout << "The plural form of success is: " << make_plural(2, "success", "es") << endl;
// 一般情况下调用该函数只需要两个实参
cout << "The singular form of success is: " << make_plural(1, "failure") << endl;
cout << "The plural form of success is: " << make_plural(2, "failure") << endl;
return 0;
}
// 表示高度和宽度的形参没有默认值
string screen(sz, sz, char = ' ');
// 我们不能修改一个已经存在的默认值:
string screen(sz, sz, char = '*'); // 错误:重复声明
// 但是可以按照如下形式添加默认实参
string screen(sz = 24, sz = 80, char); // 正确:添加默认实参
练习6.40:下面的哪个声明是错误的?为什么?
/* 练习6.40:下面的哪个声明是错误的?为什么?*/
(a) int ff(int a, int b = 0, int c = 0);
(b) char *init(int ht = 24, int wd, char bckgrnd);
/*(a)正确而(b)错误。它们都用到了默认实参,但是C++规定一旦某个形参被赋予了默认实参,则它后面的所有形参都必须有默认实参。
这一规定是为了防范可能出现的二义性,显然(b)违反了这一规定。*/
// 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, '*')
}
定义成函数有很多好处,但是缺点也有:
调用函数一般比求等价表达式的值要慢一些。在大多数机器上,一次函数调用其实包含着一系列工作:
调用前先保存寄存器,并在返回时回复;
可能需要拷贝实参;
程序转向一个心的位置继续执行。
内联机制用于优化规模较小、流程直接、频繁调用的函数。以消除函数运行时的开销。
决定一个函数是否应该是内临安函数有很多评判的依据。一般来说,内联机制适用于规模较小、流程直接、频繁调用的函数。
一旦函数被定义成内联的,则在编译阶段就展开该函数,以消除运行时产生的额外开销。
如果函数的规模很大(比如上百行)不利于展开或者函数只被调用了一两次,那么这样的函数没必要也不应该是内联的。
之前的一个例子:比较两个string
形参的长度并返回长度较小的string
的引用。
// 原来程序:
// 挑出两个 string 对象中较短的那个,返回其引用
const string &shorterString(const string &s1, const string&s2)
{
return s1.size() <= s2.size() ? s1 : s2;
}
/* 其中形参和返回类型都是 const string 的引用,不管是调用函数还是返回结果都不会真正拷贝 string 对象。*/
在shorterString
函数的返回类型前面加上关键字inline
,这样就可以将它声明成内联函数了:
// 内联版本:寻找两个 string 对象中较短的那个
inline const string &shorterString(const string &s1, const string &s2)
{
return s1.size() <= s2.size() ? s1 : s2;
}
前面的练习中:
练习6.11 中的reset
函数改写后的形式是:
inline void reset(int &i)
{
i = 0;
}
练习6.21 中的myCompare
函数改写后的形式是:
inline int myCompare(const int val, const int *p)
{
return (val > *p) ? val : *p;
}
练习6.44:将isShorter
函数改写成内联函数。
// 原程序:
bool isShorter(const string &s1, const string &s2)
{
return s1.size() < s2.size();
}
内联函数在编译时展开,从而消除了调用函数时产生的开销。
// 要改成内联函数只需要在普通函数的前面加上关键字 inline。
inline bool isShorter(const string &s1, const string &s2)
{
return s1.size() < s2.size();
}
constexpr
函数constexpr函数(constexpr function)是指能用于常量表达式的函数。
要遵循几项约定:
return
语句constexpr int new_sz {
return 42; } // 常量函数
constexpr int foo = new_sz(); // 正确:foo 是一个常量表达式
constexpr
函数体内也可以包含其他语句,只要这些语句在运行时不执行任何操作就行。
例如,constexpr
函数中可以有空语句、类型别名以及using
声明。
Note: 我们允许constexpr
函数的返回值并非一个常量:
// 如果 arg 是常量表达式,则 scale(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)不是常量表达式
练习6.46:能把isShorter
函数定义成constexpr
函数吗?如果能,将它改写成constexpr
函数;如果不能,说明原因。
// 原程序:
bool isShorter(const string &s1, const string &s2)
{
return s1.size() < s2.size();
}
【出题思路】constexpr
函数是指能用于常量表达式的函数,constexpr
函数的返回类型和所有形参的类型都得是字面值类型,而且函数体中必须有且只有一条return
语句。
【解答】显然isShorter
函数不符合constexpr
函数的要求,它虽然只有一条return
语句,但是返回的结果调用了标准库string
类的size()
函数和<
比较符,无法构成常量表达式,因此不能改写成constexpr
函数。
constexpr
函数放在头文件内和其他函数不一样,内联函数和constexpr
函数可以再程序中多次定义。
毕竟,编译器要想展开函数仅有函数声明是不够的,还需要函数的定义。
不过,对于某个给定的内联函数或者constexpr
函数来说,它的多个定义必须完全一致。
基于这个原因,内联函数和constexpr
函数通常定义在头文件中。
练习6.43:你会把下面的哪个声明和定义放在头文件中?哪个放在源文件中?为什么?
(a) inline bool eq(const BigInt&, const BigInt&) {…}
(b) void putValues(int *arr, int size);
【出题思路】函数的声明应该放在头文件中,同时内联函数的定义也应该放在头文件中。
【解答】
(a)应该放在头文件中。因为内联函数的定义对编译器而言必须是可见的,以便编译器能够在调用内联展开该函数的代码,所以仅有函数的原型不够。
并且,与一般函数不同,内联函数有可能在程序中定义不止一次,此时必须保证在所有源文件中定义完全相同,把内联函数的定义放在头文件中可以确保这一点。
(b)是函数声明,应该放在头文件中。
基本思想“程序可以包含一些用于调试的代码,但是这些代码只在开发程序时使用。当应用程序编写完成准备发布时,要先屏蔽掉调试代码。
这种方法用到两项预处理功能:assert
和NDEBUG
。
assert
预处理宏assert
是一种预处理宏(preprocessor macro)。
所谓预处理宏其实是一个预处理变量。
assert
宏定义在cassert
头文件中。预处理名字由预处理器而非编译器管理,因此可以直接使用预处理名字而无需提供using
声明。
也就是说,我们应该使用assert
而不是std::assert
,也不需要为assert
提供using
声明。
assert
宏使用一个表达式作为它的条件:
assert(expr);
首先对expr
求值,如果表达式为假(即0
),assert
输出信息并种植程序的执行。
如果表达式未真(即非0
),assert
什么也不做。
assert
宏常用语检查“不能发生”的条件。
例如,一个对输入文本进行操作的程序可能要求所有给定单词的长度都大于某个阈值。
此时,程序可以包含一条如下所示的语句:
assert(word.size() > threshold);
NDEBUG
预处理变量assert
的行为依赖于一个名为NDEBUG
的预处理变量的状态。
如果定义了NDEBUG
,则assert
什么也不做。
默认状态下没有定义NDEBUG
,此时assert
将执行运行时检查。
使用#define NDEBUG
语句定义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
// ...
}
练习6.47:如何在程序中打开和关闭调试器。
/* 练习6.47:改写练习中使用递归输出 vector 内容的程序,
使其有条件地输出与执行过程有关的信息。
例如,每次调用时输出 vector 对象的大小。
分别在打开和关闭调试器的情况下编译并执行这个程序。*/
#include
#include
using namespace std;
/* 【出题思路】考查如何在程序中打开和关闭调试器
打开调试器时,每次递归条用 print 函数都会输出“vector对象的大小是:8”;
关闭调试器时,程序只输出 vector 对象的内容,不再输出其大小。*/
// 递归函数输出 vector 的内容
void print(vector<int> vInt, unsigned index)
{
unsigned sz = vInt.size();
// 设置在此处输出调试信息
#ifndef NDEBUG
cout << "The size of vector object is: " << sz << endl;
#endif // NDEBUG
if (!vInt.empty() && index < sz)
{
cout << vInt[index] << endl;
print(vInt, index + 1);
}
}
int main()
{
vector<int> v = {
1, 3, 5, 7, 9, 11, 13, 15};
print(v, 0);
return 0;
}
/*Output:
The size of vector object is: 8
1
The size of vector object is: 8
3
The size of vector object is: 8
5
The size of vector object is: 8
7
The size of vector object is: 8
9
The size of vector object is: 8
11
The size of vector object is: 8
13
The size of vector object is: 8
15
The size of vector object is: 8
*/
候选函数(candidate function)、可行函数(viable function)
候选函数的两个特征:
一、与被调用的函数同名
二、其生命在调用点可见
可行函数的两个特征:
一、形参数量与实参数量相等
二、实参类型与形参类型相同,或者能转换成形参的类型
void f();
void f(int);
void f(int, int);
void f(double, double = 3.14);
f(5.6); // 调用 void f(double, double)
f(int)
是可行的,因为实参类型double
能转换成形参类型int
。f(double, double)
是可行的,因为它的第二个形参提供了默认值,而第一个形参的类型正好是double
,与函数使用的实参类型完全一致。其中最后那个函数本应该接受两个double
值,但是因为它含有一个默认实参,所以只用一个实参也能调用它。
Note: 如果函数含有默认实参,则我们在调用该函数时传入的实参数量可能少于它实际使用的实参数量。
精确匹配比需要类型转换的匹配更好,因此编译器把f(5.6)
解析成对含有两个double
形参的函数的调用。
对于前面那些名为f
的函数,我们来分析如下的调用会发生什么情况:(42, 2.56)
可行函数包括:
f(int, int)
f(double, double)
编译器依次检查每个实参以确定哪个函数是最佳匹配。
在上面的调用中,只考虑第一个实参时我们发现函数f(int, int)
能精确匹配;
要想匹配第二个函数,int
类型的实参必须转换成double
类型。
显然,需要内置类型转换的匹配劣于精确匹配,因此仅就第一个实参来说,f(int, int)
比f(double, double)
更好。
同样的,仅就第二个实参2.56
,f(double, double)
更好。
编译器最终将因为这个调用具有二义性而拒绝其请求。
虽然可以通过强制类型转换其中的一个实参来实现函数的匹配,但是在设计良好的系统中,不应该对实参进行强制类型转换。如果在实际应用中确实需要强制类型转换,则说明我们设计的形参集合不合理。
为了确定最佳匹配,编译器将实参类型到形参类型的转换划分成几个等级,具体排序如下所示:
const
或者从实参中删除顶层const
。const
转换实现的匹配(p143)。练习6.52:掌握精确匹配、const
匹配、类型提升匹配、算术类型转换和类类型转换的使用。
void manip(int, int);
double dobj;
// 请指出下列调用中每个类型转换的等级
(a) manip('a', 'z');
// (a)发生的参数类型转换是类型提升,字符型实参自动提升成整型。
(b) manip(55.4, dobj);
// (b)发生的参数类型转换是算术类型转换,双精度浮点型自动转换成整型
小整型一般都会提升到int
类型或更大的整数类型。
所有算术类型转换的级别都一样。例如,从int
向unsigned int
的转换并不比从int
向double
的转换级别高。
void manip(long);
void manip(float);
manip(3.14); // 错误:二义性调用
const
实参如果重载函数的区别在于它们的引用类型的形参是否引用了const
,或者指针类型的形参是否指向const
,则当调用发生时编译器通过实参是否是常量来决定选择哪个函数。
练习6.53:说明下列每组声明中的第二条语句会产生什么影响,并指出哪些不合法(如果有的话)。
(a) int calc(int&, int&);
int calc(const int&, const int&);
/* (a)合法。两个函数的区别是他们的引用类型的形参是否引用了常量,属于底层 const,可以把两个函数区分开来。*/
(b) int calc(char*, char*);
int calc(const char*, const char*);
/* (b)合法,两个函数的区别是它们的指针类型的形参是否指向了常量,属于底层 const,可以把两个函数区分开来。 */
(c) int calc(char*, char*);
int calc(char* const, char* const);
/* (c)非法,两个函数的区别是它们的指针类型的形参本身是否是常量,属于顶层 const,根据本节介绍的匹配规则可知,向实参添加顶层 const 或者从实参中删除顶层 const 属于精确匹配,无法区分两个函数。 */
函数指针指向的是函数而非对象。
// 比较两个 string 对象的长度
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
类型。
Note: *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
的整型常量表达式,表示该指针没有指向任何一个函数:
string::size_type sumLength(const string&, const string&);
bool cstringCompare(const char*, const char*);
pf = 0; // 正确:pf 不指向任何函数
pf = sumLength; // 错误:返回类型不匹配
pf = cstringCompare; // 错误:形参类型不匹配
pf = lengthCompare; // 正确:函数和指针类型精确匹配
练习6.54:编写函数的声明,令其接受两个 int
形参并且返回类型也是 int
;然后声明一个 vector
对象,令其元素是指向该函数的指针。
/* 练习6.54:编写函数的声明,令其接受两个 int 形参并且返回类型也是 int;
然后声明一个 vector 对象,令其元素是指向该函数的指针。
【出题思路】考查函数指针的声明和使用。*/
// 满足题意的函数:
int func(int, int);
// 满足题意的 vector 对象:
vector<decltype(func)*> vF;
练习6.55:编写 4 个函数,分别对两个 int
值执行加、减、乘、除运算;在上一题创建的 vector
对象中保存指向这些值的指针。
/* 练习6.55:编写 4 个函数,分别对两个 int 值执行加、减、乘、除运算;
在上一题创建的 vector 对象中保存之乡这些值的指针。
【出题思路】考查函数指针的声明和使用。*/
#include
#include
using namespace std;
int func1(int a, int b)
{
return a + b; // 加法
}
int func2(int a, int b)
{
return a - b; // 减法
}
int func3(int a, int b)
{
return a * b; // 乘法
}
int func4(int a, int b)
{
return a / b; // 除法
}
int main()
{
decltype(func1) *p1 = func1, *p2 = func2, *p3 = func3, *p4 = func4;
vector<decltype(func1)* > vF = {
p1, p2, p3, p4};
return 0;
}
练习6.56:调用上述 vector 对象汇总的每个元素并输出其结果。
/* 练习6.56:调用上述 vector 对象汇总的每个元素并输出其结果。*/
#include
#include
using namespace std;
int func1(int a, int b)
{
return a + b; // 加法
}
int func2(int a, int b)
{
return a - b; // 减法
}
int func3(int a, int b)
{
return a * b; // 乘法
}
int func4(int a, int b)
{
return a / b; // 除法
}
void Compute(int a, int b, int (*p) (int, int))
{
cout << p(a, b) << endl;
}
int main()
{
int i = 5, j = 10;
decltype(func1) *p1 = func1, *p2 = func2, *p3 = func3, *p4 = func4;
vector<decltype(func1)* > vF = {
p1, p2, p3, p4};
for (auto p : vF) // 遍历 vector 中的每个元素,依次调用四则运算函数
{
Compute(i, j, p);
}
return 0;
}
/* Output:
15
-5
50
0
*/
当使用重载函数时,上下文必须清晰地界定到底应该选用哪个函数。
如果定义了重载函数的指针:
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);
使用类型别名和decltype
来简化使用了函数指针的代码:
// Func 和 Func2 是函数类型
typedef bool Func(const string&, const string&);
typedef decltype(lengthCompare) Func2; // 等价的类型
使用typedef
定义自己的类型。
Func
和Func2
是函数类型,而FuncP
和FuncP2
是指针类型。
需要注意的是,decltype
返回函数类型,此时不会将函数类型自动转换成指针类型。
因为decltype
的结果是函数类型,所以只有在结果前面加上*
才能得到指针。
// FuncP 和 FuncP2 是指向函数的指针
typedef bool (*FuncP) (const string&, const string&);
typedef decltype(lengthCompare) *FuncP2; // 等价的类型
// useBigger 的等价声明,其中使用了类型别名
void useBigger(const string&, const string&, Func);
void useBigger(const string&, const string&, FuncP2);
后面useBigger
的两个等价声明语句声明的是同一个函数,在第一条语句中Func
表示的函数类型转换成指针。
和数组类似,虽然不能返回一个函数,但是能返回指向函数类型的指针。
然而,我们必须把返回类型写成指针形式,编译器不会自动地将函数返回类型当成对应的指针类型处理。
using F = int(int*, int); // F 是函数类型,不是指针
using PF = int (*)(int*, int); // PF 是指针类型
返回类型不会自动地转换成指针,我们必须显式地将返回类型指定为指针:
PF f1(int); // 正确:PF 是指向函数的指针,f1 返回指向函数的指针
F f1(int); // 错误:F 是函数类型,f1 不能返回一个函数
F *f1(int); // 正确:显式地指定返回类型是指向函数的指针
我们也能用下面的形式直接声明f1
:
int (*f1(int)) (int*, int);
分析:f1
you形参列表,所以f1
是个函数,f1
前面有*
,所以f1
返回一个指针;
进一步观察发现,指针的类型本身也包含形参列表,因此指针指向函数,该函数的返回类型是int
。
还可以用尾置返回类型的方式(p206):
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 &);
注意:decltype
作用域某个函数时,它返回函数类型而非指针类型。
因此,我们显式地加上*
以表示我们需要返回指针,而非函数本身。