c++基础之函数

距离上次更新又过了一周,又该更新新的读书笔记了。本次更新的主要是c++中函数部分的内容

c++ 中的函数与c语言中的函数大致用法或者语法是一样的,这里就不就这点详细展开了。需要注意的是c/c++中并没有规定函数中参数的求值顺序,所以在调用函数时需要特别注意,在传递实参的同时不要修改实参的值,也就是不要写类似func(i, ++i)这样的语句

局部对象

高级语言中,名字只是用来访问对象所在内存的一个工具,一个对象可以分为对象名和它实际所占的内存空间。这个对象名有它的作用域,对象所在内存有自己的声明周期。这二者不是一个概念,不要弄混淆了。

变量的作用域一般只在它所定义的语句块中起作用。但是变量本身根据定义位置不同,生命周期也不同,例如

1
2
3
4
int func()
{
for(int i = 0; i < 10; i++);
}

上述代码中i这个名称仅仅在for循环中有效,而i所指代的变量,它会一直持续到函数结束(可以参考鄙人曾经写过的关于c++反汇编分析相关的内容)

根据定义位置的不同,变量分为局部变量和全局变量;

  1. 局部变量,或者在书中有一个新的名字叫自动对象,对于局部变量来说,当代码执行到变量定义语句时创建该对象,当到达定义所在语句块的末端时销毁它,只存在于块执行期间的对象称作自动对象
  2. 全局变量:定义在函数外部的变量称之为全局变量,全局变量的生命周期从程序启动时创建到程序结束时销毁。

除了这两类以外,还有在函数中使用static关键字定义的局部静态变量,局部静态变量在程序第一次经过对象定义语句时初始化,并且直到程序终止时才会销毁。在此期间即使对象所在的函数结束执行也不会对它有影响。它与全局变量的生命周期相同,只是它的变量名被限定在了函数内部(关于什么时候为它分配内存,什么时候销毁的详细内容,也可以参考鄙人曾今写过的关于static的汇编分析)

参数传递

参数传递主要有值传递、指针传递和引用传递

  1. 值传递:将实参的值拷贝到形参,然后执行函数,函数中对形参的改变不影响函数外的实参
  2. 指针传递:指针值本身也是一个拷贝,在函数中可以通过对指针进行解引用操作来间接的改变函数外的实参
  3. 引用传递:引用本身是对象的别名,可以在函数中通过对引用的修改,来修改函数外实参的值(其实本质上也是通过指针来进行修改)

根据这几种传参方式,我们总结出来这样几点:

  1. 需要改变实参的值,只能传递指针或者引用
  2. 由于存在值拷贝,所以在传递大的结构体的时候尽量传递结构体的指针或者引用,如果不想修改结构体的值,可以将形参定义为const型
  3. 函数通过return语句只能返回一个值,如果要返回多个值,可以使用指针或者引用。
  4. return 语句本身会进行拷贝,并且在赋值给外部变量时也会进行拷贝,尽量返回4或者8个字节的结构,对于大的结构体尽量使用引用来返回

当形参有顶层const时,传给它常量对象或者非常量对象都是可以的。

1
2
int func(const int i);
int func(int i);

由于顶层const被自动忽略,所以在上面代码会报错,两个函数的名称、形参列表实际上是相同的。

数组形参

除了上述这样常规的参数传递,函数中也可以传递数组,这个时候数组会自动退化为指针,例如面试或者笔试题中,经常会问到的一个问题

1
2
3
4
size_t size_arr(int[10] i)
{
return sizeof(i);
}

这个时候,如果传递数组的首地址,在函数中会退化为指针,所以实际计算的是int*指针所占的内存。根据平台的不同,指针大小为4字节(32位版本)或者8字节(64位版本)

如果想要真正的传递数组,可以使用引用的方式

1
2
3
4
size_t size_arr(int (&arr)[10])
{
return sizeof(arr);
}

此时arr表示有10个int型数据的数组的引用,最终得到的结果应该是 sizeof(int) * 10

由于传递数组名时,数组名会退化为指针,所以如果只传递数组名,则在函数中无法确定数组的大小,为了解决这个问题,一般有3种方案:

  1. 使用特殊标记,表示数组的结尾,一般字符串会这么干
  2. 传递两个指针,表示数组的首地址和尾部地址,可以使用标准库中的begin 和 end 函数分别获取数组的首地址与尾地址
  3. 显式传递一个表示数组元素个数的形参。这种情况一般使用下标运算,当下标达到这个指定值时退出循环

当我们传递的是多维数组时,按照两个思路进行分析,多维数组其实是数组的数组,传递数组名实质上是数组的首元素地址。根据这两个原则进行分析,在传递多维数组时,后面的维度是数组元素类型,不能省略。而真正传递的是第一个该类型元素的地址。

1
2
void print(int (*matrix)[10]); 
void print(int matrix[][10], int rowSize); //等价定义

上述定义中,数组的第一维被忽略掉,第二维是10个整数的数组。上面的两个定义其实是等价的

可变形参

与以往使用 ... 来表示可变形参不同的是,在c++ 中新增了一个名为initializer_list 的标准库类型,这个类型只能处理所有实参类型相同的情况,对于实参类型不同的情况,可以使用可变参函数模板。

initializer_list 本身是一个类似与list的结构,但是与list不同的是,initializer_list中的对象永远是常量,无法修改该容器中的值,换一个角度来说,也就无法修改实参的值了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void error_msg(initializer_list<string> il)
{
for(auto beg = il.begin(); beg != il.end(); ++beg)
{
cout << *beg << " ";
}

cout << endl;
}

//由于它是一个容器,所以在传递值的时候应该使用一对花括号把所有值括起来
if(expected != actual)
{
error_msg({"functionX", "expected", "actual"});
}else
{
error_msg({"functionX", "okay"});
}

返回类型和return语句

函数根据返回值类型可以分为三大类,无返回值的函数、返回普通值的函数、返回指针或者引用的函数。

无返回值的函数

无返回值的函数不要求非要有return 语句,这类函数在最后一句执行完后会隐式的执行return语句

如果无返回值的函数需要在中间位置提前退出的话,可以使用return语句

另一个使用return的场景是,直接在return后面加上函数调用,不过被调用的函数需要也是无返回值的函数

返回普通值的函数

有返回值的函数,必须使用return 返回一个值,返回的值必须与函数的返回类型相同,或者能隐式的转化成函数的返回值

要注意保证所有路径都有返回值,一般编译器能发现这类情况,但是有的编译器不能,如果执行了没有返回值的分支,将产生未定义错误

不要返回局部对象的指针或者引用

函数的调用优先级与点运算和箭头运算的优先级相同,并且也附和左结合律

函数的返回类型决定函数调用是否是左值,调用一个返回引用的函数得到一个左值,其他返回类型得到右值,我们能为返回类型是非常量引用的函数结果赋值

当返回一个容器时,c++ 11开始,可以返回由大括号组成的初始化列表

针对main函数来说,最后可以不加return语句,如果最后没有return 则编译器默认给它加上一个return 0

返回数组指针的函数

因为数组不能被拷贝,所以不能直接返回数组,不过可以返回数组的指针或者引用

定义指向数组的指针采用的是int (*p)[10]; 的方式,同样的定义返回数组指针的函数,只需要把p定义为函数形式即可:int (*func(int i))[10];

上述定义写起来比较麻烦,而且也不容易理解,因此可以使用类型别名的方式来简化定义方式

1
2
3
4
5
6
7
int odd[] = {1, 3, 5, 7, 9};
int even[] = {2, 4, 6, 8, 10};

decltype(odd)* arrPtr(int i)
{
return (i % 2) ? &odd, &even;
}

当我们直到返回的数组指针具体指向哪个数组,可以使用decltype关键字来声明函数的返回类型。

从c++11 开始,提供了一种新的定义方式,即尾置返回类型的方式

1
auto func(int i) -> int (*)[10];

函数重载

c++ 与 c语言中的一个很大的不同就是c++ 允许函数重载。

同一作用域内的几个函数名称相同,但形参列表不同的,称之为函数重载。注意这里的几个前提条件:同一作用域、函数名称相同、形参列表不同;这些条件缺一不可。而且这里说的是形参列表不同,返回值不同的不能算是重载。

main函数作为入口函数,只能有一个

顶层const不影响传入的参数,因此认为顶层const与普通形参相同,不认为是重载

如果传入的参数是引用或者指针,可以根据它所指向的对象是否为const来进行区分,所以底层const可以作为重载

由于非const型参数能转化为const型,所以当传参中多个函数都满足,编译器会优先选择const版本

在实际使用时,根据调用时的传参,来与一组重载函数中的某一个关联起来,这个过程叫做函数匹配或者叫做重载确定

一般情况下函数匹配过程很容易分别出来,要么是形参个数不同,要么是类型毫无关系,但是也有例外,例如:

  1. 形参中存在默认值
  2. 形参中一种类型可以转化为另一种类型

目前来说调用函数的时候会出现下列三种情况:

  1. 可以从一堆重载函数中正确匹配,编译通过
  2. 没有函数复合调用时传入的实惨,此时编译报错,无法找到对应函数
  3. 多个重载形式都复合传入的实惨,此时编译报错,存在二义性

不要在局部作用域中定义函数,因为局部作用域内出现重名情况时,会进行名称覆盖

特殊用途的语言特性

默认实参

在定义函数时,对于后续多次调用时,传入相同实参值的形参,可以给予一个默认值。这样在调用这个函数时,针对提供了默认值的参数,可以传参也可以不传

函数调用时按照实参位置解析,默认实参负责填补函数调用缺少的尾部实参

内联函数

一般函数调用涉及到参数的拷贝,返回值的拷贝,以及最终栈的回收等一系列操作。调用函数存在一定的性能开销,因此为了提高性能或者提高代码的重复使用率,c中可以使用宏定义来定义一个短小的常规操作,最终编译时会被编译器展开。但是宏定义无法对传入参数进行校验,而且需要注意的问题较多,不好理解。C++中引入内联函数,它与宏的功能类似,是一种没有额外开销的函数

一般在函数的返回值类型前面加上inline 关键字就定义了一个内联函数

并不是所有的函数都可以定义为内联函数。内联函数用于优化规模小、流程直接、调用频繁的函数,很多编译器不支持内联递归函数。而且一个大于75行的函数也不大可能在调用点内联展开

constexpr 函数

constexpr 函数是指能用于常量表达式的函数。

constexpr函数与普通函数的定义相同,不过要遵循几项约定:

  1. 函数的返回值以及所有形参类型都是字面值类型
  2. 函数体中必须有且只有一条return语句

在编译阶段,constexpr函数会被直接替换为它返回的具体的值,为了便于函数正常展开,constexpr函数默认都是内联函数

由于在编译阶段编译器需要知道内联函数和constexpr 函数的定义。因此这两种函数可以重复定义。但是定义时要保证内容完全相同,基于这个理由,可以将这两种函数统一放到一个头文件中,在需要使用的时候包含它

调试帮助

可以使用assert预处理宏与NODEBUG宏,其中assert只有在调试模式下才会起作用,而NODEBUG宏则表示当前在发布模式下,此时assert函数不会起作用
另外C++ 也定义一些名字便于调试:

  1. FILE: 当前代码所在文件的名称
  2. LINE: 当前代码所在行数
  3. TIME: 当前代码文件被编译的时间
  4. DATE: 当前代码文件被编译的日期
  5. func: 当前代码所在的函数

函数匹配

在大多数情况下,很容易分辨某次调用应该选择哪个重载函数,然而当几个重载函数的形参数量相等以及某些形参的类型可以由其他类型转化得来时,这项工作就不那么容易了。

1
2
3
4
5
6
7
void f();
void f(int);
void f(int, int);
void f(double, double = 3.14);

//调用
f(5.6);

函数匹配过程一般经历如下步骤:

确定候选函数和可行函数

第一步选定本次调用对应的重载函数集,集合中的函数被称之为候选函数。候选函数具备两个特点,一是与被调用的函数同名,二是其声明在调用点可见,这步下来,上述例子中所有f函数都满足条件
第二步考察本次调用提供的实参,然后从候选函数中选择能被这组实参调用的函数,这些函数被称为可行函数,可行函数也有两个特征,一是其形参数量与本次调用提供的实参数量相同,二是每个实参与对应形参类型相同,或者能转化成形参的类型。上述实例,调用传入的是一个double类型的参数,double可以转化为int,因此这个时候发现满足条件的是 void f(int);void f(double, double=3.14);

寻找最佳匹配

第三步是从可行函数中寻找与本次调用最匹配的函数,它的基本思想是实参类型与形参类型越接近,它们匹配的越好。

如果多个形参都与调用函数的实参较为接近且,如果有且只有一个函数同时满足下面两个条件,则匹配成功:

  1. 该函数每个实参的匹配不劣与其他可行函数需要的匹配
  2. 至少有一个实参的匹配优于其他可行函数提供的方案

如果检查了所有实参后没有任何一个函数脱颖而出,则调用错误,编译器将报告二义性。

调用重载函数尽量避免强制类型转换,如果在实际应用中需要进行强制类型转换,说明我们设计的形参集合不合理

分析上面的例子,如果采用 void f(int); 在调用时会进行一次将double转化为int的类型转化,如果使用 void f(double, double=3.14); 5.6作为double的第一个参数进行传递不需要类型转化,而第二个参数使用默认形参,这里可以不传,因此相比较与第一种int的传参方式,后一种显然更加复合

实参类型转化

为了确定最佳匹配,编译器将实参类型到形参类型的转化划分为几个等级,具体排序如下所示:

  1. 精确匹配,包括下列情况
    1.1. 实参类型和形参类型相同
    1.2. 实参从数组类型或者函数类型转化为对应的指针类型
    1.3. 像实参添加顶层const或者从实参中删除顶层const

  2. 通过const转换实现的类型匹配

  3. 通过类型提升实现的类型匹配

  4. 通过算术类型转换或者指针转换实现的匹配

  5. 通过类类型转换实现的匹配

函数指针

声明函数指针时,只需要将函数声明中的函数名写为指针名即可,但是需要注意使用括号将表示指针的*与指针名称括起来

1
void (*f)(int);

当我们把函数名直接作为一个值使用时,该函数自动转化为指针;也可以使用取地址符针对函数名称取地址,二者是等价的。

指向不同类型函数的指针不存在类型转化

重载函数的指针必须与某个函数精确匹配,不存在形参类型转化之类的规则

可以使用typedef来为函数指针类型定义一个类型别名

1
typedef void(*f)(int); //将返回void、传入一个int参数的函数指针取类型别名为f