1.引用
1.1引用的概念
引用不是定义一个变量,而是已存在的变量取了一个别名,编译器不会为引用变量开辟内存空间,它和它引用的变量共用同一块内存空间。
其使用的基本形式为:类型& 引用变量名(对象名) = 引用实体。
#include <iostream> using namespace std; int main() { int a = 10; int& b = a;//给变量a去了一个别名,叫 b cout << "a = " << a << endl;//a打印结果为10 cout << "b = " << b << endl;//b打印结果也是10 b = 20;//改变b也就是改变了a cout << "a = " << a << endl;//a打印结果为20 cout << "b = " << b << endl;//b打印结果也是为20 return 0; }
注
:引用类型必须和引用实体是同种类型。
1.2引用的特性
1.2.1引用在定义时必须初始化
正确示例:
int a = 10; int& b = a;//引用在定义时必须初始化
错误示例:
int c = 10; int &d;//定义时未初始化 d = c; • 1 • 2 • 3
1.2.2一个变量可以有多个引用
例如:
int a = 10; int& b = a; int& c = a; int& d = a; • 1 • 2 • 3 • 4
此时,b、c、d都是变量a的引用。
1.2.3引用一旦引用了一个实体,就不能再引用其他实体例如:
int a = 10; int& b = a;
此时,b已经是a的引用了,b不能再引用其他实体。如果写下以下代码,想让b引用另一个变量c:
int a = 10; int& b = a; int c = 20; b = c;//错误想法:让b转而引用c
但该代码的意思是:将b引用的实体赋值为c,也就是将变量a的内容改成了20。
1.3引用的使用场景
1.3.1引用做参数(输出型参数)
形参的改变影响实参的参数叫做输出型参数,对于输出型参数,使用引用十分方便。
C语言中的交换函数,学习C语言的时候经常用交换函数来说明传值和传址的区别。现在我们学习了引用,可以不用指针作为形参了:
//交换函数 void Swap(int& a, int& b) { int tmp = a; a = b; b = tmp; }
因为在这里a和b是传入实参的引用,我们将a和b的值交换,就相当于将传入的两个实参交换了。
1.3.2解决二级指针难懂的问题 :
在单链表的C语言实现的这篇博客里,由于是没有头结点的链表,所以修改时,需要二级指针,刚开始学习的时候可能比较难理解。但是学了引用,就可以解决这个问题:
结构定义:
typedef struct SListNode { int data; struct SListNode* next; }SLTNode;
原代码:
void SListPushFront(SLTNode** pphead, SLTDateType x) { SLTNode* newnode = BuyListNode(x); newnode->next = *pphead; *pphead = newnode; } // 调用 SLTNode* pilst = NULL; SListPushFront(&plist);
修改后的二级指针被替换成了引用:
void SListPushFront(SLTNode*& pphead, SLTDateType x) // 修改 { SLTNode* newnode = BuyListNode(x); newnode->next = *pphead; *pphead = newnode; } // 调用 SLTNode* pilst = NULL; SListPushFront(plist); // 修改
这里的意思是给一级指针取了一个别名,传过来的是plist,而plist 是一个一级指针,所以会出现 * 。相当于 pphead 是 plist 的别名,这里修改 pphead ,也就可以对 plist 完成修改。
也可以这么写 :
typedef struct SListNode { int data; struct SListNode* next; }SLTNode, *PSLTNode;
意思就是将 struct SListNode* 类型重命名为 PSLTNode
void SListPushFront(PSLTNode& pphead, SLTDateType x) // 改 { PSLTNode newnode = BuyListNode(x); newnode->next = pphead; pphead = newnode; } // 调用 PSLTNode plist = NULL; SListPushFront(plist);
在 typedef 之后,PSLTNode 就是结构体指针,所以传参过去,只需要在形参那边用引用接收,随后进行操作,就可以达成目的。
总结
:引用做参数优点 1.作输出型参数 2.提高效率(大对象/深拷贝对象–之后学习)
1.3.3引用做返回值
引用也可以做返回值,但要注意一些问题。
int Count() { int n = 0; n++; return n; } int main() { int ret = Count(); cout << ret << endl; return 0; }
这里看似很简单,就是把Count函数计算结束的结果返回,但是这里包含了 传值返回 。
若从栈帧角度看,会先创建 main 函数的栈帧,里面就会有 call指令,开始调用Count 函数。 Count 函数也会形成栈帧,而栈帧中也有空间,用来接受参数,里面的 n 则用来计算结果并返回。
对于传值返回,返回的并不是 n ,而是返回的是 n 的拷贝。而这其中会有一个临时变量,返回的是临时变量
反向证明:如果返回的是 n 的话,由于Count 的函数栈帧已经销毁了,这里打印的ret的值是不确定的。因为空间已经归还给操作系统了,这时都是非法访问,所以必定是n拷贝后的数据被返回。
1.如果Count 函数结束,栈帧销毁,没有清理栈帧,那么ret的结果侥幸正确
2.如果Count 函数结束,栈帧销毁,清理栈帧,那么ret的结果是随机值
但是临时变量在哪?
如果 n 比较小(4/8 byte),一般是寄存器充当临时变量,例如eax
如果 n 比较大,临时变量放在调用 add 函数的栈帧中
最后将临时变量中的值赋值给ret
不论这个函数结束,返回的那个值会不会被销毁,都会创建临时变量返回,例如 :
int Count() { static int n = 0; n++; return n; } int main() { int ret = Count(); cout << ret << endl; return 0; }
对于该函数,编译器仍然是创建临时变量返回;因为编译器不会对其进行特殊处理,仍然是放到 eax 寄存器中返回的。
但这个临时变量创建的有点多余,明明这块空间一直存在,却依然创建临时变量返回
那如果改成引用返回会修改这个缺陷吗?
int& Count() { int n = 0; n++; return n; } int main() { int& ret = Count(); cout << ret << endl; return 0; }
引用返回就是不生成临时变量,直接返回 n 的引用。而这里产生的问题就是 非法访问 。
造成的问题:
1.存在非法访问,因为Count 的返回值是 n 的引用, Count 栈帧销毁后,访问变量 n 的空间,此时n的空间已经还给操作系统了,由于这是读操作,编译器不一定检查出来,但是本质是错的,类似野指针访问。
2.如果 Count 函数栈帧销毁,空间被清理,那么取 n 值时取到的就可能是随机值,取决于编译器的决策。
eg:调用Count函数后再调用其他函数后会再次建立栈帧,后面的栈帧会覆盖前面的栈帧,恰好出现随机值
引用返回的原则:如果函数返回时,出了函数作用域,返回对象还在(还没还给系统),则可以使用引用返回,如果已经还给系统了,则必须使用传值返回。区别就是传值返回生成拷贝,引用返回不生成拷贝。
比如 static 修饰的静态变量就没有缺陷
int& c() { static int n = 0; n++; return n; } int main() { int& ret = Count(); cout << ret << endl; return 0; }
因为 static 修饰的变量在静态区,出了作用域也存在,这时就可以引用返回。
我们可以理解引用返回也有一个返回值,但是这个返回值的类型是 int& ,中间并不产生拷贝,因为返回的是别名。这就相当于返回的就是它本身。
引用返回还可以方便查找和修改->读写功能同在:
#include <cassert> #define N 10 typedef struct Array { int a[N]; int size; }AY; int& PostAt(AY& ay, int i) { assert(i < N); return ay.a[i]; } int main() { AY ay; PostAt(ay, 1); // 修改返回值 for (int i = 0; i < N; i++) { PostAt(ay, i) = i * 3; } for (int i = 0; i < N; i++) { cout << PostAt(ay, i) << ' '; } return 0; }
由于PostAt 的形参 ay 为 main 中 局部变量 ay的别名,所以 ay 一直存在;这时可以使用引用返回。
引用返回 减少了值拷贝 ,不用将其拷贝到临时变量中返回;并且由于是引用返回,所以也可以 修改返回对象 。
总结:如果出了作用域,返回变量(静态static,全局变量,上一层栈帧,动态开辟malloc等不会随着函数调用的结束而被销毁的数据)仍然存在,则可以使用引用返回,不能是函数内部创建的普通局部变量。
引用做返回值优点 1.修改+获取返回值 2.减少拷贝,提高效率(大对象/深拷贝对象–之后学习)
1.4常引用
上面提到,引用类型必须和引用实体是同种类型的。但是仅仅是同种类型,还不能保证能够引用成功,我们若用一个普通引用类型去引用其对应的类型,但该类型被const
所修饰,那么引用将不会成功。
int main() { const int a = 10; //int& ra = a; //该语句编译时会出错,a为常量 const int& ra = a;//正确 //int& b = 10; //该语句编译时会出错,10为常量 const int& b = 10;//正确 return 0; }
我们可以将被const修饰了的类型理解为安全的类型,因为其不能被修改。我们若将一个安全的类型交给一个不安全的类型(可被修改),那么将不会成功。const 修饰的 a 不能修改,b 为 a 的引用。a 是只读,但是引用 b 具有 可读可写 的权利,该情况为 权限放大 ,所以错误了。下面没有错误是因为是一个拷贝,d的改变不影响c
这时,只要加 const 修饰 b ,让 b 的权限也只有只读,使得 权限不变 ,就没问题了:
权限可以缩小,此时++x可以,因为x本身有可以修改的权限且y、z的值同时也会变,因为本来就是同一个空间,x的改变就是y、z的改变。只是作为z时,由于权限限制,++z不行对于函数的返回值来说,也不能权限放大,例如:
int func1() { static int x = 0; return x; } int main() { int& ret = func1(); // error return 0; }
这样也是不行的,因为返回方式为 传值返回 ,返回的是临时变量,具有 常性
,是不可改的;而引用放大了权限,所以是错误的
这时加 const 修饰,权限平移,就没问题了:const
int& ret = func1()同理,这里错误的原因:发生类型转换(提升、截断)的时候会产生一个临时变量对于类型转换来说,在转换的过程中会产生一个临时变量,例如 int ii =dd,把dd转换后的值放到临时变量中,把临时变量给接收的值ii,而临时变量具有常性,不可修改,引用就加了写权限,就错了,因为 权限被放大了 。
而下图由于返回的是x的别名,不是x,不会产生临时变量了,再传给int& ret,为权限平移
总结
:对于引用,引用后的变量所具权限可以缩小或不变,但是不能放大(指针也适用这个说法)。const type& 可以接收各种类型的对象(变量、常量、隐式转换)。对于输出型参数可以用引用,反之用 const type& 更加安全。
1.5引用和指针的区别
在语法概念上,引用就是一个别名,没有独立的空间,其和引用实体共用同一块空间。
int main() { int a = 10; //在语法上,这里给a这块空间取了一个别名,没有新开空间 int& ra = a; ra = 20; //在语法上,这里定义了一个pa指针,开辟了4个字节(32位平台)的空间,用于存储a的地址 int* pa = &a; *pa = 20; return 0; }
但是在底层实现上,引用实际是有空间的:从汇编角度来看,引用的底层实现也是类似指针存地址的方式来处理的。
引用和指针的区别:面试常考点,强烈建议理解
1、引用在定义时必须初始化,指针没有要求。
2、引用在初始化时引用一个实体后,就不能再引用其他实体,而指针可以在任何时候指向任何一个同类型实体。
3、没有NULL引用,但有NULL指针。
4、在sizeof中的含义不同:引用的结果为引用类型的大小,但指针始终是地址空间所占字节个数(32位平台下占4个字节)。
5、引用进行自增操作就相当于实体增加1,指针进行自增操作是指针向后偏移一个类型的大小。
6、有多级指针,但是没有多级引用。
7、访问实体的方式不同,指针需要显示解引用,而引用是编译器自己处理。
8、引用比指针使用起来相对更安全。
2.内联函数
调用函数需要建立栈帧,栈帧中要保存寄存器,结束后就要恢复,这其中都是有 消耗 的:
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; }
但通过上图可以看出写宏时很容易出错(下次再错就挨打吧[bushi]),要么是替换出错,要么是优先级出错,所以宏并不友好。
为了减少函数调用开销,还可以在一定程度上替代宏,避免宏的出错, 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; }
2.1内联函数的概念
在 release 版本下,inline 内联函数会直接在函数调用部分展开;对于 debug 则需要 主动设置 (debug 下编译器默认不对代码做优化);但是 release 版本下其他版本优化的太多,可能就不太好观察,所以我们设置一下编译器,在 debug 下看:
打开解决方案资源管理器,右击项目名称,选中属性并打开,在 C/C++ 区域常规部分,在调试信息一栏设置格式为程序数据库:
在 C/C++ 优化一栏,将内联函数扩展部分选中只适用于 _inline :设置完毕后,点击应用。
在设置前、后,分别启动调试,查看反汇编代码:
修改前:两段反汇编代码最大的区别就是 call 消失了 ,call 就是函数调用的指令,它的消失就说明第二段代码没有进行调用。内联函数直接在局部展开了,在 main 函数中完成了操作。有了内联,我们就不需要去用 c语言 的宏了,因为宏很容易出错。
2.2 特性
inline是一种以 空间换时间 的做法,如果编译器将函数当成内联函数处理,在编译阶段,会用函数体替换函数调用。
缺陷:可能会使目标文件变大;优势:少了调用开销,提高程序运行效率。
inline对于编译器而言只是一个建议,不同编译器关于inline实现机制可能不同,一般建议:将函数规模较小(即函数不是很长,具体没有准确的说法,取决于编译器内部实现)、不是递归、且频繁调用的函数采用inline修饰,否则编译器会忽略inline特性。
inline不建议声明和定义分离,分离会导致链接错误。因为inline被展开,就没有函数地址了,链接就会找不到。
注意:
1)空间换时间是因为反复调用内联函数,导致编译出来的可执行程序变大
inline void func() { // 编译完成为 10 条指令 cout<<"111111"<<endl; cout<<"111111"<<endl; cout<<"111111"<<endl; cout<<"111111"<<endl; cout<<"111111"<<endl; cout<<"111111"<<endl; cout<<"111111"<<endl; cout<<"111111"<<endl; cout<<"111111"<<endl; cout<<"111111"<<endl; }
若不用内联函数,不展开,若10000次调用 func,每次调用的地方为 call 指令的形式,总计 10010 行指令。若用内联函数,则展开,若一千次调用,每次调用的地方为都会展开为 10 条指令,总计 10 * 10000 行指令。
展开会让编译后的程序变大,如果递归函数作内联,后果可想而知。所以长函数和递归函数不适合展开。
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 + 地址的指令,但是内联函数并没有地址,链接不到,就报错了。
所以当声明和定义分离,调用函数时,由于内联函数无地址,编译器链接不到,就会报错,为链接错误。
// F.h #include <iostream> using namespace std; inline void f(int i) { cout << i << endl; } // main.cpp #include "F.h" int main() { f(10); return 0; }
因此。申明和定义不要分离,直接在.h 文件中定义,所有包含.h 的地方不需要链接,直接展开
总结:简短,频繁调用的小函数建议定义成 inline 内联函数 .
1、inline是一种以空间换时间的做法,省了去调用函数的额外开销。由于内联函数会在调用的位置展开,所以代码很长或者有递归的函数不适宜作为内联函数。频繁调用的小函数建议定义成内联函数。
2、inline对于编译器而言只是一个建议,编译器会自动优化,如果定义为inline的函数体内有递归等,编译器优化时会忽略掉内联。
3、inline不建议声明和定义分离,分离会导致链接错误。因为inline被展开,就没有函数地址了链接就会找不到。
3.auto关键字(C++11)
3.1auto简介
在早期的C/C++中auto的含义是:使用auto修饰的变量是具有自动存储器的局部变量,但遗憾的是一直没有人去使用它。
在C++11中,标准委员会赋予了auto全新的含义:auto不再是一个存储类型指示符,而是作为一个新的类型指示符来指示编译器,auto声明的变量必须由编译器在编译时期推导而得。
#include <iostream> using namespace std; double Fun() { return 3.14; } int main() { int a = 10; auto b = a; auto c = 'A'; auto d = Fun(); //打印变量b,c,d的类型 cout << typeid(b).name() << endl;//打印结果为int cout << typeid(c).name() << endl;//打印结果为char cout << typeid(d).name() << endl;//打印结果为double return 0; }
注意
:使用auto变量时必须对其进行初始化,在编译阶段编译器需要根据初始化表达式来推导auto的实际类型。因此,auto并非是一种“类型”的声明,而是一个类型声明的“占位符”,编译器在编译期会将auto替换为变量实际的类型。
3.2 auto的使用细则
3.2.1 auto与指针和引用结合起来使用
用auto声明指针类型时,用auto和auto*没有任何区别,但用auto声明引用类型时必须加&
#include <iostream> using namespace std; int main() { int a = 10; auto b = &a; //自动推导出b的类型为int* auto* c = &a; //自动推导出c的类型为int* auto& d = a; //自动推导出d的类型为int //打印变量b,c,d的类型 cout << typeid(b).name() << endl;//打印结果为int* cout << typeid(c).name() << endl;//打印结果为int* cout << typeid(d).name() << endl;//打印结果为int return 0; }
注意
:用auto声明引用时必须加&,否则创建的只是与实体类型相同的普通变量。
3.2.2在同一行定义多个变量
当在同一行声明多个变量时,这些变量必须是相同的类型,否则编译器将会报错,因为编译器实际只对第一个类型进行推导,然后用推导出来的类型定义其他变量。
int main() { auto a = 1, b = 2; //正确 auto c = 3, d = 4.0; //编译器报错:“auto”必须始终推导为同一类型 return 0; }
3.3 auto不能推导的场景
3.3.1auto不能作为函数的参数
以下代码编译失败,auto不能作为形参类型,因为编译器无法对x的实际类型进行推导。
void TestAuto(auto x) {}
3.3.2auto不能直接用来声明数组
int main() { int a[] = { 1, 2, 3 }; auto b[] = { 4, 5, 6 };//error return 0; }
4.基于范围的for循环(C++11)
范围for的语法糖
若是在C++98中我们要遍历一个数组,可以按照以下方式:
int arr[] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; //将数组元素值全部乘以2 for (int i = 0; i < sizeof(arr) / sizeof(arr[0]); i++) { arr[i] *= 2; } //打印数组中的所有元素 for (int i = 0; i < sizeof(arr) / sizeof(arr[0]); i++) { cout << arr[i] << " "; } cout << endl;
以上方式也是我们C语言中所用的遍历数组的方式,但对于一个有范围的集合而言,循环是多余的,有时还容易犯错。因此C++11中引入了基于范围的for循环。for循环后的括号由冒号分为两部分:第一部分是范围内用于迭代的变量,第二部分则表示被迭代的范围。
int arr[] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; //将数组元素值全部乘以2 for (auto& e : arr) { e *= 2; } //打印数组中的所有元素 for (auto e : arr) { cout << e << " "; } cout << endl;
注意
:与普通循环类似,可用continue来结束本次循环,也可以用break来跳出整个循环。
4.1范围for的使用条件
4.1.1for循环迭代的范围必须是确定的
对于数组而言,就是数组中第一个元素和最后一个元素的范围;对于类而言,应该提供begin和end的方法,begin和end就是for循环迭代的范围。
以下代码就有问题,因为for的范围不确定,因为函数传参,数组就会退化为指针:
void TestFor(int array[]) { for (auto& e : array) { cout << e << endl; } }
4.1.2迭代的对象要实现++和==操作
这是关于迭代器的问题,先了解一下。
5.指针空值nullptr
5.1 C++98中的指针空值
在良好的C/C++编程习惯中,在声明一个变量的同时最好给该变量一个合适的初始值,否则可能会出现不可预料的错误。比如未初始化的指针,如果一个指针没有合法的指向,我们基本都是按如下方式对其进行初始化:
int* p1 = NULL; int* p2 = 0;
NULL其实是一个宏,在传统的C头文件(stddef.h)中可以看到如下代码:
/* Define NULL pointer value */ #ifndef NULL #ifdef __cplusplus #define NULL 0 #else /* __cplusplus */ #define NULL ((void *)0) #endif /* __cplusplus */ #endif /* NULL */
可以看到,NULL可能被定义为字面常量0,也可能被定义为无类型指针(void*)的常量。但是不论采取何种定义,在使用空值的指针时,都不可避免的会遇到一些麻烦,例如:
#include <iostream> using namespace std; void Fun(int p) { cout << "Fun(int)" << endl; } void Fun(int* p) { cout << "Fun(int*)" << endl; } int main() { Fun(0); //打印结果为 Fun(int) Fun(NULL); //打印结果为 Fun(int) Fun((int*)NULL); //打印结果为 Fun(int*) return 0; }
程序本意本意是想通过Fun(NULL)调用指针版本的Fun(int* p)函数,但是由于NULL被定义为0,Fun(NULL)最终调用的是Fun(int p)函数。
注意:在C++98中字面常量0,既可以是一个整型数字,也可以是无类型的指针(void*)常量,但编译器默认情况下将其看成是一个整型常量,如果要将其按照指针方式来使用,必须对其进行强制转换。
5.2 C++11中的指针空值
对于C++98中的问题,C++11引入了关键字nullptr。
注意:
1、在使用nullptr表示指针空值时,不需要包含头文件,因为nullptr是C++11作为关键字引入的。
2、在C++11中,sizeof(nullptr)与sizeof((void*)0)所占的字节数相同。
3、为了提高代码的健壮性,在后序表示指针空值时建议最好使用nullptr。
6.总结:
今天我们认识并具体学习了有关引用、内联函数、auto关键字、范围for循环(C++11)、指针空值nullptr的知识。接下来,我们将继续学习C++中类和对象的相关知识。希望我的文章和讲解能对大家的学习提供一些帮助。
当然,本文仍有许多不足之处,欢迎各位小伙伴们随时私信交流、批评指正!我们下期见~