C++ 细节逐步填坑中,还有几个大坑预计 8 月前结束。普通的函数没啥意思了,本文涉及函数的进阶使用,包括:函数的默认参数、内联函数、函数重载和函数模板。
函数原型
也就是某些教材上常说的函数声明,如果没有函数原型,那么函数首次使用前出现的整个函数定义充当了函数原型。函数由三部分组成:
- 函数原型,约定好返回值的类型与接受参数的类型。这就描述了函数到编译器的接口,将参数类型和数量提前告知编译器。这样当函数的返回值放到某个寄存器时,编译器也知道检索多少个字节来解释返回值。如果不告知函数原型,
main
函数的编译只能终止然后去寻找函数原型,这样会导致效率不高,甚至有些文件没有搜索的权限,这样会报错。而C++
的编程风格,习惯将main
函数放在前面,这样更需要函数原型。 - 函数定义,函数头 + 函数体,实现完整的函数功能。
- 函数调用,主函数调用子函数完成功能。
因此,函数原型有以下的作用:
- 正确处理函数的返回值
- 检查参数的数目、类型是否正确;如果不正确,尽可能转换为正确类型
内联函数
常规函数和内联函数的主要区别不在于编写方式不同,更多的是程序组合到程序中的方式不同。
对于普通函数而言,程序在执行到函数调用指令时,存储当前指令的地址(保护现场),将函数参数复制到堆栈帧,跳转到子函数起始的内存地址,执行子函数,执行的临时变量放入堆栈帧。执行完毕后,跳回指令被保存的地址处(恢复现场),继续往下执行。使用子函数会造成来回的记录和跳转,造成一定的开销。
内联函数会代替函数调用,内联函数直接被插入到主函数中,这样就无需跳转而是顺序执行。执行速度快,但是需要更大的内存。
如果函数执行的时间远大于跳转时间,则内联函数的意义不大;如果代码执行时间很短,且需要多次调用,那么内联调用会节省很多时间;如果节省的时间所占执行的时间并不大,或者很少调用,则不需要内联函数。注意,编译器一般不内联包含循环、递归、switch 等复杂操作的内联函数。
相对于宏展开,了类型检查,真正具有函数特性。
在类声明中定义的函数,除了虚函数的其他函数都会自动隐式地当成内联函数。因此内联函数可以访问类的成员变量,宏定义则不能。
1 |
|
与虚函数
内联可以修饰虚函数,因为内联是在编译期由编译器内联,而虚函数的多态性在运行期,编译器无法知道运行期调用哪个代码,因此虚函数表现为多态性时(运行期)不可以内联。虚函数与多态可以参考之前的文章。但是,inline virtual
唯一可以内联的时候是:编译器知道所调用的对象是哪个类(如 Base::method()
),这只有在编译器具有实际对象而不是对象的指针或引用时才会发生。
默认参数
这个倒是不难,就是为一些参数提供默认值。如果一个参数有默认值,那么,它右边的参数必须也要有默认值,且赋值的时候不允许跳过。按照 main
函数放前面这样的编程风格来试一下,默认值在函数原型中提供,函数定义不需要,否则报错。
1 |
|
函数重载
对于一个打印函数 print
,可能传入 int
类型的数据,也可能传入 double
类型的数据,这个时候就需要函数重载。函数重载的重点是函数的特征标,也就是函数的参数列表,也就是参数的数目、类型和排列顺序。比如可以这样重载:
1 |
|
但是,如果调用函数出现了未匹配的类型,很可能错误,如:
1 | unsigned int a{12}; |
double
和 int
都可以接受 unsigned int
的参数,二义性的程序会导致错误。
const 重载
const
可以构成重载,不过只能是指针,非指针不构成重载。这也很容易理解,对于非指针而言,const
或非 const
都不重要,因为原数据无法修改,因此不构成重载;指针则不一样,const
表示原数据或指针不修改,非 const
表示原数据或指针任意修改。这是两个含义的特征标,因此可以构成重载。而编译器根据实参是否为 const
来决定匹配的原型函数。
1 |
|
对于 void print(const int* a)
这样的函数,如果没有重载,那么这个函数是可以接收非 const
数据的。
此外,对于没有任何参数的函数,且不希望函数修改任何变量,可以将
const
关键字放到函数括号的后面。
1 | // 错误 |
引用重载
引用无法构成重载,因为无论是否引用,都可以接收参数:
1 | // 错误 |
但是引用加上 const
,含义改变,就可以重载。而对于引用的重载,调用最为匹配的版本:
1 | void print(double& x) |
如何实现重载
C++
通过名称修饰来跟踪重载函数,根据函数原型的函数特征标对函数进行加密。也就是根据特征标对函数进行编码,在函数上添加一组符号后,函数换了个名字作为自己的内部表示,不同特征标的函数名也不一样,不过使用者看不到这一层。具体如何修饰,这取决于编译器。
重载诱人,但使用时一定要注意类型,只有用相同的形式处理不同类型的数据,才会考虑重载。
函数模板
模板比重载还要更省事一点。使用泛型来定义函数,也就是,类型作为参数传递给模板代码,编译器生成指定类型的函数。也就是说,模板通过泛型(参数化类型)来解决任务。使用背景一般是:同一算法需要处理多种类型的参数。
重载也可以完成这些任务,比如说要交换两个同类型的数,int, double, float, const, char, str, vector
等等等等,重载可以,但是写很多遍会很累。
模板例子:使用 template <typename T>
来建立模板,编译器检查传入的类型参数,生成相应的函数以供执行。程序员看不到生成的代码,但代码确实被生成以及被使用。且最终生成的代码不包含模板,只包含为程序生成的实际代码。如下所示的模板,交换任意简单类型的数据:
1 |
|
一般而言,对不同类型使用相同算法会考虑模板。但是,不是所有类型用相同的算法都能实现,比如,对象、数组等会涉及深浅拷贝、地址等,并不像简单数据类型那样容易处理。
举个例子,以交换函数而言,如果是数值类型,就交换;如果是数组类型,交换前 2 个元素;如果是类,有的成员交换有的成员不交换。总之,模板具有局限性,判断相等时,数组不能直接用等号。所以编写的模板很可能无法处理某些类型,大概有两种解决方案:
- 在类中重载运算符,如大小、相等的比较;
- 为特定类型提供具体化的模板定义
但是这部分坑准备留在类的重载运算符、移动语义和深浅拷贝之后了,方便对比。
模板重载
如果重载模板,函数的特征标同样不能相同。注意,泛型并不是所有参数都得是模板参数类型:
1 |
|
模板的发展
在 C++98
中,编写模板函数时会一个问题,不知道该声明为哪一种类型:
1 | template<typename T1, typename T2> |
上述代码中的 z
是什么类型呢?而 C++11
新增的关键字 decltype
提供了解决方案,按照给定的 expression
类型创建指定类型的变量,即 decltype (x) y
,y
和 x
同类型。
1 |
|
那么上述模板代码就有了解决方案。而 decltype (expr) var
为确定 var
的类型,遍历一个核对表,只要有一项匹配,那么类型确定完毕,不用在判断后面的。
expr
是一个没有括号标识符,那么var
与expr
相同;expr
是一个函数,var
与函数返回值类型相同;- 如果
expr
是一个左值,var
为expr
类型的引用,以double
为例,decltype ((x)) y
,y
就是double
类型的引用; - 如果不满足以上,那么
expr
与var
同类型,如int& x, int& y, decltype (x+y) z
,z
是int
类型,不是引用类型;
但是尽管解决了函数中对变量类型的赋值,但是没有解决模板返回值的问题:
1 | template <typename T1, typename T2> |
函数的返回值类型和 T1
和 T2
相关,但是要运算后才知道。但是返回值区域, x,y
还不在作用域内就无法使用,这就成了先有鸡还是先有蛋的问题,那么如何提前知道运算结果的类型呢?即使在函数内部知道了返回值类型,也没办法反馈到函数的声明中。
这个可以通过后置返回值类型 (tailing return type) 可以实现,写法:auto f1(int x, float y) -> double
,对应到函数声明,写法如下:
1 | template <typename T1, typename T2> |