C++ 入门基础之三

大纲

C++ 对 C 语言的函数扩展

内联函数

什么是内联函数

在 C 语言中,使用宏定义函数这种借助编译器的优化技术来减少程序的执行时间,那么在 C++ 中有没有相同的技术或者更好的实现方法呢?答案是有的,那就是内联函数。内联函数作为编译器优化手段的一种技术,在降低程序运行时间上非常有用。C++ 的内联函数是通常与类一起使用。如果一个函数是内联的,那么在编译时,编译器会把该函数的代码副本放置在每个调用该函数的地方。对内联函数进行任何修改,都需要重新编译函数的所有客户端,因为编译器需要重新更换一次所有的代码,否则将会继续使用旧的函数。如果想把一个函数定义为内联函数,则需要在函数名前面放置关键字 inline,在调用函数之前需要对函数进行定义。所有在类中定义的函数都是内联函数,即使没有使用 inline 关键字声明。当内联函数收到编译器的指示时,即可发生内联:编译器将使用函数的定义体来替代函数调用语句,这种替代行为发生在编译阶段而非程序运行阶段。值得一提的是,内联函数仅仅是对编译器的内联建议,编译器是否觉得采取建议取决于函数是否符合内联的有利条件。如何函数体非常大,那么编译器将忽略函数的内联声明,而将内联函数作为普通函数处理。

为什么要使用内联函数

有时候我们会写一些功能专一的函数,这些函数的函数体不大,包含了很少的执行语句。例如在计算 1~1000 以内的素数时,我们经常会使用开方操作使运算范围缩小,这时我们会写如下一个函数:

1
2
3
4
int root(int n)
{
  return (int)sqrt((float)n);
}

然后求范围内素数的函数可以这样写:

1
2
3
4
5
6
7
8
9
10
int prime(int n)
{
int i;
for (i = 2; i <= root(n); i++)
{
if (n%i == 0)
      return 0;
return 1;
}
}

当然,把 root 函数放在循环中不是个不明智的选择,但想象一下,在某个程序上下文内必须频繁地调用某个类似 root 的函数,其调用函数的花销会有多大:当遇到普通函数的调用指令时,程序会保存当前函数的执行现场,将函数中的局部变量以及函数地址压入堆栈,然后再将即将调用的新函数加载到内存中,这要经历复制参数值、跳转到所调用函数的内存位置、执行函数代码、存储函数返回值等过程;当函数执行完后,再获取之前正在调用的函数的地址,回去继续执行那个函数,运行时间开销简直太多了。为了解决上述问题,C++ 内联函数提供了替代函数调用的方案,通过 inline 声明,编译器首先在函数调用处使用函数体本身语句替换了函数调用语句,然后编译替换后的代码。因此,通过内联函数,编译器不需要跳转到内存其他地址去执行函数调用,也不需要保留函数调用时的现场数据。

如何使用内联函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>

using namespace std;

// 宏定义函数的声明
#define MAXFUNC(x, y) (x > y) ? x : y

// 内联函数的声明
inline int Max(int x, int y) {
return (x > y) ? x : y;
}

int main() {
// 内联函数的调用
cout << "Max (20,10): " << Max(20, 10) << endl;
cout << "Max (0,200): " << Max(0, 200) << endl;
cout << "Max (100,1010): " << Max(100, 1010) << endl;

// 宏定义函数的调用
printf("Max (10,30): %d\n", MAXFUNC(10, 30));
return 0;
}

程序运行的输出结果如下:

1
2
3
4
Max (20,10): 20
Max (0,200): 200
Max (100,1010): 1010
Max (10,30): 30

内联函数的优缺点

优点:

  • 它通过避免函数调用所带来的开销来提高程序的运行速度
  • 通过将函数声明为内联,则可以把函数定义放在头文件内
  • 它避免了普通函数调用时的额外开销(压栈、弹栈、跳转、返回)

缺点:

  • 因为代码的扩展,内联函数增大了可执行程序的体积
  • C++ 内联函数的展开是编译阶段,这就意味着如果内联函数发生了改动,那么就需要重新编译代码
  • 当把内联函数放在头文件中时,它将会使头文件信息变多,不过头文件的使用者不用在意这些细节
  • 有时候内联函数并不受到青睐,比如在嵌入式系统中,嵌入式系统的存储约束可能不允许体积很大的可执行程序运行

内联函数的编译限制

C++ 中内联函数编译的限制:

  • 函数体不能过于庞大
  • 不能对函数进行取址操作
  • 不能存在任何形式的循环语句
  • 不能存在过多的条件判断语句
  • 函数的内联声明必须在调用语句之前

编译器对于内联函数的限制并不是绝对的,内联函数相对于普通函数的优势只是省去了函数调用时压栈、弹栈、跳转和返回的开销。因此,当函数体的执行开销远大于压栈、弹栈、跳转和返回所用的开销时,那么内联将变得毫无意义。

什么时候该使用内联函数

当程序设计需要时,每个函数都可以声明为 inline,下面列举一些有用的建议:

  • 当对程序执行性能有要求时,那么就可以使用内联函数
  • 当想使用宏定义一个函数时,那就果断使用内联函数来替代
  • 在类内部定义的函数会默认声明为 inline 函数,这有利于类实现细节的隐藏

关键点:

  • 虚函数不允许内联
  • 所有在类中定义的函数都默认声明为 inline 函数,所有不用再显示地去声明 inline
  • 虽然说模板函数放中头文件中,但它们不一定是内联的(不是说定义在头文件中的函数都是内联函数)
  • C++ 编译器会直接将编译后的内联函数体插入到调用的地方,内联函数在最终生成的代码中是没有定义的
  • 内联函数由编译器处理,直接将编译后的内联函数体插入到调用的地方;而宏定义由预处理器处理,只进行简单的文本替换,没有任何编译过程
  • 一些现代的 C++ 编译器提供了扩展语法,能够对函数进行强制内联,例如: g++ 中的 __attribute__((always_inline)) 属性
  • 编译器的内联看起来就像是代码的复制与粘贴,但这与预处理宏是很不同的;宏定义函数是强制的内联展开,可能将会污染所有的命名空间与代码,会为程序的调试带来困难
  • 内联声明只是一种对编译器的建议,编译器是否采用内联措施由编译器自己来决定。现代 C++ 编译器能够进行编译优化,甚至在汇编阶段或链接阶段,一些没有 inline 声明的函数,也可能被编译器内联编译

函数默认参数

C++ 中可以在函数声明时为参数提供一个默认值,当函数调用时没有指定这个参数的值,编译器会自动用默认值代替。函数默认参数的使用规则如下:

  • 只有参数列表后面部分的参数才可以提供默认参数值
  • 一旦在一个函数调用中开始使用默认参数值,那么这个参数后的所有参数都必须使用默认参数值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>

using namespace std;

void funcA(int x = 3) {
printf("x: %d\n", x);
}

void funcB(int a, int b, int y = 4, int z = 5) {
printf("a: %d, b: %d, y: %d, z: %d\n", a, b, y, z);
}

int main() {
funcA();
funcA(6);
funcB(1, 2);
funcB(1, 2, 3, 4);
return 0;
}

程序运行的输出结果如下:

1
2
3
4
x: 3
x: 6
a: 1, b: 2, y: 4, z: 5
a: 1, b: 2, y: 3, z: 4

函数占位参数

函数占位参数只有参数类型声明,而没有参数名声明;一般情况下,在函数体内部无法使用占位参数。

1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>

using namespace std;

int func(int a, int b, int) {
return a + b;
}

int main() {
printf("func(1, 2, 3) = %d\n", func(1, 2, 3));
return 0;
}

程序运行的输出结果如下:

1
func(1, 2, 3) = 3

函数默认参数结合函数占位参数

可以将函数默认参数与函数占位参数结合起来使用,其意义在于为以后程序的扩展留下空间,并兼容 C 语言代码中可能出现的不规范写法。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>

using namespace std;

void func(int a, int b, int = 0) {
printf("a + b = %d\n", a + b);
}

int main() {
func(1, 2);
func(1, 2, 3);
return 0;
}

程序运行的输出结果如下:

1
2
a + b = 3
a + b = 3

函数重载

函数重载的概念

函数重载概念(Function Overload):

  • 用同一个函数名定义不同的函数
  • 当函数名和不同的参数搭配时函数的含义不同

函数重载至少满足下面的一个条件(函数重载的判断标准):

  • 参数个数不同
  • 参数类型不同
  • 参数顺序不同

特别注意: 函数的返回值不是函数重载的判断标准

函数重载的使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <iostream>
#include <string.h>

using namespace std;

int func(int x) {
return x;
}

int func(int a, int b) {
return a + b;
}

int func(const char *s) {
return strlen(s);
}

int main() {
int c = 0;

c = func(1);
printf("c = %d\n", c);

c = func(1, 2);
printf("c = %d\n", c);

c = func("12345");
printf("c = %d\n", c);

return 0;
}

程序运行的输出结果如下:

1
2
3
c = 1
c = 3
c = 5

函数重载的调用准则

编译器调用重载函数的准则:

  • 将所有同名函数作为候选者
  • 尝试寻找可行的候选函数
  • 精确匹配实参
    • 通过默认参数能够匹配实参
    • 通过默认类型转换匹配实参
  • 匹配失败
    • 最终寻找到的可行候选函数不唯一,则出现二义性,编译失败
    • 无法匹配所有候选者,函数未定义,编译失败

函数重载的注意事项:

  • 重载函数的函数类型是不同的
  • 函数重载是发生在一个类中里面的
  • 函数的返回值不能作为函数重载的依据
  • 函数重载是由函数名和参数列表决定的
  • 重载函数在本质上是相互独立的不同函数

函数重载与函数指针

当使用重载函数名对函数指针进行赋值时:

  • 根据重载规则挑选与函数指针参数列表一致的候选者
  • 严格匹配候选者的函数类型与函数指针的函数类型
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <iostream>
#include <string.h>

using namespace std;

int func(int x) {
return x;
}

int func(int a, int b) {
return a + b;
}

int func(const char *s) {
return strlen(s);
}

// 第一种写法:声明函数类型
typedef int (FUNC)(int a);

// 第二种写法:声明函数指针类型
typedef int(*PFUNC)(int a, int b);

int main() {
// 根据上面的第一种写法,定义函数指针类型的变量
FUNC *FUNC = func;
int c = FUNC(1);
printf("c = %d\n", c);

// 根据上面的第二种写法,定义函数指针类型的变量
PFUNC p = func;
int d = p(3, 4);
printf("d = %d\n", d);
return 0;
}

程序运行的输出结果如下:

1
2
c = 1
d = 7

函数重载与函数默认参数

当函数重载遇上函数默认参数时,如果代码存在二义性,那么 C++ 编译器会编译失败,示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>

using namespace std;

int func(int a, int b, int c = 0) {
return a * b * c;
}

int func(int a, int b) {
return a + b;
}

int func(int a) {
return a;
}

int main() {
int c = 0;
// c = func(1, 2); // 存在二义性,调用失败,编译不能通过
printf("c = %d\n", c);
return 0;
}