0%

C++函数进阶:内联、重载和模板

C++ 细节逐步填坑中,还有几个大坑预计 8 月前结束。普通的函数没啥意思了,本文涉及函数的进阶使用,包括:函数的默认参数、内联函数、函数重载和函数模板。

函数原型

也就是某些教材上常说的函数声明,如果没有函数原型,那么函数首次使用前出现的整个函数定义充当了函数原型。函数由三部分组成:

  • 函数原型,约定好返回值的类型与接受参数的类型。这就描述了函数到编译器的接口,将参数类型和数量提前告知编译器。这样当函数的返回值放到某个寄存器时,编译器也知道检索多少个字节来解释返回值。如果不告知函数原型,main 函数的编译只能终止然后去寻找函数原型,这样会导致效率不高,甚至有些文件没有搜索的权限,这样会报错。而 C++ 的编程风格,习惯将 main 函数放在前面,这样更需要函数原型。
  • 函数定义,函数头 + 函数体,实现完整的函数功能。
  • 函数调用,主函数调用子函数完成功能。

因此,函数原型有以下的作用:

  • 正确处理函数的返回值
  • 检查参数的数目、类型是否正确;如果不正确,尽可能转换为正确类型

内联函数

常规函数和内联函数的主要区别不在于编写方式不同,更多的是程序组合到程序中的方式不同。

  • 对于普通函数而言,程序在执行到函数调用指令时,存储当前指令的地址(保护现场),将函数参数复制到堆栈帧,跳转到子函数起始的内存地址,执行子函数,执行的临时变量放入堆栈帧。执行完毕后,跳回指令被保存的地址处(恢复现场),继续往下执行。使用子函数会造成来回的记录和跳转,造成一定的开销。

  • 内联函数会代替函数调用,内联函数直接被插入到主函数中,这样就无需跳转而是顺序执行。执行速度快,但是需要更大的内存。

如果函数执行的时间远大于跳转时间,则内联函数的意义不大;如果代码执行时间很短,且需要多次调用,那么内联调用会节省很多时间;如果节省的时间所占执行的时间并不大,或者很少调用,则不需要内联函数。注意,编译器一般不内联包含循环、递归、switch 等复杂操作的内联函数。

  • 相对于宏展开,了类型检查,真正具有函数特性。

  • 在类声明中定义的函数,除了虚函数的其他函数都会自动隐式地当成内联函数。因此内联函数可以访问类的成员变量,宏定义则不能。

1
2
3
4
5
6
7
8
9
10
11
#include <iostream>
using namespace std;
inline double sqrt(double x){
return x * x;
}
int main (){
double a{12.3};
double b = sqrt(a);
cout << b;
return 0;
}

与虚函数

内联可以修饰虚函数,因为内联是在编译期由编译器内联,而虚函数的多态性在运行期,编译器无法知道运行期调用哪个代码,因此虚函数表现为多态性时(运行期)不可以内联。虚函数与多态可以参考之前的文章。但是,inline virtual 唯一可以内联的时候是:编译器知道所调用的对象是哪个类(如 Base::method()),这只有在编译器具有实际对象而不是对象的指针或引用时才会发生。

默认参数

这个倒是不难,就是为一些参数提供默认值。如果一个参数有默认值,那么,它右边的参数必须也要有默认值,且赋值的时候不允许跳过。按照 main 函数放前面这样的编程风格来试一下,默认值在函数原型中提供,函数定义不需要,否则报错。

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

void show_info(int, int, int a=1, int b=2, int c=3);

int main (){
int d{12}, e{13};
int x{11};
show_info(d, e, x);
}
void show_info(int d, int e, int a, int b, int c) {
cout << a << " " << b << " " << c;
}

函数重载

对于一个打印函数 print ,可能传入 int 类型的数据,也可能传入 double 类型的数据,这个时候就需要函数重载。函数重载的重点是函数的特征标,也就是函数的参数列表,也就是参数的数目、类型和排列顺序。比如可以这样重载:

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

void print(const char* str, int t) {
cout << str << endl;
}

void print(double str, int t) {
cout << str << endl;
}

void print(int str, int t) {
cout << str << endl;
}

int main (){
int a{10};
double b{1.23};
const char* str = "void";
print(str, 1);
print(a, 1);
print(b, 1);
return 0;
}

但是,如果调用函数出现了未匹配的类型,很可能错误,如:

1
2
unsigned int a{12};
print(a, 1);

doubleint 都可以接受 unsigned int 的参数,二义性的程序会导致错误。

const 重载

const 可以构成重载,不过只能是指针,非指针不构成重载。这也很容易理解,对于非指针而言,const 或非 const 都不重要,因为原数据无法修改,因此不构成重载;指针则不一样,const 表示原数据或指针不修改,非 const 表示原数据或指针任意修改。这是两个含义的特征标,因此可以构成重载。而编译器根据实参是否为 const 来决定匹配的原型函数。

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

void print(int* a) {
cout << *a << "---" << endl;
}

void print(const int* a) {
cout << *a << "===" << endl;
}

int main (){
int p{1};
int* a = &p;
*a = 2;
const int* b = &p;
print(a);
print(b);
return 0;
}

对于 void print(const int* a) 这样的函数,如果没有重载,那么这个函数是可以接收非 const 数据的。

此外,对于没有任何参数的函数,且不希望函数修改任何变量,可以将 const 关键字放到函数括号的后面。

1
2
3
4
5
// 错误
int show(int a) const {
a++;
return a;
}

引用重载

引用无法构成重载,因为无论是否引用,都可以接收参数:

1
2
3
// 错误
void print(double x)
void print(double& x)

但是引用加上 const,含义改变,就可以重载。而对于引用的重载,调用最为匹配的版本:

1
2
3
4
5
6
7
8
9
10
void print(double& x)
void print(const double& x)
// 右值引用,没有的话就调用 print(const double& x)
void print(double&& x)

double x{33.3};
const double y{12.3};
print(x); // print(double& x)
print(y); // print(const double& x)
print(x + y); // print(double&& x)

如何实现重载

C++ 通过名称修饰来跟踪重载函数,根据函数原型的函数特征标对函数进行加密。也就是根据特征标对函数进行编码,在函数上添加一组符号后,函数换了个名字作为自己的内部表示,不同特征标的函数名也不一样,不过使用者看不到这一层。具体如何修饰,这取决于编译器。

重载诱人,但使用时一定要注意类型,只有用相同的形式处理不同类型的数据,才会考虑重载。

函数模板

模板比重载还要更省事一点。使用泛型来定义函数,也就是,类型作为参数传递给模板代码,编译器生成指定类型的函数。也就是说,模板通过泛型(参数化类型)来解决任务。使用背景一般是:同一算法需要处理多种类型的参数。

重载也可以完成这些任务,比如说要交换两个同类型的数,int, double, float, const, char, str, vector 等等等等,重载可以,但是写很多遍会很累。

模板例子:使用 template <typename T> 来建立模板,编译器检查传入的类型参数,生成相应的函数以供执行。程序员看不到生成的代码,但代码确实被生成以及被使用。且最终生成的代码不包含模板,只包含为程序生成的实际代码。如下所示的模板,交换任意简单类型的数据:

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

template <typename T>
void swap(T& a, T& b);

int main() {
double a{1.2}, b{2.1};
swap(a, b);
std::cout << a << " " << b << std::endl;
int c{1}, d{2};
swap(c, d);
std::cout << c << " " << d << std::endl;
return 0;
}

template <typename T>
void swap(T& a, T& b) {
T t;
t = a;
a = b;
b = t;
}

一般而言,对不同类型使用相同算法会考虑模板。但是,不是所有类型用相同的算法都能实现,比如,对象、数组等会涉及深浅拷贝、地址等,并不像简单数据类型那样容易处理。

举个例子,以交换函数而言,如果是数值类型,就交换;如果是数组类型,交换前 2 个元素;如果是类,有的成员交换有的成员不交换。总之,模板具有局限性,判断相等时,数组不能直接用等号。所以编写的模板很可能无法处理某些类型,大概有两种解决方案:

  • 在类中重载运算符,如大小、相等的比较;
  • 为特定类型提供具体化的模板定义

但是这部分坑准备留在类的重载运算符、移动语义和深浅拷贝之后了,方便对比。

模板重载

如果重载模板,函数的特征标同样不能相同。注意,泛型并不是所有参数都得是模板参数类型:

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
36
37
38
#include <iostream>

template <typename T>
void swap(T& a, T& b);

template <typename T>
void swap(T a[], T b[], int i = 2);

int main() {
double a{1.2}, b{2.1};
swap(a, b);
std::cout << a << " " << b << std::endl;
int c[4] = {1, 2, 3, 4};
int d[4] = {9, 8, 7, 6};
swap(c, d);
for (int i = 0; i < 4; i++) {
std::cout << c[i] << " <=> " << d[i] << std::endl;
}
return 0;
}

template <typename T>
void swap(T& a, T& b) {
T t;
t = a;
a = b;
b = t;
}

template <typename T>
void swap(T a[], T b[], int i) {
T t[2];
for (int j = 0; j < i; j++) {
t[j] = a[j];
a[j] = b[j];
b[j] = t[j];
}
}

模板的发展

C++98 中,编写模板函数时会一个问题,不知道该声明为哪一种类型:

1
2
3
4
template<typename T1, typename T2>
void f(T1 x, T2 y) {
z = x + y;
}

上述代码中的 z 是什么类型呢?而 C++11 新增的关键字 decltype 提供了解决方案,按照给定的 expression 类型创建指定类型的变量,即 decltype (x) yyx 同类型。

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

int main() {
double x{12.3};
decltype (x) y;
// d
std::cout << typeid(y).name() << std::endl;
return 0;
}

那么上述模板代码就有了解决方案。而 decltype (expr) var 为确定 var 的类型,遍历一个核对表,只要有一项匹配,那么类型确定完毕,不用在判断后面的。

  1. expr 是一个没有括号标识符,那么 varexpr 相同;
  2. expr 是一个函数,var 与函数返回值类型相同;
  3. 如果 expr 是一个左值,varexpr 类型的引用,以 double 为例, decltype ((x)) yy 就是 double 类型的引用;
  4. 如果不满足以上,那么 exprvar 同类型,如 int& x, int& y, decltype (x+y) zzint 类型,不是引用类型;

但是尽管解决了函数中对变量类型的赋值,但是没有解决模板返回值的问题:

1
2
template <typename T1, typename T2>
? gt (T1 x, T2 y)

函数的返回值类型和 T1T2 相关,但是要运算后才知道。但是返回值区域, x,y 还不在作用域内就无法使用,这就成了先有鸡还是先有蛋的问题,那么如何提前知道运算结果的类型呢?即使在函数内部知道了返回值类型,也没办法反馈到函数的声明中。

这个可以通过后置返回值类型 (tailing return type) 可以实现,写法:auto f1(int x, float y) -> double,对应到函数声明,写法如下:

1
2
template <typename T1, typename T2>
auto f1(T1 x, T2 y) -> decltype(x + y)
感谢上学期间打赏我的朋友们。赛博乞讨:我,秦始皇,打钱。

欢迎订阅我的文章