目录
一、引用
1. 引用概念
引用不是新定义一个变量,而是给已存在变量取了一个别名,编译器不会为引用变量开辟内存空间,它和它引用的变量共用同一块内存空间。
- 类型& 引用变量名(对象名) = 引用实体。我们看下面代码:
int main() { int a = 10; int& b = a; int& c = a; int& d = b; }
上述代码中定义一个变量a,a这块空间占据4个字节,接下来我们又给它取了二个名字叫做b和c,然后又给b取了一个名字叫做d,也就是说a,b,c,d同时可以访问且修改变量a的这块空间,并且a,b,c,d的地址都是一样的。
2. 引用特性
引用具有三大特性:
- 引用在定义时必须初始化。
- 一个变量可以有多个引用。
- 引用一旦引用一个实体,再不能引用其它实体。
接下来我们将其特性进行一一讲解。
2.1 引用在定义时必须初始化
假如我们写出如下的引用会发生什么呢?
int& d;
我们可以看到已经发生错误,所以引用在定义的时候必须进行初始化。
2.2 一个变量可以有多个引用
比如我有个变量a,你可以给其取个别名b,也可以取个别名c,甚至给别名c再取别名d都可以,并且这些别名和a的地址均是一样的,我改变其中一个,其它的也会随之改变。
2.3 引用一旦引用一个实体,再不能引用其他实体
在这段代码中,我们已经给a取别名b,随后把e的值赋给b,这里可不是对e取别名了,通过编译即可看出来,b的地址同引用的a的地址,而不同于e的地址。
3. 常引用
3.1 取别名的规则
我们在取别名的时候不是在所有的情况下都是可以随便取的,要在一定的范围内。对原引用变量,权限只能缩小,不能放大。
3.2 权限放大error
我们都知道在C语言中有个const,而在C++的引用这一块也是有const引用的,假如我们现在有个被const修饰过的变量X,现在我们想对X取别名,我们还能用下面的方式吗??
这个就是典型的权限放大,变量X被const修饰只可以进行读,不能进行修改。而此时我们对X引用成y,并且是int型的,此时y是可读可写的,不满足X的只读条件。
那怎样才能对x进行引用呢?我们只需要确保权限不变即可。见下文:
3.3 权限不变
想要使权限不变,我们只需要对x引用的同时加上const修饰,让变量y也只是只读。
//权限不变 const int x = 0; const int& y = x;
那如果变量没有加const修饰,但是在引用时加上const可以吗??这就是权限缩小。
3.4 权限缩小
//权限缩小 int c = 10; const int& d = c;
这里变量C是可读可写的,我们创建引用变量d进行const修饰,那么d就只为可读,权限缩小,不报错。
4. 引用原理与拓展
4.1 如何给常量取别名?
我们可以用下面方法给常量取别名吗?
int& c=20; //err
对于常量我们不可以直接进行取别名,我们需要加上const修饰才可以。
const int& c=20; //right
4.2 临时变量具有常性
我们看如下代码:
double d = 2.2; int& e = d;
编译一下看看:
很明显e不能成为d的别名。但是如果我们加上const,发现它竟然不会出错!!
该如何解释上述现象呢??这就需要我们先回顾一下C语言的类型转换。C++本身就是建立在C语言之上,C语言在相似类型是允许隐式类型转换的。大的数据类型给小的会发生截断,小的给大的会提升。我们看如下代码:
这里的丢失数据其实就是会丢失精度。
⭐原理:
这里把d赋值给f并不是直接赋值的,会先把d的整数部分取出来赋值给一个临时变量,该临时变量大小为4个字节,随后再把这个临时变量赋给f
- 临时变量具有常性,就像被const修饰了一样,不能被修改。
谈到这里我想大家应该就可以理解为什么下面的这段代码需要加上const才能编译通过:
double d = 2.2; const int& e = d;
这里e引用的是临时变量,临时变量具有常性,不能直接引用,否则就是放大了权限,加上const才能保证其权限不变。
- 既然是这样,那为何下面这段代码在赋值的时候不加上const呢??
double d = 2.2; int f = d;
- 上述加const是在引用的基础上加的,如果不加const那么就是放大权限。而在此段代码,对f的改变并不会影响到临时变量。普通的变量不存在权限放大或者缩小。
- 那么下列代码中的e还是对d的引用吗?
double d=2.2 const int& e=d;
当然不是,此时的e是对临时变量的引用,是临时变量的别名,我们可以通过编译来验证:
我们可以看到变量e和d的地址都不一样。
4.3 权限控制的用处
这里简单的提一下,例如下面的传参问题。
如若函数写成普通的引用,那么很多参数可能会传不过去:
观察上面代码我们发现只有a能够正常传过去,后面的均传不过去,因为后面传的参数均涉及权限放大。 但如果我们在函数的形参上加const修饰呢?
加上const修饰后编译器就不会报错了。
5. 引用的使用场景
引用的使用场景分为两个:
- 做参数
- 做返回值
接下来我们将会详细讲解一下。
5.1 做参数
比如我们现在想写一个Swap函数,以前是用指针传参:
//指针版 void Swap(int* pa, int* pb) { int tmp = *pa; *pa = *pb; *pb = tmp; } int main() { int a = 10; int b = 5; Swap(&a, &b); return 0; }
而现在,我们可以巧用引用来完成Swap函数
//引用版 void Swap(int& x, int& y) { int tmp = x; x = y; y = tmp; } //支持函数重载 void Swap(double& x, double& y) { double tmp = x; x = y; y = tmp; } int main() { //交换整数 int a = 0, b = 1; Swap(a, b); //交换浮点数 double c = 1.1, d = 2.2; Swap(c, d); return 0; }
- 在输出型参数里面用引用非常的方便:
int* preorderTraversal(struct TreeNode* root, int* returnSize) { //…… }
我们可以将上述代码改为下面的代码:
int* preorderTraversal(struct TreeNode* root, int& returnSize) { //…… } int main() { preorderTraversal(tree, size); }
加上引用在调用函数时省去了写&,更加方便理解,同时也减少了对指针的使用。
用引用做参数的好处如下:
- 输出型参数
- 减少拷贝,提高效率
5.2 做返回值
我们先看下面一段代码:
输出结果为1、2、3。
- 这里可能有人会提问为什么不是1、1、1呢?注意这里使用了静态区的变量只会初始化一次,也就是说我static int n = 0这行代码在编译时只有第一次会跳到这,其余两次均不会走这一行代码,你每次进去的n都是同一个n。
⭐传值返回:
int Count() { int n = 0; n++; return n; } int main() { int ret = Count(); return 0; }
在传值返回的过程中会产生一个临时变量(类型为int),如果这个临时变量小会用寄存器进行替代,如果大就不会用寄存器替代。
具体返回的过程中是先把函数的n拷贝给临时变量,再把临时变量拷贝给ret。
- main函数里有个变量ret,汇编时会call一个指令跳到函数Count,Count里有一个变量n。这里不能把n直接传给ret,因为函数调用完后函数的栈帧就销毁了,这里会产生一个临时变量保存n的值,再把n的值传给ret。
我们如何证明这个过程中产生了临时变量呢?
我们只需要加一个引用。
这里很明显发生了编译错误。 这里ret之所以出错就是因为其引用的是临时变量,临时变量具有常性,只读不可修改,直接引用则会出现上文所述的权限放大问题。 所以这就很巧合的验证了此函数调用中途会产生临时变量。
想要解决此类问题,我们只需要使其保持权限不变即可,即加上const修饰:
⭐传引用返回:
我们对上述代码进行微调:
int& Count() { int n = 0; n++; return n; } int main() { int ret = Count(); return 0; }
这里加上了引用&后,中间也会产生一个临时变量,只是这个临时变量的类型是int&。我们把这个临时变量假定为tmp,那么此时tmp就是n的别名,再把tmp赋值给ret。这个过程不就是直接把n赋给ret吗。这里区分于传值返回的核心就在于传引用的返回就是n的别名。
如何证明传引用返回的是n的别名?
我们还可以通过打印法来验证:
这里的ret和n的地址都是一样的,这也就意味着ret其实就是n的别名。 综上,传值返回和传引用返回的区别如下:
- 传值返回:会有一个拷贝
- 传引用返回:没有这个拷贝了,返回的直接就是返回变量的别名
这里又存在一个问题:上述代码有没有其他错误??
我传引用返回后,ret就是n的别名,出了函数出了这个作用域变量n就销毁。但空间的销毁并不代表空间不在了。空间的归还就好比你退房,虽然你退房了,但是这个房间还是在的,只是说使用权不是你的了。但是假说你在不小心的情况下留了一把钥匙,你依旧是可以进入这个房间,不过你这个行为是非法的。这个例子也就足矣说明了上述的代码是有问题的。是一个间接的非法访问。
我们看下面代码和运行结果:
这里第一次打印ret的值为1,打印完后函数栈帧销毁,此时ret里面存储的是随机值。
- 那如果我们非要引用返回,该怎样做呢?
加上static即可:
int& Count() { static int n = 0; n++; cout << "&n: " << &n << endl; return n; } int main() { int& ret = Count(); cout << ret << endl; cout << "&ret: " << &ret << endl; cout << ret << endl; return 0; }
加上了static后n就被放入了静态区,出了作用域不会被销毁。
⚠: 如果函数返回时,出了函数作用域,如果返回对象还未还给系统,则可以使用引用返回,如果已经还给系统了,则必须使用传值返回。否则就可能会出越界问题。
看下面代码:
int& Add(int a, int b) { int c = a + b; return c; } int main() { int& ret = Add(1, 2); Add(3, 4); cout << "Add(1, 2) is :" << ret << endl; //7 return 0; }
此段代码的执行结果ret的值为7,首先我Add(1,2)。调用完后返回C的别名给ret,调用完后Add栈帧销毁,当我第二次调用函数c的值就被修改成7。
正常情况下我们应该加上static:
加上static后这里ret的值就是3了,因为加上了static初始化只有一次。此时c在静态区了,销毁栈帧它还在。
- 我们再演示一下其被覆盖的情形:
正常情况:
不加static发生覆盖:
⭐传值、传引用效率比较:
以值作为参数或者返回值类型,在传参和返回期间,函数不会直接传递实参或者将变量本身直接返回,而是传递实参或者返回变量的一份临时的拷贝,因此用值作为参数或者返回值类型,效率是非常低下的,尤其是当参数或者返回值类型非常大时,效率就更低。
- 作为参数比较
#include <time.h> struct A { int a[10000]; }; void TestFunc1(A a) {} void TestFunc2(A& a) {} void TestRefAndValue() { A a; // 以值作为函数参数 size_t begin1 = clock(); for (size_t i = 0; i < 10000; ++i) TestFunc1(a); size_t end1 = clock(); // 以引用作为函数参数 size_t begin2 = clock(); for (size_t i = 0; i < 10000; ++i) TestFunc2(a); size_t end2 = clock(); // 分别计算两个函数运行结束后的时间 cout << "TestFunc1(A)-time:" << end1 - begin1 << endl; cout << "TestFunc2(A&)-time:" << end2 - begin2 << endl; } int main() { TestRefAndValue(); }
- 作为返回值比较
#include <time.h> struct A { int a[10000]; }; A a; // 值返回 A TestFunc1() { return a; } // 引用返回 A& TestFunc2() { return a; } void TestReturnByRefOrValue() { // 以值作为函数的返回值类型 size_t begin1 = clock(); for (size_t i = 0; i < 100000; ++i) TestFunc1(); size_t end1 = clock(); // 以引用作为函数的返回值类型 size_t begin2 = clock(); for (size_t i = 0; i < 100000; ++i) TestFunc2(); size_t end2 = clock(); // 计算两个函数运算完成之后的时间 cout << "TestFunc1 time:" << end1 - begin1 << endl; cout << "TestFunc2 time:" << end2 - begin2 << endl; } int main() { TestReturnByRefOrValue(); }
6. 引用的使用场景
引用和指针的不同点:
- 引用概念上定义一个变量的别名,指针存储一个变量地址
- 引用在定义时必须初始化,指针没有要求
- 引用在初始化时引用一个实体后,就不能再引用其他实体,而指针可以在任何时候指向任何一个同类型实体
- 没有NULL引用,但有NULL指针
- 在sizeof中含义不同:引用结果为引用类型的大小,但指针始终是地址空间所占字节个数(32位平台下占4个字节)
- 引用自加即引用的实体增加1,指针自加即指针向后偏移一个类型的大小
- 有多级指针,但是没有多级引用
- 访问实体方式不同,指针需要显式解引用,引用编译器自己处理
- 引用比指针使用起来相对更安全
⭐:其实在底层实现上,引用是按照指针方式来实现的。下面我们来看下引用和指针的汇编代码对比:
二、内联函数
1. 概念
以inline修饰的函数叫做内联函数,编译时C++编译器会在调用内联函数的地方展开,没有函数压栈的开销,内联函数提升程序运行的效率。
我们以Add函数为例:
int Add(int x, int y) { int z = x + y; return z; }
上述的Add函数如若被频繁调用,则在效率上会存在一定的损失。假如我们调用10次,那么就需要建立十次栈帧。建立栈帧又要保存寄存器,压参数等一系列操作都会造成效率损失。
- 在我们先前学过的C语言中就给出了解决方案:宏
#define ADD(x,y) ((x)+(y))
- 既然用宏能够解决,那C++为何要引出inline?
使用宏确实可以帮助我们避免调用栈帧而造成效率损失,但是宏的写法上欠妥,我们需要注意结尾不能加分号,要注意优先级带来的问题而频繁加括等等一系列问题。C++为了填补宏书写规则麻烦的坑,引出了内敛函数的概念(inline)。内敛函数的特点:
1.解决宏函数晦涩难懂,容易写错的问题
2.解决宏不支持调试,不支持类型安全的检查等问题
- 内敛函数(inline)如何使用?
我们只需要在函数前面加上inline即可:
inline int Add(int x, int y) { int z = x + y; return z; }
此时我们再调用函数就不会再建立栈帧了,函数直接会在调用的地方展开。
- inline的好处如下:
1.debug支持调试
2.不容易写错,就是普通函数的写法
2. 特性
- inline是一种以空间换时间的做法,省去调用函数额开销。所以代码很长或者有循环/递归的函数不适宜使用作为内联函数。
- inline对于编译器而言只是一个建议,编译器会自动优化,如果定义为inline的函数体内有循环/递归等等,编译器优化时会忽略掉内联。
- inline不建议声明和定义分离,分离会导致链接错误。因为inline被展开,就没有函数地址了,链接就会找不到。
3. 经典面试题
- 问题1:宏的优缺点
优点:
- 增强代码的复用性
- 提高性能
缺点:
- 不方便调试宏(因为预编译阶段进行了替换)
- 导致代码可读性差,可维护性差,容易误用
- 没有类型安全的检查
- 问题2:C++有那些技术替代宏
- 常量定义,换用const
- 函数定义,换用内联函数
三、auto关键字(C++11)
1. auto简介
在早期C/C++中auto的含义是:使用auto修饰的变量,是具有自动存储器的局部变量,但遗憾的是一直没有人去使用它,大家可思考下为什么?
C++11中,标准委员会赋予了auto全新的含义即:auto不再是一个存储类型指示符,而是作为一个新的类型指示符来指示编译器,auto声明的变量必须由编译器在编译时期推导而得。
简单来说:先前定义变量要在变量前指定类型,使用auto可以不指定类型,让右边赋的值进行推导,如示例:
int a = 10; auto b = a; auto c = 'a';
这里a的类型是整型,那么就自动推出b的类型为int,而'a'为char类型,自然c就是char类型。
- 补充:
这里补充一个知识点:typeid().name。它是专门用来输出一个变量的类型,返回的是一个字符串。
- 代码演示:
int TestAuto() { return 10; } int main() { const int a = 10; auto b = a; auto m = &a; auto c = 'a'; auto d = TestAuto(); cout << typeid(b).name() << endl; // int cout << typeid(m).name() << endl; // int const * cout << typeid(c).name() << endl; // char cout << typeid(d).name() << endl; // int //auto e; 无法通过编译,使用auto定义变量时必须对其进行初始化 return 0; }
- 结果如下:
- 注意:
使用auto定义变量时必须对其进行初始化,在编译阶段编译器需要根据初始化表达式来推导auto的实际类型。因此auto并非是一种“类型”的声明,而是一个类型声明时的“占位符”,编译器在编译期会将auto替换为变量实际的类型。
2. auto的使用细则
- auto与指针和引用结合起来使用
用auto声明指针类型时,用auto和auto*没有任何的区别,但用auto声明引用类型时则必须加&
int main() { int x = 10; auto a = &x; auto* b = &x; auto& c = x; cout << typeid(a).name() << endl; // int* cout << typeid(b).name() << endl; // int* cout << typeid(c).name() << endl; // int *a = 20; *b = 30; c = 40; return 0; }
- 在同一行定义多个变量
当在同一行声明多个变量时,这些变量必须是相同的类型,否则编译器将会报错,因为编译器实际只对第一个类型进行推导,然后用推导出来的类型定义其他变量。
void TestAuto() { auto a = 1, b = 2; auto c = 3, d = 4.0; // 该行代码会编译失败,因为c和d的初始化表达式类型不同 }
3. auto不能推导的场景
- auto不能作为函数的参数
// 此处代码编译失败,auto不能作为形参类型,因为编译器无法对a的实际类型进行推导 void TestAuto(auto a) {}
- auto不能做返回值
auto Test() { return 10; // err }
- auto不能直接用来声明数组
void TestAuto() { int a[] = { 1,2,3 }; auto b[] = { 4,5,6 }; //err 错误 }
- 为了避免与C++98中的auto发生混淆,C++11只保留了auto作为类型指示符的用法。
- auto在实际中最常见的优势用法就是跟以后会讲到的C++11提供的新式for循环,还有lambda表达式等进行配合使用。
4. auto的实际应用价值
- 类型很长时,懒得写,我们可以让其自动推导
以后在我们学到容器的时候,我们会写出这样的代码:
#include<map> #include<string> int main() { std::map<std::string, std::string>dict; dict["sort"] = "排序"; dict["string"] = "字符串"; //auto意义之一:类型很长时,懒得写,可以让它自动推导 std::map<std::string, std::string>::iterator it = dict.begin(); auto it = dict.begin(); return 0; }
我们使用auto就可以简化前面定义过长类型的代码,使其自动判断类型
- 基于范围的for循环
基于范围的for循环我们单独来讲,看下文。
5. 基于范围的for循环(C++11)
5.1 范围for的语法
我们平常写打印一串数组中的数据的时候我们可以这样写:
void TestFor() { int array[] = { 1, 2, 3, 4, 5 }; for (int i = 0; i < sizeof(array) / sizeof(int); ++i) array[i] *= 2; for (int i = 0; i < sizeof(array) / sizeof(int); ++i) cout << array[i] << " "; // 2 4 6 8 10 }
对于一个有范围的集合而言,我们可以利用基于范围的for循环。for循环后的括号由冒号“ :”分为两部分:第一部分是范围内用于迭代的变量,第二部分则表示被迭代的范围。
因此在C++中我们可以这样写:
void TestFor() { int array[] = { 1, 2, 3, 4, 5 }; for (int i = 0; i < sizeof(array) / sizeof(int); ++i) array[i] *= 2; for (auto e : array) cout << e << " "; // 2 4 6 8 10 }
此段代码就是范围for,它可以自动遍历,会依次取数组中的数据赋值给e,自动判断结束
- 可如果现在我想对数组进行修改,使数组中的每一个数字除以2,我们该怎么做呢?是如下这样吗?
我们可以看到上述方法是错误的,其并没有起到修改的作用。我们注意看范围for的规则:依次取数组中的数据赋值给e,这也就说明e是数组中每个值的拷贝,e的改变并不会影响到数组 。此时就需要我们用到引用了,当我们给其取别名时,e的修改就会影响到原数组。
void TestFor() { int array[] = { 1, 2, 3, 4, 5 }; for (int i = 0; i < sizeof(array) / sizeof(int); ++i) array[i] *= 2; for (auto e : array) cout << e << " "; // 2 4 6 8 10 cout << endl; for (auto& e : array) e /= 2; for (auto e : array) cout << e << ' '; }
- 补充:
1、范围for里的auto也可以写成int,不过最好还是写成auto,毕竟auto可以自动推出数组的类型嘛,不用auto还要自己手动设置。把e改成其它的变量也是可以的,不强求。
2、与普通循环类似,可以用continue来结束本次循环,也可以用break来跳出整个循环。
5.2 范围for的使用条件
- for循环迭代的范围必须时确定的
对于数组而言,就是数组中第一个元素和最后一个元素的范围;对于类而言,应该提供begin和end的方法,begin和end就是for循环迭代的范围。
注意:以下代码就有问题,因为for的范围不确定
void TestFor(int array[]) { for (auto& e : array) cout << e << endl; }
用范围for必须是数组名,C语言有规定参数传递的过程中不能是数组,这里的形参是指针,自然不能用范围for的规则了。
- 迭代的对象要实现++和==的操作。(关于迭代器这个问题,以后会讲)
四、指针空值nullptr(C++11)
在良好的C/C++编程习惯中,声明一个变量时最好给该变量一个合适的初始值,否则可能会出现不可预料的错误,比如未初始化的指针。如果一个指针没有合法的指向,我们基本都是按照如下方式对其进行初始化:
void TestPtr() { int* p1 = NULL; int* p2 = 0; // …… }
但是在C++中,我们推荐这样写:
int* p3=nullptr;
前者中,NULL和0在C++其实是等价的,都不规范。NULL实际是一个宏,在传统的C头文件(stddef.h)中,可以看到如下代码:
#ifndef NULL #ifdef __cplusplus #define NULL 0 #else #define NULL ((void *)0) #endif #endif
如果没有定义宏,如果在cplusplus里,NULL被定义成0。可以看到,NULL可能被定义为字面常量0,或者被定义为无类型指针(void*)的常量。不论采取何种定义,在使用空值的指针时,都不可避免的会遇到一些麻烦,比如:
程序本意是想通过f(NULL)调用指针版本的f(int*)函数,但是由于NULL被定义成0,因此与程序的初衷相悖。
在C++98中,字面常量0既可以是一个整形数字,也可以是无类型的指针(void*)常量,但是编译器默认情况下将其看成是一个整形常量,如果要将其按照指针方式来使用,必须对其进行强转(void *)0。
- 注意:
- 在使用nullptr表示指针空值时,不需要包含头文件,因为nullptr是C++11作为新关键字引入的。
- 在C++11中,sizeof(nullptr) 与 sizeof((void*)0)所占的字节数相同。
- 为了提高代码的健壮性,在后续表示指针空值时建议最好使用nullptr。