6. 函数
6.1 函数基础
-
函数就是一个命名了的代码块。
-
主调函数、被调函数。
-
实参是形参的初始值。第一个实参初始化第一个形参,第二个实参初始化第二个形参。
实参数量应与形参数量一致,所以形参一定会被初始化。 -
函数的形参列表可以为空。
函数的形参列表中,每个形参都必须写出类型名。 -
即使函数的某个形参不被函数使用,也应该为它提供一个实参。
-
英文单词:
实参argument,形参parameter
局部变量local variable
局部静态对象local static object
6.1.1 局部对象
-
名字有作用域,对象有生命周期。
局部变量:作用域、生命周期都与函数保持一致。
局部静态变量(static):作用域仅在函数内,生命周期为程序的执行期间。 -
函数内的局部变量如果不进行初始化:
局部变量:内置类型将产生未定义的值。
局部静态变量(static):内置类型将默认初始化。
6.1.2 函数声明
-
函数声明也称作函数原型。
函数声明应与函数定义保持一致。 -
函数声明应放在头文件中,函数定义放在同名源文件中。
含有函数定义的源文件中,应包含同名的头文件。
6.1.3 分离式编译
-
生成可执行文件:告诉编译器我们的代码在哪里。
-
重新编译改动了的文件:
大多数编译器提供了分离式编译每个文件的机制,这一过程通常会产生一个后缀名是.obj(windows)或.o(unix)的文件。 -
接下来编译器负责把对象文件链接在一起形成可执行文件。
-
可以阅读编译器的用户手册,来体会多个文件组成的程序是如何编译并执行的。
6.2 参数传递
-
每次调用函数都会重新创建它的形参,并用传入的实参对形参进行初始化。形参初始化的机理与变量初始化一样。
-
如果形参是引用类型,它将绑定到对应的实参上;否则,将实参的值拷贝后付给形参。
-
当形参是引用类型时,我们说它对应的实参被引用传递,或函数被传引用调用。引用形参是它对应的实参的别名。
-
当实参的值被拷贝给形参时,形参和实参是两个相互独立的对象,我们说这样的实参被值传递,或函数被传值调用。
6.2.1 传值参数
-
当初始化一个非引用类型的变量时,初始值被拷贝给变量。对形参的改动不会影响实参。
-
指针形参:可以通过传指针,改变指针所指对象的值。此时的实参是指针,仍然没变。
6.2.2 传引用参数
-
通过使用引用形参,允许函数改变一个或多个实参的值。
-
使用引用避免拷贝:因为拷贝低效、或某些对象(IO类)不支持拷贝。
若函数无需改变引用形参的值,最好将其声明为常量引用。bool isShorter(const string &s1, const string &s2){ return s1.size() < s2.size(); }
-
使用引用形参返回额外信息
return只能返回一个值,若使用引用则能返回多个。
6.2.3 const形参和实参
-
当用实参初始化形参时会忽略掉顶层const。
即,当形参有顶层const时,传给它常量对象或者非常量对象都是可以的。
void fcn(const int i)
,调用该函数时,既可以传入const int
,也可以传入int
。 -
C++语言中,允许定义若干具有相同名字的函数,不过前提是不同函数的形参列表应该有明显的区别。
因为顶层const被忽略掉了,所以在下面的代码中传入两个fcn函数的参数可以完全一样。
尽管这两个函数形式上有差异,但实际上第二个的形参和第一个fcn的形参没什么不同。void fcn (const int i){}; // 只读i,不能向i写值` void fcn (int i){}; // 错误:重复定义了fcn(int)`
-
指针或引用形参与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; // 错误:字面值不能初始化普通引用 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); // 错误:类型不匹配 // 正确:find_char的第一个形参是对常量的引用 find_char("Hello World!", 'o', ctr);
-
尽量使用常量引用
把函数不会改变的形参定义,最好定义为const引用。
否则会限制函数的所能接受的实参类型,或引发错误。
因上述原因引发的错误,常常体现在内外层函数对同一个参数的操作上。string::size_type find_char(string &s, char c, string::sizetype &occurs); // 形参s为普通引用时,下面原调用方式会出错 find_char("Hello World!", 'o', ctr);
6.2.4 数组形参
-
数组的两个特殊性质,导致了数组作为形参时的两个特点:
不允许拷贝数组,故无法用值传递使用数组参数。
数组会转换成指针,故为函数传递数组时,实际传递的是指针。 -
所以形参列表中,指针和数组的写法是等价的:
void print(int*); void print(int[]); void print(int[10]);// 这里的维度表示我们期望数组有多少元素,实际上不一定 int i = 0; int j[2] = {0, 1}; print(&i); // 正确:&i的类型是int* print(j); // 正确:j转换成int*并指向j[0]
-
综合以上要求,管理指针有三种常有方式:
(1)对于char型数组,可以通过C风格字符串来存储。
函数调用该char型数组时,通过结尾'\0'
来读取数组。
(2)对于一般数组,可以通过首元素、尾后元素指针作为形参。
begin()
和end()
来求对应的指针。
(3)对于一般数组,可以通过首元素、数组长度作为形参。
end()-begin()
来求对应数组的长度。 -
当不需要对数组进行写操作时,可定义为指向常量的指针。
-
数组引用的形参:
但这种方式会限制传递给该函数的实参只能为,有10个int元素的数组。void f(int &arr[10]); // arr是数组,里面放了10个引用,当然是错的。 void f(int (&arr)[10]); // arr是引用,绑定的是数组,数组有10个int元素。
-
传递多维数组:下面三种写法等价:
void print(int (*matrix)[10], int rowSize); // matrix是指针,指向的是数组,数组有10个int元素。 void print(int matix[][10], int rowSize); // matrix是指针,指向的是数组,数组有10个int元素。 void print(int **matrix, int rowSize, int colSize); //matrix是指针,指向的是数组(并被识别为指针),rowSize说明了外层数组的元素个数,colSize说明了内层数组的元素个数)
6.2.5 main:处理命令行选项
-
main函数并非都只有空形参列表。
形参argc,表示数组argv中字符串的数量。
形参argv,表示一个数组,元素为指向C风格字符串的指针。
因此可以有如下两者写法。int main(int argc, char *argv[]){...} int main(int argc, char **argv){...}
-
假定main函数在可执行文件prog内,则可以向程序传递下面的选项:
prog -d -o ofile data0
当实参传给main后,argv的第一个元素指向程序的名字或一个空字符串.
接下来的元素依次传递命令行提供的实参。
最后一个指针之后的元素值保证为0。
以此为例,传进main函数的argc和argv的值如下:argc = 5; // argv的元素个数 argv[0] = "prog"; // main函数所在程序名 argv[1] = "-d"; // 用户定义的输入值 argv[2] = "-o"; argv[3] = "ofile"; argv[4] = "data0"; // 最后一个元素 argv[5] = 0; // 最后一个指针之后的值,必须为0
6.2.6 含有可变形参的函数
-
为了编写能处理不同数量实参的函数,C++提供了两种主要办法:
(1)若所有实参类型相同,可以传递一个名为initiallizer_list的标准库类型。
(2)若实参类型不同,可以编写一种特殊的函数,也就是可变参数模板(第16.4节)。
(3)C++还有一种特殊的形参类型,省略符,可以用它传递可变数量的实参。需要注意的是,该功能一般只用于与C函数交互的接口程序。 -
initializer_list形参:一种标准库类型,用于表示某种特定类型的值的数组。其定义在同名头文件中,它提供如下操作:
语句 说明 initializer_list<T> lst;
默认初始化,T类型元素的空列表 initializer_list<T> lst{a,b,c};
lst的元素数量和初始值一样多。lst的元素是对应初始值的副本。列表中的元素是const lst2(lst)
、lst2 = lst
拷贝或赋值一个initializer_list对象不会拷贝列表中的元素;拷贝后,原始列表和副本共享元素 lst.size()
列表中的元素数量 lst.begin()
返回指向lst中首元素的指针 lst.end()
返回指向lst中尾元素下一位置的指针 -
和vector一样,initializer_list也是一种模板类型,且也可以使用范围for循环。
initializer_list对象中的元素永远是常量值,我们无法改变initializer_list对象中元素的值。 -
如果想向initializer_list形参传递一个值的序列,则必须把序列放在一堆花括号内。
比如函数void error_msg(initializer_list lst);
,调用该函数的形式为error_msg({"functionX", expected});
-
省略符形参:
为了便于C++程序访问某些特殊的C代码而设置的,这些代码使用了名为varargs的C标准库功能。
省略符形参应该仅仅适用于C和C++通用的类型。特别应该注意的是,大多数类型的对象在传递给省略符形参时都无法正确拷贝。 -
省略符形参只能出现在形参列表的最后一个位置。
void foo(parm_list, ...);
void foo(...);
第一种形式指定了foo函数的部分形参的类型,对应于这些形式的实参会执行正常的类型检查。
省略符形参所对应的实参无需类型检查。
6.3 返回类型和return语句
6.3.1 无返回值函数
-
无返回值的return语句只能用在返回类型是void的函数中。
-
可以省略return语句(隐式执行return)的情况:void类型函数、和main函数。
6.3.2 有返回值函数
-
return语句返回值类型必须与返回类型相同、或可转换。
有返回类型的函数都必须有return语句(main函数可省略但不建议)。 -
返回值的方式:返回的值用于初始化调用点的一个临时量。
如果不是引用,则把返回值拷贝到调用点。
为了避免拷贝,应使用引用。
如果是引用,该返回值必须是对函数之前已经存在的对象的引用。
不允许返回对局部变量的引用。
(同样,也不允许返回局部变量的指针。) -
运算符优先级:与
.
和->
相同。
如:shorterString(s1, s2).size()
自左向右结合。 -
调用一个返回引用的函数得到左值,其他返回类型得到右值。
原理:函数的返回值要么是值(返回局部变量的值的副本,局部变量本身随函数销毁而销毁),要么是引用(会绑定到对象上,不会销毁,且对象可以被赋值)。
因此,允许为返回类型是非常量引用的函数的结果(左值)赋值。
若不希望某函数的结果作为左值,在声明函数时应,返回类型应为常量引用,即const TYPE &
。 -
列表初始化返回值:
函数可以返回花括号包围的值的列表,此处的列表也用来对表示函数返回的临时量进行初始化。 -
main函数返回0表示执行成功,返回其他值则因编译环境不同而意义不同。
因此可以使用cstdlib
头文件中定义的两个预处理变量来表示成功与失败:EXIT_SUCCESS
、EXIT_FAILURE
。 -
递归:函数调用自己,应避免无限循环。
特例:main函数不能调用自己。
6.3.3 返回数组指针
-
因为数组不能直接拷贝,所以不能返回数组,但可以返回数组的指针或引用。
int (*func(int i))[10]; // func是一个函数, // 函数返回的是指针, // 指针解引用得到的是含10元素的数组, // 数组里每个元素都是int
-
另外的写法:
但不管是哪一种,都要注意func返回的是地址才行。// 第一种:类型别名 typedef int arrT[10]; // arrT是类型别名,表示含有10个整数的数组 using arrT = int[10]; // arrT的等价声明 arrT* func(int i); // func返回一个指针,指向含有10个整数的数组 // 第二种:置尾返回类型 auto func(int i) -> int(*)[10]; // 把func函数的返回类型写在后面,并在前面放置一个auto // 第三种:使用decltype int odd[] = {1, 3, 5, 7, 9}; int even[] = {0, 2, 4, 6, 8}; decltype(odd) *arrPtr(int i){ return (i % 2) ? &odd : &even; }
6.4 函数重载
-
函数名相同但形参列表不同,称为重载函数。
实际调用时,编译器会根据实参类型推断具体某个函数。
main函数不能重载。 -
两个函数,若函数名否相同、形参类型相同,仅返回类型不同,则第二个函数声明时错误的。
函数声明中,形参列表可以不写形参,但必须写形参类型。
函数定义中,形参的列表和类型必须都写。 -
顶层const不影响传入函数的对象,因此拥有顶层const的形参和普通形参没有区别。
-
如果形参是某种类型的指针或引用,则通过区分其指向的是常量还是非常量对象,可以实现函数的重载。
record lookup(Account); // (1)形参为值 record lookup(const Account); // (2)与(1)一致,因为顶层const被忽略 record lookup(Account*); // (3)形参为指针 record lookup(Account* const); // (4)与(3)一致,因为顶层const被忽略 record lookup(const Account*); // (5)形参为指针,且函数不会改动指针所指向的值 record lookup(Account&); // (6)形参为引用 record lookup(const Account&); // (7)形参为引用,且函数不会改动引用所绑定的值
-
重载函数最好应用于功能相近、甚至一致的函数。
-
const_cast在重载函数的情景中很有用。
// 原来的函数 const string &shorterString (const string &s1, const string &s2){ return s1.size() <= s2.size() ? s1 : s2; } // 使用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); }
-
定义一组重载函数之后,具体调用该组重载函数中的哪一个,称为函数匹配,或重载确定。
6.4.1 重载与作用域
- 在内层作用域中声明名字,将隐藏外层作用域中的同名实体(函数、对象)。
当外层作用域有函数(或一组重载函数),内层作用域也有与之同名的函数(或重载函数),编译器会屏蔽外层函数。
如果内层作用域的一组重载函数没有与调用语句相匹配的函数,即使外层作用域的重载函数某一个符合要求,也不会调用外层的函数。
6.5 特殊用途语言特性
6.5.1 默认实参
-
某些函数有这样一种形参,在函数的很多次调用中都被赋予一个相同的值,此时,我们把这个反复出现的值称为函数的默认实参。
调用含有默认实参的函数时,可以包含该实参,也可以省略该实参。 -
需要注意的是,一旦某个形参被赋予了默认值,它后面所有形参都必须有默认值。
-
使用默认实参调用函数时,所有默认实参都可以同时省略。
函数会自动填补尾部省略的默认实参,但不允许跳过最左边或中间的形参的赋值。
因此设计函数时,应尽量将那些经常使用默认值的形参放在后面。typedef string::size_type sz; string screen(sz height = 24, sz width = 80, char background = ' '); 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('66',80,' ') // 虽然最后一句并非我们本意,但编译可以通过。 // 因为char型字符'?'可以自动类型转换为十进制数63。
-
默认实参声明:
对于函数的声明来说,通常的习惯是将其放在头文件中,并且一个函数只声明一次,但多次声明同一个函数也是合法的。
但,同一函数的多次声明只能为那些之前没有默认值的形参添加默认实参,且该形参右侧的所有形参必须都有默认值。string screen(sz, sz, char = ' '); // 高度和宽度没有默认值 string screen(sz, sz, char = '*'); // 错误:重复声明,原因:修改一个已经存在的默认值 string screen(sz = 24, sz = 80, char); // 正确:添加默认实参
-
默认实参初始值:
只要表达式的类型能转化为形参所需要的类型,该表达式就能作为默认实参。sz wd = 80; char def = ' '; sz ht(); string screen(sz = ht(), sz = wd, char = def); string window = screen();
6.5.2 内联函数和constexpr函数
-
小函数对比等价表达式的缺点:效率比表达式慢、开销比表达式大。
调用寄存器、恢复寄存器、拷贝实参、转向新位置执行等。 -
内联函数(关键字
inline
)可避免函数调用的开销,其直接在调用点”内联地“展开,而避免函数调用的复杂步骤。// 函数定义: inline const string & shorterString(const string &s1, const string &s2){ return s1.size() <= s2.size() ? s1 : s2; } // 调用: cout << shortString(s1, s2) << endl; // 编译时直接展开: cout << (s1.size() <= s2.size() ? s1 : s2) << endl;
-
内联说明只是向编译器发出一个请求,编译器可以选择忽略这个请求。
-
constexpr函数是指能用于常量表达式的函数。
但constexpr函数的返回类型必须都是字面值类型,
且函数体中必须有且仅有一条return语句。 -
为了能在编译过程中能随时展开,constexpr函数被隐式的指定为内联函数。
-
constexpr函数体内也快成包含其他语句,但要求这些语句不执行操作。
如:空语句、typedef声明、using声明等。 -
constexpr函数体内,跟在return后面的表达式,必须是常量表达式。
但constexpr函数的返回值不一定是常量,也不一定是常量表达式。
举例:i + 2;
,在i
为const int
时才是常量表达式。 -
内联函数和constexpr函数通常放在头文件内。
复习:2.4节指出:constexpr也是一个类型说明符,其作用有两种:
一种是,将指针普通指针置为顶层const,
另一种是,将其后跟随的表达式的值交给编译器,判定其是否是常量表达式。
一般说来,如果能够认定变量是一个常量表达式,那就应该把它声明成constexpr类型。
6.5.3 调试帮助
-
aseert是一种预处理宏,
assert(expr);
,定义在cassert头文件中。
表达式为0,assert输出信息并终止程序。
表达式非0,assert什么也不做。 -
assert常用于检查“不能发生”的条件。
assert(cord.size() > threshold);
-
NDEBUG预处理变量
assert行为依赖于NDEBUG的预处理器变量状态。
如果定义了NDEBUG,则assert什么都不做。
默认状态下没有定义NDEBUG,此时assert将执行运行时检查。 -
我们可以使用一个#define语句定义NDEBUG,从而关闭调试状态。
很多编译器都提供了一个命令行选项使我们可以定义预处理变量。
cc -D NDEBUG main.c
,这条命令等价于在main.c文件的一开始写#indefine NDEBUG
-
定义NDEBUG能避免检查各种条件所需的运行时开销。
但assert不能替代真正的错误检查,也不能替代程序本身应该包含的错误检查。 -
NDEBUG还可以编写自己的条件调试代码。
如果NDEBUG未定义,将执行#idndef
和#endif
之间的代码。
如果NDEBUG已定义,这些代码将被忽略掉。 -
静态数组:
__FILE__
存放文件名的字符串字面值;
__LINE__
存放当前行号的整型字面值;
__TIME__
存放文件编译时间的字符串字面值;
__DATE__
存放文件编译日期的字符串字面值;
6.6 函数匹配
-
函数匹配的过程:候选函数、可行函数、最佳匹配。
-
多参数时,可能会出现二义性调用。
-
避免二义性调用,避免重载函数强制类型转换。
6.6.1 实参类型转换
-
为了确定最佳匹配,编译器将实参类型到形参类型的转换划分了几个等级,具体排序如下:
(1) 精确匹配:- 实参类型和形参类型相同。
- 实参从数组类型或函数类型转换成对应的指针类型。
- 向实参添加顶层const、或从实参中删除顶层const。
(2) 通过const指针转换实现的匹配
(3) 通过类型提升实现的匹配
(4) 通过算术转换或指针转换实现的匹配
(5) 通过类造型转换实现的匹配 -
用非常量对象初始化常量引用需要类型转换,故应选用非常量版本精确匹配。
6.7 函数指针
-
函数的类型由它的返回类型和形参类型共同决定,与函数名无关。
比如声明bool lengthCompare(const string&, const string &);
,
该函数的类型是:bool(const string&, const string &);
。
想要声明一个指向该函数的指针,只需要用指针替换函数名即可。
bool(*pf)(const string&, const string &);
其中的(*pf)中的括号不可少。 -
使用函数指针:
当我们把函数名作为一个值使用时,该函数会自动地转化成指针。
另外,还可以直接使用指针来调用函数,无需解引用bool(*pf)(const string&, const string &); pf = lengthCompare; pf = &lengthCompare; bool b1 = pf("Hello", "Goodbye"); // 等价于调用函数 bool b2 = (*pf)("Hello", "Goodbye"); // 调用函数,同上 bool b3 = lengthCompare("Hello", "Goodbye"); // 同上
-
同样,使用重载函数的指针时,调用时一定要与函数类型精确匹配,不允许二义性调用。
-
函数指针作为形参:
和数组类似,虽然不能定义函数类型的形参,但形参可以成定义指向函数的指针。
当然也可以使用类型别名来简化。// 声明和定义: 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 &)); // 两种写法等价 typedef bool FuncType(const string &, const string &) ; typedef decltype(lenthCompare) FuncType; typedef bool(*FuncTypeP)(const string &, const string &) ; typedef decltype(lenthCompare) *FuncTypeP; // 调用: // 可以直接把函数作为参数使用,此时会自动转换为指针 useBigger(s1, s2, lengthCompare); useBigger(s1, s2, FuncType); useBigger(s1, s2, FuncTypeP);
-
函数指针作为返回值
和数组类似,虽然不能返回函数,但可以返回指向函数的指针。
当然也可以使用类型别名。using F = int (int*, int); // F是函数类型 using PF = int (*) (int*, int); // PF是指针类型 PF f1(int); // 正确 F f1(int); // 错误 F *f1(int); // 正确 int (*f1(int))(int*, int); // 直接声明f1 auto fi(int) -> int (*)(int*, int);
-
auto和decltype用于函数指针类型
decltype可以简化书写函数返回类型的过程。
应注意:decltype作用于某个函数时,它的返回函数类型而非指针类型。
因此,我们需要显式的加上*以表明我们需要返回指针。