《C++ Primer》【Chapter 6】
Chapter6 函数
6.1 函数基础
一个函数包括:
- 返回类型
- 函数名字
- 0个或多个形参组成的列表
- 函数体
函数的调用
通过调用符号来执行函数。调用符号是一对圆括号,它作用于一个表达式,该表达式是函数或者指向函数的指针;圆括号之内是用逗号隔开的实参列表,用这些实参初始化函数的形参。调用表达式类型就是函数的返回类型。
函数调用主要完成两个工作:
- 用实参初始化函数对应的形参;
- 将控制权转移给被调用的函数。此时,主调函数(calling function)的执行被暂时中断,被调函数(called function)开始执行。
return 语句可能不只是单纯的返回值
return语句在函数中有两项工作:
- 返回需要返回的值
- 将控制权从被调函数移回主调函数
形参和实参应当满足
- 数量对应(形参多少,实参也应该有多少)
- 位置对应(每个实参对应的形参位置要正确)
- 类型对应(类型要么一样,要么能隐式转)
局部变量和作用域
函数定义了一个语句块,语句块构成的作用域中定义的变量和形参都是局部变量。他们对于函数而言是局部的,同时局部变量还会隐藏在外层作用域中同名的其他所有声明中。(即局部变量和外层作用域同名的变量的修改互相不干扰,前提是局部有定义!而不是直接修改)
局部静态对象
这个东西还是很有意思的,需要注意的是,静态的是函数的作用域,虽然函数执行完了,但在函数作用域内,该对象只会初始化一次。
void fun() {
static int a = 0;
a++;
cout << a << endl;
}
int main() {
for(int i = 0; i < 10; ++i) {
fun(); //输出1-10
cout << a << endl; //会报错,因为函数中a的静态只针对函数作用域,而不是全局的
}
return 0;
}
为什么函数声明和定义要分开?
一般在头文件中声明函数,在源文件中定义函数。
- 如果声明和定义在一个文件里,很繁琐,且容易出错;
- 把函数声明放在头文件中,能确保同一函数的所有声明保持一致;
- 一旦我们想改变函数的接口,只需要改变一条声明即可。
关于分离式编译自己的理解
分离式编译,即当项目复杂时,由于很多时候实现和声明是分离的,有的cpp文件(假设为a.cpp)只引用了头文件中的函数,并没有实现,为了不重复定义函数,可以通过编译定义函数的另一个cpp文件(b.cpp),然后通过编译器链接在一起进行编译,可以让函数实现和使用尽可能分离,独立编译,提高工作效率。
g++ -c a.cpp #编译a.cpp 得到a.o
g++ -c b.cpp #编译b.cpp 得到b.o
g++ a.cpp b.cpp -o main #链接两个源文件形成可执行文件
6.2 参数传递
参数传递分为:引用传递和值传递
传值参数
对于值传递,形参和实参是独立的,即函数中形参的修改并不会影响实参。但并不是一定不能修改函数外的值。
指针形参
对于指针而言,形参对于实参指针进行了copy,但是指针指向的对象都是一样的,当函数内对指针指向的对象操作了,指针实参指向的对象依然会改变。但是如果函数内对指针进行了修改,即指向别的对象后,指针实参的指向依然不会受到影响。
在写C++的时候不建议使用指针形参,若要修改函数外部对象,可以直接使用引用
void swap(int *a, int *b) {
//由于指针的指向不会改变,所以为了交换函数外部两个变量的值,必须修改指针指向的对象,而不是修改指针
int c = *a;
*a = *b;
*b = c;
}
int main() {
int a = 1, b = 2;
std::cout << a << ", " << b << std::endl;
swap(&a, &b);
std::cout << a << ", " << b << std::endl;
}
传引用参数
用法和定义引用差不多,简单理解就是定义一个引用形参和实参绑定。
使用引用参数的优点:
- 避免拷贝浪费时间、空间
- 为函数返回多个结果提供有效的途径,直接通过参数返回
什么情况下用传引用的参数呢?
ans:如果函数无须改变形参的值,最好将其声明为常量引用
const形参和实参
回顾:顶层const表示指针本身是一个常量(作用于对象本身);底层const表示指针所指的对象是一个常量
- 当形参有顶层const时,传给它常量对象或非常量对象都可以
void fun(const int i);
void fun(int i); //错误,因为两个函数虽然形式上有差异,但实际上两者的形参没有什么不同
const形参的使用和const本身的应用是差不多的。而常量形参可以接受非常量的实参,但反之不可以。这也是为什么尽量使用常量引用的原因,可以增加参数所能接受实参的类型。
为什么要用常量引用?
- 增加形参数据接受范围,因为常量形参可以接受非常量的实参,但反之不可以。
- 对于不需要修改的数据,常量引用可以保护数据不被修改,同时减少形参实参复制的时间和空间开销。
数组形参
数组有两个性质对于使用数组参数有影响:
- 不允许拷贝数组
- 使用数组时通常会将其转成指针。(所以我们无法以值传递的方式使用数组参数)
以下3个函数定义的方式都正确
void print(const int*);
void print(const int[]);
void print(const int[10]); //10这个长度其实并没有影响,最后都是指针类型
单纯的指针并没有包含数组的长度,所以调用者还需要提供指针的长度信息。
管理指针形参的三种常用的技术
- 使用标记指定数组的长度;(例如字符串的'\0'符号)
void print(const char *cp) {
if (cp)
while(*cp != '\0') {
std::cout<<*cp++;
}
}
这种技术适用于有明显标记的数据。
- 使用标准库规范
传递指向数组首元素和尾后元素的指针
void print(const int *beg, const int *end) {
while(beg != end) {
std::cout << *beg++;
}
}
- 显示传递一个表示数组大小的形参
void print(const int *a, size_t size) {
for(int i = 0; i < size; ++i) {
std::cout << a[i];
}
}
数组引用形参
C++允许将变量定义成数组的引用,同样的道理,形参也可以是数组的引用。
void print(int (&arr)[10]) {
arr[0] = 333;
}
需要注意的是,形参如果是数组的引用,那么维度也是类型的一部分,即实参维度要和形参维度对应。
传递多维数组
C++中没有真正的多维数组,所以传递多维数组实际传递的就是指针,并且传递数组第二维之后的大小(长度)都是数组类型的一部分,不能省略。
void print(const int (*mat)[3], int row_size) {
for(int i = 0; i < row_size; ++i) {
for(int j = 0; j < 3; ++j) {
std::cout<<mat[i][j]<<" ";
}
std::cout<<std::endl;
}
}
main:处理命令行选项
我们平常写代码定义的main函数都只有空形参列表,但偶尔也需要给main函数传递实参,一种常见情况是用户通过设置一组选项来确定函数所要执行的操作。
//main.cpp文件
int main(int argc, char *argv[]) { //argc是传递的字符串数目,argv是传递的字符串
for(int i = 0; i < argc; ++i) {
std::cout<<argv[i]<<std::endl;
}
}
./main -d -o ofile data0
输出结果是:
./main #argv[0] 允许程序的名字
-d #argv[1]
-o
ofile
data0 #argv[4]
6.3 返回类型和return语句
无返回值函数
void
有返回值函数
需要注意的是,当在for循环里有return语句时,需要在for语句之后再添加一个return语句,以防止没有进入for循环。
对于返回值,一般返回的值用于初始化调用点的一个临时变量,该临时变量是函数调用的结果,即返回的是拷贝的值(如果返回的是引用,则不会真正拷贝)
不要返回局部对象的引用或指针
原因:因为函数完成时,所占用的存储空间也随之被释放掉,所以千万不能返回局部对象的引用或指针。函数终止后,局部变量的引用将指向不再有效的区域。
返回类类型的函数和调用符
即函数返回类后,可直接通过点运算符得到类里的属性
auto sz = shortString(s1, s2).size();
引用返回左值
左值和右值的区别很显然,就是能够出现在=号左边的就是左值,能出现等号右边的就是右值。
函数返回引用作为左值,把这个函数的值当成一个变量即可,但要注意的是,如果返回的是常量引用则不能作为左值。
char &get_value(std::string &str, int id) {
return str[id];
}
int main() {
std::string s = "Hello world!";
std::cout << s << std::endl;
get_value(s, 1) = 'E';
std::cout << s << std::endl;
}
列表初始化返回值
C++11规定,函数可以返回花括号包围的值的列表。
vector<string> process() {
return {"functionX", expected, actual};
}
main的返回值
一般函数的规定是:如果函数的返回类型不是void,那么它必须返回一个值。但是这条规定对main例外。main函数的结尾处如果没有return语句,编译器将隐式地插入一条返回0的return语句,表示执行成功,非0值根据机器不同表达含义不同。
递归
定义:函数调用自己本身。main函数不能调用自己。
返回数组指针
因为数组不能拷贝,所以函数不能返回数组,但可以返回数组的指针或引用。
int a[] = {0,1,2};
int b = a; //错误的,数组不能拷贝
int (*ptr)[3] = &a; //正确 ptr是指向包含有3个整数的数组的指针
定义一个数组指针或引用
typedef int arrT[10]; //使用类型别名,arrT表示10个整数的数组
using arrT = int[10]; //arrT的等价声明
arrT* func(int i); //func返回一个指向含有10个整数的数组的指针
声明一个返回数组指针的函数
声明数组的时候,声明数组的维度是必须的
int arr[10]; //arr是一个含有10个整数的数组
int *p1[10]; //p1是一个含有10个整型指针的数组
int (*p2)[10] = &arr; //p2是一个指向有10个整数的数组的指针
返回数组指针的函数形式:
Type (*function (parameter_list)) [dimension]
例如:
func(int i); //表示调用func函数时需要一个int参数
(*func(int i)); //意味着我们可以对函数调用的结果执行解引用操作,即可以通过*得到指针指向的内容
(*func(int i))[10]; //表示解引用func的调用将得到一个大小是10的数组
int (*func(int i)) [10];//表示数组中的内容是int
使用尾置返回类型
任何函数的定义都能使用尾置返回,主要用于比较复杂的函数。尾置返回类型跟在形参列表后面并以->符号开头,以表示函数真正的返回类型。且一般在声明的时候,在函数最前面加一个auto
auto func(int i) -> int(*)[10];
更粗暴的方法是直接使用decltype
int even[] = {2,4,6,8};
int odd[] = {1,3,5,7};
decltype(odd) *arrPtr(int i) {
return (i%2)?&odd:&even;
}
6.4 函数重载
确定调用的函数应该根据函数名和参数(形参数量,形参类别)来确定。
const形参
如果形参是某种类型的指针或引用,则通过区分其指向(底层)的是常量对象还是非常量对象可以实现函数的重载。但若实参是非常量的,优先考虑非常量的版本的函数。
重载函数的标注是要看重载后是否会让函数更容易理解
const_cast和重载
const string &shortstring(const string &s1, const string &s2) {
return s1.size()<s2.size()?s1:s2;
}
//转换为非常量的
string &shortstring(string &s1, string &s2) {
auto &r = shortstring(const_cast<const string&>(s1), const_cast<const string&>(s2));
return const_cast<string&>(r);
}
调用重载的函数
函数匹配:指把函数调用与一组重载函数中的某一个关联起来的过程,也叫做重载确定。通常,根据参数的个数和类型很容易确定,但是当类型是可以相互转换的类型时就比较困难。
当重载函数时有三种可能的结果:
- 编译器找到一个与实参最佳匹配(best match)的函数,并生成调用该函数的代码。
- 找不到任何一个函数与调用的实参匹配,此时编译器发出无匹配(no match)的错误信息。
- 有多于一个函数可以匹配,但是每一个都不是明显的最佳选择。此时也将发生错误,称为二义性(ambiguous call)。
重载与作用域
void print(const string &);
void print(double);
void foobar() {
void print(int); //函数内的局部作用域上声明的函数
print("Hello world"); //报错
}
在foobar函数内声明的print(int)隐藏了函数外(外层作用域)中的同名实体。
6.5 特殊用途语言特性
默认实参
需要注意的一点是,对于函数的声明,多次声明一个函数是合法的,但在给定的作用域中,一个形参只能被赋予一次默认实参。
int a(int x, int y);
int a(int x, int y); //ok
int a(int x=2, int y); //错误,默认参数右边的参数应该都要有默认值
int a(int x, int y=3);
int a(int x, int y=4); //错误,该作用域内,一个形参只能被赋予一次默认形参
下面的重载函数,声明可以过,但是调用会有二义性,因为不知道该匹配哪一个。
void f(int a) {
std::cout << "f1 : " << a << std::endl;
}
void f(int a = 1, int b = 4) {
std::cout << "f1 : " << a << " and b " << b << std::endl;
}
int main() {
f(1); //错误,二义性
}
内联函数和constexpr函数
内联函数引用原因是:调用函数一般比求等价表达式的值要慢一些。当函数被频繁调用时,会浪费很多时间。
在大多数机器上,一次函数调用其实包含着一系列工作:调用前要先保存寄存器,并在返回时恢复;可能需要拷贝实参;程序转向一个新的位置继续执行
内联函数可避免函数调用的开销
将函数指定为内联函数,通常就是将它在每个调用点上“内联地”展开。内联机制用于优化规模较小、流程直接、频繁调用的函数。很多编译器不支持递归内联递归,长度太长的函数不支持内联
constexpr函数
constexpr函数是指能用于常量表达式的函数。constexpr函数会被隐式的指定为内联函数。
有以下几项约定:
- 函数的返回类型及所有形参的类型都是字面值类型(算术类型1+2、引用、指针)
- 函数体中有且仅有一条return语句
- 允许返回值并非一个常量
内联函数和constexpr函数放在头文件内
内联函数和constexpr函数可以在程序内多次定义,因为编译器想展开函数仅有函数声明是不够的,还需要函数的定义。不过,对于某个给定的内联函数或者constexpr函数来说,它的多个定义必须完全一致。基于这个原因,内联函数和constexpr函数通常定义在头文件中,保证定义完全一致。
调试帮助
assert预处理宏
assert(expr);
对expr求值,如果表达式为假,assert输出信息并终止程序的执行。如果为真,则什么也不做。
NDEBUG预处理变量
assert的行为依赖与一个名为NDEBUG的预处理变量的状态。如果定义了NDEBUG,则assert什么也不做。默认状态下没有定义NDEBUG,此时assert将执行运行时检查。
可以使用一个#define语句在代码中定义NDEBUG,从而关闭调试状态。同时,很多编译器提供命令行选项定义预处理变量。
g++ -D NDEBUG main.cpp -o mian
除了C++编译器定义了静态的函数名字信息,预处理器还定义了另外4个对于程序调试有用的信息。
__func__;//函数名
__FILE__;//存放文件名的字符串字面值
__LINE__;//存放当前行号的整型字面值
__TIME__;//存放文件编译时间的字符串字面值
__DATE__;//存放文件编译日期的字符串字面值
6.6 函数匹配
重载函数的匹配对于函数的使用非常重要。
-
确定候选函数和可行函数:第一步就是选定本次调用对应的重载函数集,集合中的函数称为候选函数。候选函数一是与被调用的函数同名,二是其声明在调用点可见。
-
考察函数调用提供的实参:从候选函数中选出能被这组实参调用的函数,这些新选出来的函数称为可行函数。可行函数一是其形参数量与实参数量相等,二是每个实参的类型与实参类型相同,或者是能转化成形参的类型。
-
寻找最佳匹配:从可行函数中选出与本次调用最匹配的函数。逐一检查函数调用提供的实参,寻找类型最匹配的那个可行函数。精准类型匹配的要比需要类型准还的匹配更好。
含有多个形参的函数匹配
编译器会依次检查每个实参以确定哪个函数是最佳匹配。如果有且仅有一个函数满足下列条件,则匹配成功,否则报二义性错误。
- 该函数每个实参的匹配都不劣于其他可行函数需要的匹配
- 至少有一个实参的匹配优于其他可行函数提供的匹配
实参类型转换
即将实参转换为形参的类型。重点是要确保类型转换后不会出错。
函数匹配和const实参
如果重载函数的区别在于它们的引用类型的形参是否引用了const,或者指针类型的形参是否指向const,则当调用发生时编译器通过实参是否是常量来决定选择哪个函数。即常量类型和非常量类型会进行区分,进行精准的匹配。当用非常量对象作为形参的时候,转换为常量需要进行类型转换。
6.7 函数指针
函数指针指向的是函数而非对象。函数的类型由它的返回类型和形参类型共同决定,与函数名无关。
bool (*pf)(const string&, const string&);
使用函数指针
与平常使用变量不同,把函数名作为一个值使用时,函数自动地转换成指针。
pf = lengthCompare;
pf = &lengthCompare; //与上面的表达式等价
pf = 0;
pf = nullptr; //与上式都表示指针没有指向任何一个函数
与使用指针不同的是,我们能够直接使用指向函数的指针调用该函数,无须提前解引用指针:
bool b1 = pf("hello", "goodbye");
bool b2 = (*pf)("hello", "goodbye"); //与上面的表达式等价
bool b3 = lengthCompare("hello", "goodbye");//原函数的调用,也等价
使用函数指针调用重载函数
由于函数指针的定义需要返回类型和形参类型,那么就可以通过定义直接匹配到使用哪个重载函数。
函数指针形参
虽然不能定义函数类型的形参,但是可以定义函数类型的指针作为形参。此时,形参可以看作函数类型,虽然实际上是被当成指针使用的。
bool lengthCompare(const std::string &a, const std::string &b) {
return a.length() > b.length();
}
void useBigger(const std::string &a, const std::string &b, bool pf(const std::string &a, const std::string &b)) ;
void useBigger(const std::string &a, const std::string &b, bool (*pf)(const std::string &a, const std::string &b)) ; //两个定义等价
useBigger(a,b, lengthCompare); //以下两种用法都是对的
useBigger(a,b, &lengthCompare);
当函数指针或者函数类型作为形参时,传递函数的实参会自动将函数转换为指针使用
但是当使用类型别名的时候,函数类型和函数指针类型是有区分的,即不会将函数类型自动转换为指针。
typedef bool Func(const string&, const string&);
typedef decltype(lengthCompare) Func2; //与上面的等价
typedef bool (*FuncP)(const string&, const string&); //这里可能不是很好理解,但是类别typedef long long ll;感觉就是将表达式(long long ll = 333;)前面加个typedef就是起类型别名了。
typedef decltype(lengthCompare) *FuncP2;//与上面的等价
返回指向函数的指针
首先,铭记不能返回函数,那么用类型别名可以方便返回函数指针。如果不好理解类型别名,可以用尾置返回类型的方式。
auto f(int) -> int (*) (int*, int); //f函数的返回类型是int (int*, int)类型函数的指针
将auto和decltype用于函数指针类型
decltype一般用于明确知道返回类型是什么类型的函数指针。
string::size_type sumLength(const string&, const string&);
string::size_type largerLength(const string&, const string&);
decltype(sumLength) *getFcn(const string &); //声明一个函数,返回类型是sumLength函数类型的指针,注意不是(*getFcn),所以getFcn是函数而不是指针!
需要注意的是,decltype作用于函数时,它返回函数类型而非指针类型!