一、前言
大家好,我是 a n d u i n anduin anduin . 这篇文章是 C++ 入门的最后一篇。内容主要为 内联函数、auto、范围 for 循环和 nullptr 空指针 ,对于后三个都是 C++11 的特性。内容不多,也不难,让我们开始学习吧!
二、内联函数
调用函数需要建立栈帧,栈帧中要保存寄存器,结束后就要恢复,这其中都是有 消耗 的:
int add(int x, int y) { int ret = x + y; return ret; } int main() { add(1, 2); add(1, 2); add(1, 2); add(1, 2); add(1, 2); return 0; }
而针对 频繁调用 的 小函数,可以用 宏 优化,因为宏是在预处理阶段完成替换的,并没有执行时的开销,并且因为代码量小,也不会造成代码堆积。
例如,代码就可以写成这样:
#define ADD(x, y) ((x) + (y)) int main() { cout << ADD(1, 2) << endl; return 0; }
但是宏也有缺点:
不能调试
没有类型安全的检查
有些场景下非常复杂
就拿 add 函数来说,可能一不小心就会写成 #define ADD(x + y) x + y 的样子;所以写宏时出错,要么是替换出错,要么是因为优先级出错,所以宏并不友好。
而 C++ 针对为了减少函数调用开销,又可以在一定程度上替代宏,避免宏的出错,从而设计出了内联函数 。
内联函数的关键字为 inline :
inline int add(int x, int y) { int ret = x + y; return ret; } int main() { int ret = add(1, 2); cout << ret << endl; return 0; }
1、概念
在 release 版本下,inline 内联函数会直接在调用部分展开;对于 debug 则需要 主动设置 (debug 下编译器默认不对代码做优化);但是 release 版本下其他版本优化的太多,可能就不太好观察,所以我们设置一下编译器,在 debug 下看:
打开解决方案资源管理器,右击项目名称,选中属性并打开,在 C/C++ 区域常规部分,在调试信息一栏设置格式为程序数据库:
在 C/C++ 优化一栏,将内联函数扩展部分选中只适用于 _inline :
设置完毕后,点击应用。
在设置前、后,分别启动调试,查看反汇编代码:
修改前:
修改后:
两段反汇编代码最大的区别就是 call 消失了 ,call 就是函数调用的指令,它的消失就说明第二段代码没有进行调用。内联函数直接在局部展开了,在 main 函数中完成了操作。有了内联,我们就不需要去用 c 的宏了,因为宏很容易出错。
2、特性
inline是一种以 空间换时间 的做法,如果编译器将函数当成内联函数处理,在编译阶段,会用函数体替换函数调用,缺陷:可能会使目标文件变大,优势:少了调用开销,提高程序运行效率。
inline对于编译器而言只是一个建议,不同编译器关于inline实现机制可能不同,一般建议:将函数规模较小(即函数不是很长,具体没有准确的说法,取决于编译器内部实现)、不是递归、且频繁调用的函数采用inline修饰,否则编译器会忽略inline特性。下图为《C++prime》第五版关于inline的建议:
inline不建议声明和定义分离,分离会导致链接错误。因为inline被展开,就没有函数地址了,链接就会找不到。
要点讲解:
1)空间换时间是因为反复调用内联函数,导致编译出来的可执行程序变大
inline void func() { // 假设编译完成为 10 条指令 }
若不用内联函数,不展开,若1000次调用 func,每次调用的地方为 call 指令的形式,总计 1010 行指令。若用内联函数,则展开,若一千次调用,每次调用的地方为都会展开为 10 条指令,总计 10 * 1000 行指令。
展开会让编译后的程序变大,如果递归函数作内联,后果可想而知。所以长函数和递归函数不适合展开。
2)编译器可以忽略内联请求,内联函数被忽略的界限没有被规定,一般10行以上就被认为是长函数,当然不同的编译器不同
基于上面的解释,所以编译器会决策是否使用内联函数。因为如果函数太大导致的结果很糟糕。
3)内联函数声明和定义不可分离
// F.h #include <iostream> using namespace std; inline void f(int i); // F.cpp #include "F.h" void f(int i) { cout << i << endl; } // main.cpp #include "F.h" int main() { f(10); return 0; } // 链接错误:main.obj : error LNK2019: 无法解析的外部符号 "void __cdecl f(int)" (?f@@YAXH@Z),该符号在函数 _main 中被引用
由于内联函数在调用的地方展开,所以内联函数无地址(这里的地址指的是call 指令调用函数的地址,通过这个地址会跳到 jmp 指令处,再根据 jmp 处指令跳转到函数执行的部分) ,即 f.cpp->f.o 符号表中,不会生成 f 的地址。
当编译时,由于头文件要被包含,但是这时只有函数声明,但是没有函数的定义,所以只能在链接时展开,这里只能变为 call + 地址的指令,但是内联函数并没有地址,链接不到,就报错了。
所以当声明和定义分离,调用函数时,由于内联函数无地址,编译器链接不到,所以就会报错,为链接错误。
结论:简短,频繁调用的小函数建议定义成 inline .
三、auto(C++ 11)
1、概念
在 c 语言中,也存在 auto 关键字。
int main() { auto int a = 0; }
auto 关键字修饰后,a变为自动存储类型,即变量会在函数结束以后自动销毁。但是这个语法完全多此一举,因为后来,对于局部变量默认就是自动存储类型,当函数结束后也会自动销毁。
于是 C++ 委员会废弃了 auto 的用法,赋予了新的意义:auto不再是一个存储类型指示符,而是作为一个新的类型指示符来指示编译器,auto声明的变量必须由编译器在编译时期推导而得。
int main() { int a = 0; int b = 0; auto c = a; // 自动推导 c 的类型 auto d = 'A'; auto e = 10.11; return 0; }
auto 会自动推导变量的类型。
typeid
可以看对象类型,用法为 typeid(c).name()
,由此可以打印变量的类型:
对于 auto 来说,并不会保留 const 属性,例如 e ,10.11 是常量,就丢弃了 const 属性,而保留 double .
对于 auto ,如果要加上 const 属性,则需要主动加上:
int main() { int x = 10; const auto y = x; cout << typeid(y).name() << endl; // 这里不会打印出,需要调试看 return 0; }
2、价值
auto 具有两种针对场景:
- 类型难于拼写
- 含义不明确导致容易出错
上面的使用仅仅是样例,而它真正的价值体现在 stl 部分 和 范围遍历 中。
在 stl 中,有些类型名字很长,例如:
#include <map> int main() { map<string, string> dict; map<string, string>::iterator it = dict.begin(); return 0; }
当要取其迭代器进行遍历时,很复杂,这时 auto 就派上用场了。
auto it = dick.begin();
它会根据右边的返回值类型,自动推导 it 的类型,写起来十分方便。
对于这里使用 typedef 进行重命名也可以 :
int main() { map<string, string> dict; typedef map<string, string>::iterator Dict; Dict it = dict.begin(); return 0; }
虽然 typedef 可以使用,但是 typedef 有一个缺点,看这个例子:
p1 是失败的 :
因为 typedef 这里不是展开为 const char* p1 ,实际上这里 typedef 之后是 char* const p1 ,const 修饰的是 p1 ,但是 p1 没有初值,之后也改不了了,所以就失败了;但是 p2 展开是 char* const *p2 ,修饰的是 *p2 ,此刻 p2 是能改的,所以没问题。
验证一下:
typedef char* pstring; int main() { const pstring p1 = nullptr; // 需要一个初值 const pstring* p2; }
auto 很灵活,甚至可以在后面附上其他属性,附上属性后,就显示表现它的类型:
auto& c 的意思就是,c 为 x 的别名,但是类型还是 int ,不过显示认为,它就是推导出来类型的别名。
3、三个不能
- auto不能独立定义
- auto不能作为函数的参数
// 此处代码编译失败,auto不能作为形参类型,因为编译器无法对a的实际类型进行推导 void TestAuto(auto a) {}
因为编译器无法推导 auto 的类型,没有根据。
在 C++11 后的版本中,auto 可以作为参数,但是博主主要学习的是 C++98 和 C++11 的版本,所以暂且不考虑。
- auto不能定义数组
void TestAuto() { int a[] = {1,2,3}; auto b[] = {4,5,6}; }
四、范围for循环(C++11)
1、基本使用
之前对于数组的遍历,需要使用下标遍历:
int main() { int arr[] = { 1, 2, 3, 4,5 }; for (int i = 0; i < sizeof(arr) / sizeof(arr[0]); i++) { printf("%d ", arr[i]); } return 0; }
而 C++ 中效仿新语言,加入了范围遍历:
int main() { int arr[] = { 1, 2, 3, 4,5 }; for (auto num : arr) { cout << num << endl; } return 0; }
其中就用到了 auto 关键字,当复杂类型遇到auto和范围遍历,就是天堂。
而范围for循环的原理就是自动取遍历目标的每一个元素,再放到给定的临时变量中。在上方就是取 arr 的元素放到 num 中,并自动判断结束。auto 会根据遍历目标的元素类型自动推导,当然直接写类型 int 也对 。
如果要使用范围遍历修改遍历目标,则可以使用引用 :
for (auto num : arr) { num ++ ;}
若以这种写法,是不会改变的,因为 num 是局部变量,只是值和 arr 中元素相等,本身并没有关联。
但是如果加上引用,意义就变了:取遍历目标每个元素的值给 num ,让 num 变为那些元素的别名 :
而对于 num 的生命周期,则可以认为仅在每次范围遍历中(某一次循环)才存在。
范围 for 会根据遍历目标的元素类型来取出元素,例如上方例子就是 int ,如果这时用指针接收,就是错误的:
因为取出来的每一个元素是 int ,类型不匹配。而判断结束我们并不用担心,其实和普通遍历类似。
2、使用条件
for循环迭代的范围必须是确定的
对于数组的范围就是数组中第一个元素和最后一个元素的范围;对于类而言,应该提供begin和end的方法,begin和end就是for循环迭代的范围。
以下代码就有问题,因为for的范围不确定,因为函数传参,数组就会退化为指针:
void TestFor(int array[]) { for (auto& e : array) { cout << e << endl; } }
是错误的。
五、指针空值nullptr(C++11)
对于 c 来说,空指针为 NULL,是一个宏。
在 C++98/03 时,只能使用 NULL ;而 C++11 后,推荐使用 nullptr 。
NULL
:
#ifndef NULL #ifdef __cplusplus #define NULL 0 #else #define NULL ((void *)0) #endif #endif
实际上 NULL 就是个宏,所以说写成 int* p = 0 ,也可以;而j绝大多数情况下,这样写都没问题。
但是对于极端场景:
void f(int) // 这边由于不使用形参,不给形参名也可以 { cout << "f(int)" << endl; } void f(int*) { cout << "f(int*)" << endl; } int main() { f(0); f(NULL); return 0; }
按道理,对于第一次调用,应该匹配第一个,对于第二次调用,应该匹配第二个。
但是实际上它们都匹配了第一个,原因是 NULL 是一个宏,本质为 0 .
在C++98中,字面常量 0 既可以是一个整形数字,也可以是无类型的指针(void* )常量,但是编译器默认情况下将其看成是一个整形常量,如果要将其按照指针方式来使用,必须对其进行强转(void* )0,例如:(int*)NULL ,所以在 C++11 后,使用 nullptr 是明智的选择。
注意点:
在使用nullptr表示指针空值时,不需要包含头文件,因为nullptr是C++11作为新关键字引入的。
在C++11中,sizeof(nullptr) 与 sizeof((void*)0)所占的字节数相同。
为了提高代码的健壮性,在后续表示指针空值时建议最好使用nullptr。
六、结语
到这里本篇博客就到此结束了。
对于 C++ 杂七杂八的知识点,我们就讲到这里。下一篇文章,我们开始讲解类和对象,做好准备哦!
如果觉得 a n d u i n anduin anduin 写的不错的话,可以点赞 + 收藏 + 评论支持一下哦!
那么我们下期见!