第6章 函数
6.1 函数基础
-
一个函数包括以下部分:
-
函数在被调用时首先(隐式地)定义并初始化它的形参,其实这个过程就是一个值初始化的过程,所以之前对于 值初始化或 auto初始化的规则一样有效。**这里要注意一点,即 C++并没有规定实参的求值顺序,编译器能以任意可行的顺序对实参求值。**所以形如下式的表达式是错误的!
int a = fun(i, ++i); // 错误:传递进来的实参不能对其它实参有副作用!
-
函数的返回类型不能是数组或函数类型,但可以是指向数组或函数的指针。
-
局部静态对象在程序执行路径第一次经过对象定义语句时初始化,直到程序终止才被销毁;如果局部静态变量没有显式的初始值,执行值初始化,内置类型被初始化为 0。
-
另外,为了确保同一函数在不同使用该函数的地方保持一致,并且将接口和实现分离开来,C++通常会将函数声明放到头文件(.h),实现放到源文件(.cpp)中。这样,使用和修改函数接口都会很方便。
-
C++支持分离式编译,对每个 源文件(.cpp)独立编译。这样如果我们修改了其中一个源文件,那么只需要重新编译那个改动了的文件。之后编译器会将对象文件(.obj)链接到一起,形成可执行文件(.exe)。整体过程如下
这样的话,如果在头文件中实现了某个函数,而该函数又被多个源文件使用,那么在编译时正常,而在链接时就会报错,某些函数多次重复定义。这是因为每个源文件都会对自己使用的函数进行编译,编译后的 .obj中已经包括了该函数的定义,而在后续多个 .obj文件链接时,才发现这个函数被多次定义了。解决办法就是在 .h文件中仅包含函数声明,函数实现放到 .cpp文件中去。
6.2 参数传递
-
形参初始化的机理与变量初始化一样。包括引用传递和值传递,其中指针参数也是值传递,进行的是指针的值的拷贝。拷贝之后,两个指针是不同的指针,只是它们都指向都一个对象。
-
使用引用传递可以避免拷贝,效率较高;另外,有些类型(IO操作)不支持拷贝,只能通过引用形参访问该类型的对象。
-
C++中一个函数只能返回一个值,而当函数需要返回多个值时,可以通过引用和指针形参来完成。这样的话,输入参数在函数执行完毕后也会被改变,也就相当于是一个输出参数了。当然,还可以通过自定义一个数据类型或使用 tuple模板来返回多个值。
-
与变量初始化一样,参数初始化时,会忽略掉顶层 const。因此对下式传给它常量对象或者非常量对象都是可以的。
int a = fcn(const int i); // fcn能够读取 i,但是不能修改 i的值
另外,因为忽略掉顶层 const的缘故,顶层 const并不会引起函数重载,而是函数重定义!
void fcn(const int i);
void fcn(int i); // 错误,函数重定义!
-
尽量使用常量引用,表示该函数不会改变该形参。因为将函数定义成普通引用有以下缺点:
- 非常量引用只能接受非常量对象,不能把 const对象、字面值传递给这种形参。
- 在含有常量引用形参的函数中,无法将常量引用传递给非常量引用的函数,从而限制了后者的适用范围。此时需要使用 const_cast来转换底层 const属性。
- 给函数的调用者以误导,使用者可能会以为函数可以修改它的实参的值。
-
数组不允许拷贝,所以无法以值传递的形式传递数组参数;使用数组时通常会将其转换成指针,所以当为函数传递一个数组参数时,实际传递的是指向数组首元素的指针。数组的大小对函数的调用没有影响。
// 尽管形式不同,但三个 print函数是等价的,每个形参都是 const int*类型
void print(const int *);
void print(const int[]); // 此函数的意图是作用于一个数组
void print(const int[10]); // 这个维度表示我们期望的输入数组有多少个元素,实际并不一定!
int i = 0, j[2] = {0, 1};
print(&i); // 正确,即使参数只是一个单独的 int类型
print(j); // 正确
- 对于数组引用形参,因为维度是数组类型的一部分,所以声明数组引用形参时必须指定数组的维度,也只能将函数应用于指定大小的数组。
// 形参是数组的常量引用,维度是类型的一部分
void print(const int (&arr) [10]);
int i = 0, j[2] = {0, 1};
print(&i); // 错误,实参不是含有 10个整数的数组
print(j); // 错误,实参不是含有 10个整数的数组
- 使用 main函数处理命令行选项时,通常会写成下列两种形式:
int main(int argc, char *argv[]) {...}
int main(int argc, char **argv ) {...}
在上面两个表达式中,argv是一个数组,它的元素是指向 C风格字符串的指针,而 argv又可以看成是指向首元素的指针,因此 argv就是一个二级指针,所以也就有了第二个表达式的写法。
在使用 argv的实参时,可选的实参从 argv[1]开始;argv[0]保存的是程序的名字,而非用户输入。
9. 为了编写处理不同数量实参的函数,C++11新标准提供了两种方法:所有实参类型相同,使用 initializer_list;实参类型不同, 使用可变参数模板,然后实例化即可。另外,对于与C函数交互的接口程序,省略符形参(...)。可变参数符号与其它特定参数一起出现时,必须在最右边。
10.initializer_list提供了对一系列相同类型元素的轻量级存储和访问的能力,值初始化后列表中的元素永远是常量值。在拷贝或赋值时,执行的也是“类指针拷贝”,原始列表和副本共享元素。
6.3 返回类型和 return语句
- 在含有 return语句的循环后面应该也有一条 return语句,对于该错误,编译器可能检测不到该错误(在我的 VS2015中,会警告,但不报错),则运行时该程序的行为将是未定义的!
- 返回一个值的方式和初始化一个变量完全一样:返回的值用于初始化调用点的一个临时量,该临时量就是函数调用的结果。
int a = fcn(5);
// 上式等价于
int tmp = fcn(5); // 在调用点定义并初始化一个临时量
int a = tmp; // 执行 int类型的拷贝构造函数
- 不要返回局部对象的引用或指针,因为函数完成后,内部的局部变量也会被释放掉。
const string &manip()
{
string ret;
// 以某种方式改变一下 ret
if (!ret.empty())
return ret; // 错误:返回局部对象的引用
else
return "Empty"; // 错误:字符串字面值还是会被转换成一个临时 string对象
}
- 调用一个返回引用的函数得到左值,其他返回类型得到右值。可以为返回类型是非常量引用的函数的结果赋值。
char &get_val(string &str, string::size_type ix)
{
return str[ix];
}
string s("value");
get_val(s, 0) = 'V'; // 将 s改成 "Value"
-
C++11新规定,可以返回花括号包围的值的列表,用此列表对表示函数返回的调用点的临时量进行初始化。列表为空,临时量执行值初始化。另外,如果返回类型是内置类型,则花括号列表最多包含一个值,且该值所占内存空间不应大于目标类型的空间(比如,double——>int就会报错)
-
main函数,返回 0表示执行成功,返回其他值表示执行失败,其中非 0值的具体含义依机器而定。
-
数组不能被拷贝,所以函数不能返回数组,但是可以返回数组的指针或引用。形式如下
int (*func(int i))[10] // func函数返回指向 10个 int组成的数组的指针
可以按照由内到外的顺序来理解该声明的含义:
- func(int i)表示一个函数,形参为 int类型。
- (*func(int i))表示可以对函数调用的结果执行解引用操作,则函数的返回值是指针类型。
- int (*func(int i))[10]表示该指针指向 10个 int组成的数组
使用类型别名的话可以大大简化上述表达式,且其可读性也更好。
using arrT = int[10];
arrT* func(int i);
C++11新标准中还可以使用尾置返回类型来简化上述函数声明。下式就可以很清楚地看到 func函数返回的是一个指针,且该指针指向了含有 10个整数的数组。
auto func(int i) -> int(*)[10];
另外,如果已经有返回值类型的数组存在,可以使用 decltype关键字声明返回类型。
int arr[10] = {1,2};
decltype(arr) *func(int i);
不过,需要注意,decltype的返回类型是数组类型,要想表示返回类型为指向数组的指针,必须加上一个 *****符号。
6.4 函数重载
- 重载,几个函数名字相同但形参列表不同,在判断是否重载时,返回类型不予考虑。另外,因为在编译时会为函数在进行重命名,而在重命名时是只考虑函数名和形参的,所以不允许两个函数除了返回类型外其它所有的要素都相同。
int func(int i);
double func(int i); // 错误,无法重载仅按返回类型重载的函数
- 顶层 const形参不构成重载,而底层 const形参是可以构成重载的。
Record lookup(Account &); // 实参为非常量对象时,优先调用此版本
Record lookup(const Account &); // 实参为常量对象时,只能调用此版本
对于第二个表达式,实参为常量/非常量对象,都是可以的。但是如果两种表达式都存在,且实参为非常量对象时,会优先调用第一个非常量版本。因为第一个表达式为精确匹配,而第二个表达式则需要将非常量类型转化为常量类型。
3. 在 C++语言中,名字查找发生在类型检查之前。在内层作用域中声明的名字将会隐藏外层作用域中的同名实体。
string read();
void print(const string &s);
void main()
{
bool read = false;
string s = read(); // 错误:read是一个 bool类型,而非函数
void print(int);
print("value"); // 错误:print(const string &s)被隐藏掉了
}
6.5 特殊用途语言特性
- 默认实参,应尽量让有默认值的形参出现在参数列表的后面。
- 局部变量不能作为默认实参。
// 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,但是默认实参使用的
// 是外层作用域中的 wd,所以对于函数调用没有影响!
window = screen(); // 调用 screen(ht(), 80, '*')
}
-
constexpr函数,当所有形参在编译期就已全部知道,其返回值也是常量表达式,即也是在编译期就已知的。而只要有一个形参在编译期是未知的,它就会表现为一个正常函数,在运行期计算它的值。这样做,可以大大扩展一个函数的适用范围,对于需要使用在编译期就能知道的常量表达式的场景(如数组大小的说明,整形模板参数(包括std::array对象的长度),枚举成员的值等),该函数也可以使用了。
-
C++11中规定,函数的返回类型及所有形参都得是字面值类型,而且函数体中必须有且只有一条return语句(不过可以通过条件表达式 “?:”和迭代绕过这些限制)。更详细的内容见 Item 15: 只要有可能,就使用constexpr,这是《effective modern c++》Item 15的翻译,关于 constexpr,讲得非常透彻!
-
另外,内联函数和 constexpr函数可以在程序中多次定义,不过对于某个给定的内联函数或 constexpr函数,多个定义必须完全一致。基于这个原因,内联函数和 constexpr函数通常定义在头文件中。也因为它们可以多次定义,所以即使定义在头文件中,链接时也不会出现多次定义的错误,而普通函数这样做就会出错。
-
assert预处理宏,assert(expr),语义为保证表达式为真,如果表达式为假,assert输出信息并终止程序。这种技术一般用于调试代码,只在开发程序时使用。真正在发布程序时,需要屏蔽掉调试代码。这时可以使用 NDEBUG,定义了 NDEBUG后,assert什么也不做。
6.6 函数匹配
- 函数匹配的过程:
-
确定候选函数:与被调用函数同名,且在调用点可见。
-
确定可行函数:参数数量相同,参数类型相同或能转换。
-
寻找最佳匹配。为了确定最佳匹配,将实参类型转换划分成几个等级,由上到下优先级逐渐降低。
- 精确匹配,包括以下情况:
- 实参类型和形参类型相同。
- 实参从数组或函数类型转换成指针。
- 添加或删除顶层 const属性。
- 需要进行 const转换(const_cast)。
- 需要进行类型提升(short--->int)。
- 需要进行算术类型(int-->double)或指针转换。
- 需要进行类类型转换。
- 精确匹配,包括以下情况:
-
编译器依次检查每个实参以确定哪个函数是最佳匹配,如果有且只有一个函数满足下列条件,则匹配成功;否则,编译器将报二义性错误。
- 该函数每个实参的匹配都不劣于其他可行函数。
- 至少已有一个实参的匹配优先于其他可行函数。
-
6.7 函数指针
-
函数指针,指向某种特定函数类型。而函数类型由返回类型和形参类型共同决定,与函数名无关。例如:
bool compare(int i, int j);
其函数类型是 bool(int, int)。则该函数类型的指针可声明为
bool (*pf)(int i, int j);
但是此时只是声明了一个该类型的函数指针变量,并没有进行初始化!还需要使用函数名或函数指针进行初始化。而把函数名当做一个值使用时,函数可以自动转换成指针。
pf = compare; 等价于 pf = &compare;
此外,在使用函数指针调用函数时,无须提前解引用指针。
bool b1 = pf(1, 2); 等价于 bool b2 = (*pf)(1, 2);
-
不能定义函数类型的形参,但形参可以是指向函数的指针。与
void print(const int[10])
类似,函数看起来是函数(数组)类型,但实际上却是当成指针使用。所以下面两个表达式都是可以的。
void useBigger(int i, int j, bool compare(int i, int j)); // 形参是函数类型,但会自动地转换成相应的函数指针
void useBigger(int i, int j, bool (*pf)(int i, int j)); // 显式地将形参定义成函数指针
注意,对于上面两个表达式,在其之前是否已经声明了 compare和 pf,不会对其产生任何影响。因为作为形参, compare或 pf只是形参的名字,与之前已经声明的同名名字没有关系。另外,作为形参表达式,整体的意义是一个类型。所以使用类型别名可以简化代码,增强可读性。
// Func和 Func2是函数类型
typedef bool Func(int i, int j);
typedef decltype(compare) Func2;
// FuncP和 FuncP2是函数指针类型
typedef bool (*FuncP)(int i, int j);
typedef decltype(compare) *FuncP2;
// useBigger的等价声明
void useBigger(int i, int j, Func); // 形参是函数类型,但会自动地转换成相应的函数指针
void useBigger(int i, int j, FuncP2);
- 返回函数指针。不能返回一个函数,但可以返回函数指针。与返回数组指针一样,也还是这四种返回方式。
- 直接声明。
int (*f1(int)) (int*, int);
- 类型别名。
using PE = int(*)(int*, int); PE f1(int);
- 尾置返回。
auto f1(int) ->int(*)(int*, int);
- decltype。
int f(int*, int); decltype(f) *f1(int);
- 直接声明。