C++ 入门基础之三
大纲
C 语言中的宏函数
宏函数和普通函数的区别
宏函数的定义
- 宏函数的定义语法:
#define 宏名(a, b, c, ...) a + b * c
- 如果宏函数后面的代码有多行,可以使用大括号包裹,如
#define 宏名(a, b, c, ...) {代码1; 代码2; ...}
- 特别注意,宏函数后面的代码不能直接换行,如果代码确实太长,可以使用续行符
\
换行,如下所示:
1 |
特别注意
- 宏函数不是真正的函数,而是带参数的宏,只是使用方式像函数而已。
- 在代码中使用宏函数,编译器进行预处理时会经历两次替换,第一次把宏函数替换成它后面的一串代码、表达式,第二次把宏函数中的参数替换到表达式中。
宏函数的优缺点
宏函数的优点:
- 执行速度快,它不是真正的函数调用,而是在预处理阶段简单地替换代码,不会有函数调用、返回的额外开销。
- 宏函数可以实现一些函数实现不了的操作,比如把参数直接转换成字符串,连接两个标识符等。
- 编译器不会检查参数的类型,因此通用性更强。
宏函数的缺点:
- 不方便调试代码,由于宏函数的替换在预处理阶段已经完成,因此在调试代码时,调试的是最终生成的可执行程序。由于可执行程序的代码已经完成了替换,因此看到的代码(未替换)和调试的代码(已替换)是不一样的。
- 由于宏函数不是真正的函数调用,而是在预处理阶段简单地替换代码,每使用一次,就会替换出一份代码,会造成代码冗余、编译速度变慢、可执行文件变大的问题。
- 使用宏函数时,代码是在对应的位置直接替换,如果该位置周围有其他操作符,有可能干扰宏体内的操作符的执行顺序,导致代码执行后产生错误的结果。
- 宏函数没有作用域的概念,无法作为一个类的成员函数,也就说宏函数无法表示类的范围。
- 没有返回值,但可以有执行结果。
- 类型检查不严格,安全性低。
- 无法进行递归调用。
普通函数的优缺点
普通函数的优点:
- 不存在代码冗余的情况,函数的代码只会在代码段中存储一份,使用时跳转过去执行,执行结束后再返回,还可以附加返回值。
- 安全性高,编译器会对参数进行类型检查。
- 可以进行递归调用。
普通函数的缺点:
- 执行速度较慢,函数调用时需要在栈空间上开辟一块栈帧,参数还要压栈。当函数体的代码执行完后,需要返回时,还要销毁栈帧,这些操作都会耗费大量的时间。
- 类型专用,形参是什么类型,实参就必须是什么类型,无法通用。
宏函数的适用场景
什么样的代码适合封装成宏函数?
- 执行次数多
- 对返回值没有要求
- 代码量少(逻辑简单),即使多次使用也不会造成代码段过度冗余
设计宏函数时要注意哪些问题?
- 末尾不要加分号
- 多加小括号防止产生二义性
- 不要使用自加、自减的变量给宏函数提供参数。
宏函数的使用案例
使用案例一
使用宏函数封装一个 MAX
功能,实现求两个数的最大值。
1 |
|
程序运行的输出结果如下:
1 | a = 20 |
使用案例二
使用宏函数封装一个 MY_MALLOC
、MY_FREE
功能,实现内存的申请和释放。
1 |
|
程序运行的输出结果如下:
1 | main.c main 18 申请了 10 字节的内存,地址是 0xd81260 |
C++ 对 C 语言的函数扩展
内联函数
什么是内联函数
在 C 语言中,使用宏函数这种借助编译器的优化技术来减少程序的执行时间,那么在 C++ 中有没有相同的技术或者更好的实现方法呢?答案是有的,那就是内联函数。内联函数作为编译器优化手段的一种技术,在降低程序运行时间上非常有用。C++ 的内联函数通常与类一起使用。内联函数具有普通函数的所有行为,唯一不同的是如果一个函数是内联的,那么在编译时,编译器会把该函数的代码副本放置在每个调用该函数的地方,所以不会产生函数调用的开销。对内联函数进行任何修改,都需要重新编译函数的所有客户端,因为编译器需要重新更换一次所有的代码,否则将会继续使用旧的函数。如果想把一个函数定义为内联函数,则需要在函数名前面放置关键字 inline
,且必须在调用函数之前对函数进行定义。所有在类中定义的函数都是内联函数,即使没有使用 inline
关键字声明。当内联函数收到编译器的指示时,即可发生内联:编译器将使用函数的定义体来替代函数调用语句,这种替代行为发生在编译阶段而非程序运行阶段。值得一提的是,内联函数仅仅是对编译器的内联建议,编译器是否觉得采取建议取决于函数是否符合内联的有利条件。如何函数体非常大,那么编译器将忽略函数的内联声明,而将内联函数作为普通函数处理。实际上,内联函数会占用更多的磁盘空间,但是内联函数相对于普通函数的优势在于省去了函数调用时的压栈、弹栈、跳转、返回的开销,可以理解为空间换时间。
为什么要使用内联函数
有时候我们会写一些功能专一的函数,这些函数的函数体不大,包含了很少的执行语句。例如在计算 1~1000 以内的素数时,我们经常会使用开方操作使运算范围缩小,这时我们会写如下一个函数:
1 | int root(int n) |
然后求范围内素数的函数可以这样写:
1 | int prime(int n) |
当然,把 root
函数放在循环中不是个不明智的选择,但想象一下,在某个程序上下文内必须频繁地调用某个类似 root
的函数,其调用函数的花销会有多大:当遇到普通函数的调用指令时,程序会保存当前函数的执行现场,将函数中的局部变量以及函数地址压入栈,然后再将即将调用的新函数加载到内存中,这要经历复制参数值、跳转到所调用函数的内存位置、执行函数代码、存储函数返回值等过程;当函数执行完后,再获取之前正在调用的函数的地址,回去继续执行那个函数,运行时间开销简直太多了。为了解决上述问题,C++ 内联函数提供了替代函数调用的方案,通过 inline
声明,编译器首先在函数调用处使用函数体本身语句替换了函数调用语句,然后编译替换后的代码。因此,通过内联函数,编译器不需要跳转到内存其他地址去执行函数调用,也不需要保留函数调用时的现场数据。
如何使用内联函数
如果想把一个函数定义为内联函数,则需要在函数名前面放置关键字 inline,且必须在调用函数之前对函数进行定义。
1 | // 宏函数的定义(C语言里常用) |
程序运行的输出结果如下:
1 | Max (20, 10): 20 |
内联函数的优缺点
优点:
- 通过将函数声明为内联,就可以把函数定义放在头文件内
- 它避免了普通函数调用时的额外开销(压栈、弹栈、跳转、返回),提高了程序的运行速度
缺点:
- 因为代码的扩展,内联函数增大了可执行程序的体积
- C++ 内联函数的展开是编译阶段,这就意味着如果内联函数发生了改动,那么就需要重新编译代码
- 当把内联函数放在头文件中时,它将会使头文件信息变多,不过头文件的使用者不用在意这些细节
- 有时候内联函数并不受到青睐,比如在嵌入式系统中,嵌入式系统的存储约束可能不允许体积很大的可执行程序运行
内联函数的编译限制
C++ 对内联函数的编译(即内联编译)有一些限制,以下情况编译器可能不会考虑对函数进行内联编译:
- 函数体不能过于庞大
- 不能对函数进行取址操作
- 不能存在任何形式的循环语句
- 不能存在过多的条件判断语句
特别注意
- 编译器对于内联函数的限制并不是绝对的,内联函数相对于普通函数的优势只是省去了函数调用时压栈、弹栈、跳转和返回的开销。因此,当函数体的执行开销远大于压栈、弹栈、跳转和返回所用的开销时,那么内联函数将变得毫无意义。
- 内联声明只是一种对编译器的建议,编译器是否采用内联措施由编译器自己来决定。现代 C++ 编译器能够进行编译优化,甚至在汇编阶段或链接阶段,一些没有
inline
声明的函数,也可能被编译器内联编译。
内联函数的使用注意事项
在普通函数(非类的成员函数)前面加上 inline
关键字后,可以使其成为内联函数,但是函数体和声明必须结合在一起,否则编译器只会将它作为普通函数来对待。
1 | inline void add(int a, int b); |
以上写法没有任何效果,仅仅是声明普通函数,编译器不会将它作为内联函数来对待,正确的写法如下:
1 | inline void add(int a, int b) { |
什么时候该使用内联函数
当程序设计需要时,每个函数都可以声明为 inline
,下面列举一些有用的建议:
- 当对程序执行性能有要求时,那么就可以使用内联函数
- 当想使用宏定义一个函数(宏函数)时,那就可以果断使用内联函数来替代
- 在类内部定义的函数会默认声明为
inline
函数,这有利于类实现细节的隐藏
使用内联函数时,特别值得关注的几点细节:
- 虚函数不允许内联
- 所有在类中定义的函数都默认声明为
inline
函数,所有不用再显示地去声明inline
- 虽然说模板函数放中头文件中,但它们不一定是内联的(不是说定义在头文件中的函数都是内联函数)
- C++ 编译器会直接将编译后的内联函数体插入到调用的地方,内联函数在最终生成的代码中是没有定义的
- 内联函数由编译器处理,直接将编译后的内联函数体插入到调用的地方;而宏定义由预处理器处理,只进行简单的文本替换,没有任何编译过程
- 一些现代的 C++ 编译器提供了扩展语法,能够对函数进行强制内联,例如:
g++
中的__attribute__((always_inline))
属性 - 编译器的内联看起来就像是代码的复制与粘贴,但这与预处理宏是很不同的;宏函数是强制的内联展开,可能将会污染所有的命名空间与代码,会为程序的调试带来困难
- 内联声明只是一种对编译器的建议,编译器是否采用内联措施由编译器自己来决定。现代 C++ 编译器能够进行编译优化,甚至在汇编阶段或链接阶段,一些没有
inline
声明的函数,也可能被编译器内联编译
函数默认参数
C++ 中可以在函数声明时为参数提供一个默认值,当函数调用时没有指定这个参数的值,编译器会自动用默认值代替。值得一提的是,C 语言是不支持函数默认参数的。函数默认参数的使用规则如下:
- 只有参数列表后面部分的参数才可以提供默认参数值
- 一旦在一个函数调用中开始使用默认参数值,那么这个参数后的所有参数都必须使用默认参数值
- 一旦在函数声明里面有了默认参数,那么函数实现的时候不能再有默认参数,反之亦然。也就是说,函数声明和函数实现两者只能有一个允许有函数默认参数
1 |
|
程序运行的输出结果如下:
1 | x = 3 |
函数占位参数
在 C++ 中,函数占位参数只有参数类型声明,而没有参数名声明。一般情况下,在函数体内部无法使用占位参数。值得一提的是,C 语言是不支持函数占位参数的。
1 |
|
程序运行的输出结果如下:
1 | 3 |
另外,还可以将函数默认参数与函数占位参数结合起来使用,其意义在于为以后程序的扩展留下空间,并兼容 C 语言代码中可能出现的不规范写法。
1 |
|
程序运行的输出结果如下:
1 | sum = 3 |
函数重载
函数重载的概念
函数重载概念(Function Overload):
- 用同一个函数名定义不同的函数
- 当函数名和不同的参数搭配时函数的含义不同
函数重载至少满足下面的一个条件(函数重载的判断标准):
- 同一个作用域
- 参数个数不同
- 参数类型不同
- 参数顺序不同
特别注意
函数的返回值不是函数重载的判断标准
函数重载的使用
1 |
|
程序运行的输出结果如下:
1 | c = 1 |
函数重载的准则
编译器调用重载函数的准则
- 将所有同名函数作为候选者
- 尝试寻找可行的候选函数
- 精确匹配实参
- 通过默认参数能够匹配实参
- 通过默认类型转换匹配实参
- 匹配失败
- 最终寻找到的可行候选函数不唯一,则出现二义性,编译失败
- 无法匹配所有候选者,函数未定义,编译失败
函数重载的使用注意事项
- 重载函数的函数类型是不同的
- 函数重载是发生在一个类中里面的
- 函数的返回值不能作为函数重载的依据
- 函数重载是由函数名和参数列表决定的
- 重载函数在本质上是相互独立的不同函数
函数重载与引用
当函数重载遇上引用时,const
关键字也是可以作为函数重载的条件,示例代码如下:
1 |
|
程序运行的输出结果如下:
1 | sum = 13 |
函数重载与函数指针
当使用重载函数名对函数指针进行赋值时:
- 根据函数重载规则挑选与函数指针参数列表一致的候选者
- 严格匹配候选者的函数类型与函数指针的函数类型
1 |
|
程序运行的输出结果如下:
1 | c = 1 |
函数重载与函数默认参数
当函数重载遇上函数默认参数时,如果代码存在二义性,那么 C++ 编译器会编译失败,示例代码如下:
1 |
|
函数重载的底层实现原理
编译器为了实现函数重载,也是默认为我们做了一些幕后的工作。编译器使用不同的参数类型来修饰不同的函数名称,比如 void func()
,编译器可能会将函数名称修饰成 _func
,当编译器碰到 void func(int x)
编译器可能将函数名称修饰为 _func_int
,当编译器碰到 void func(int x, char c)
编译器可能会将函数名称修饰为 _func_int_char
。这里使用 “可能” 这个字眼是因为编译器如何修饰重载的函数名称并没有一个统一的标准,所以不同的编译器可能会产生不同的内部名称。
1 | void func() {} |
以上三个函数在 Linux 下生成的编译之后的函数名称为:
1 | _Z4funcv // v 代表 void,无参数 |
C++ 中 extern 关键字的浅析
在 C++ 中使用 extern
关键字,可以解决 C++ 调用 C 语言函数的问题。
第一种使用方式
- sub.h 源文件
1 |
|
- sub.c 源文件
1 |
|
- main.cpp 源文件
1 |
|
第二种使用方式
上述的第一种使用方式,如果 C++ 需要调用多个在其他 C 语言源文件里定义的函数,那么就会有多行 extern "C" xxxx
的声明,这样会显得很累赘,因此可以使用宏的方式来处理,示例代码如下:
- sub.h 源文件
1 |
|
- sub.c 源文件
1 |
|
- sub.cpp 源文件
1 |
|