目录
一、引用🐅
1.1 引用概念🐱
引用 不是新定义一个变量,而 是给已存在变量取了一个别名 ,编译器不会为引用变量开辟内存空间(理论上,实际并非如此,后面细说),它和它引用的变量共用同一块内存空间。
引用的使用: 类型& 引用变量名(对象名)=引用实体。

引用就是如此,它们指代的都是同一块空间,所以地址肯定是相同的。
不过需要注意的是:引用类型必须和引用实体是同种类型的。
1.2 引用特性🐱
1、引用在定义时必须初始化。
2、一个变量可以有多个引用。(一个人可以有很多的外号)
3、引用只能指向一个空间,不能改为其他空间。
4、引用没有开辟空间,如果对它赋值,是改变了引用指向空间的内容。
5、不能利用引用去做链表,因为引用不能更改指向,在C++中,引用还没完全取代指针,而Java是可以脱离指针的。
1.3引用的使用🐱
①做参数😀
在C语言阶段我们要交换两个数的值,常常需要传他们的地址,通过地址来改变他们的值,在提出引用之后,我们不需要传地址了。
void Swap(int& left, int& right)
{
int temp = left;
left = right;
right = temp;
}
同样的,通过使用引用,我们有些地方可以没必要使用二级指针,从而使代码简化。
void SListPushBack(Node**pphead,int x)
void SListPushBack(Node*&pphead,int x)//这种方式
②做返回值😀
int& Count1()
{
int n = 0;
n++;
return n;//引用返回
}
//传值返回
int& Count2()
{
static int n = 0;
n++;
return n;//传值返回
}
Count1和Count2函数不同的区别:
在于Count1中n是局部变量,在栈上开辟的,而Count2中n是在静态区开辟的,不会随着函数的结束而销毁。
我们再来看一下它们被调用后可以观察到什么
这样的结果是意料之内的,因为Count1中的n随着函数结束而销毁,也就是它的空间不归我们使用,归还给了操作系统,我们可以通过引用找到那个空间,但是它可能已经被操作系统使用而值发生了变化,所以我们读写的数据是不确定的。所以在这里我们要注意,当使用引用做返回值的时候,返回的数不能在栈区,不能是局部变量,它可以在静态区或者堆。
在这里,我们可以再深入一点,看一下传值返回和传引用返回有什么不一样。
Count1返回的是引用,我们惊奇地发现,用int和int&接受它们的值也是不一样的,这似乎超乎我们的意料,不过如果我们知道函数的栈帧,再一分析就很好理解了。
为什么出现不一样的结果?
因为函数结束也就是栈帧销毁,会生成一个临时变量,如果临时变量比较小,就会存放再寄存器中,如果比较大,比如结构体,就会开辟一块空间,因为不可能直接传,因为空间已经被释放还给操作系统了。
所以这里ret接受的是n的拷贝,那么还有一个问题,int&类型的ret接受的是不是拷贝呢?其实也是拷贝,那为什么,它能找到那个空间呢?因为这里,最本质上是传了地址!如果诸位不相信,我们可以在编译器内部看一下引用和指针。
我们惊奇的发现真的好相似,所以引用在底层上实际也是指针实现的!
1.4Const引用和临时变量🐱
①引用权限的放大和缩小😀
权限的放大:
我们来看这段代码,为什么int& rb=b会出错呢?
这就是典型的引用的权限的放大,这里rb引用b的别名,b已经被const限制,而rb不被限制,rb是b的别名,rb可修改而b不可修改是不是不合理?
反之,如果合理,那么我给rb赋值,那么rb所指向的空间的内容被修改,而这个空间就是b,但是b被const限制,不能修改,是不是就起冲突了?
权限的缩小
我们再来看一下这段代码,这段代码中,被const限制的rra不能被修改,但是它可以引用a,只不过权限受限,而a不受限制。
启示:
1、指针和引用赋值中,权限可以平移,可以缩小,但是不能放大!类似于上级给你权力,它可以给你和他相同的,或者小于他的,但是不能高于他,这是一样的道理。
2、权限的放大和缩小,仅限于指针和引用,因为这两种都会影响到原来的数据。
3、如果不更改原来的数据,一般引用或者用指针做参数,一般都是用const加限制引用!
②临时变量的讲解😀
我们还是通过代码来看一下:
int main()
{
double d = 10.28;
int i = d;
cout << (int)d << endl;
return 0;
}
看了这段代码,我想问几个问题:
1、把浮点型d赋值给整型i,是把d强转为int后再赋值给i吗?
2、输出强转成整型的d,是真的把d给强转截断了吗?
显然不是,这里就涉及到了临时变量的概念,这里无论是把d赋给i,还是把d强转,都产生了临时变量,它对原来的数据进行拷贝,在int i=d中,临时变量就等于把d强转后的数,然后再赋给i。
这里为什么会出错呢?这要说到临时变量的一个特性,也就是常量性,临时变量是不可修改的,它是被const限制的,这里的赋值出错就是因为,临时变量对于d强转整型后的拷贝是常量,他不能被赋值给变量。
当我们用const加限制,使它变为常量后,就运行成功了!
总结一下临时变量的特点:
1、隐式类型转换、强制类型转换、截断、提升、函数栈帧的销毁都会产生临时变量。
2、临时变量具有常量性,不能被修改!
1.5 传值、传引用效率的比较🐱
以值作为参数或者返回值类型,在传参和返回期间,函数不会直接传递实参或者将变量本身直接返回,而是传递实参或者返回变量的一份临时的拷贝,因此用值作为参数或者返回值类型,效率是非常低下的,尤其是当参数或者返回值类型非常大时,效率就更低。
#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();
return 0;
}
我们很轻易就看出,传引用的效率要远高于传值的。
1.6 引用和指针的区别🐱
在 语法概念上 引用就是一个别名,没有独立空间,和其引用实体共用同一块空间。但是,上面博主就验证过了,实际并非如此。
引用和指针的不同点:
1. 引用概念上定义一个变量的别名,指针存储一个变量地址。2. 引用 在定义时 必须初始化 ,指针没有要求,但是为了避免野指针,我们也要初始化指针。3. 引用 在初始化时引用一个实体后,就 不能再引用其他实体 ,而指针可以在任何时候指向任何 一个同类型实体。4. 没有 NULL 引用 ,但有 NULL 指针。5. 在 sizeof 中含义不同 : 引用 结果为 引用类型的大小 ,但 指针 始终是 地址空间所占字节个数 (32 位平台下占4 个字节 )。6. 引用自加即引用的实体增加 1 ,指针自加即指针向后偏移一个类型的大小。7. 有多级指针,但是没有多级引用。8. 访问实体方式不同, 指针需要显式解引用,引用编译器自己处理9. 引用比指针使用起来相对更安全。
二、内联函数🐅
2.1概念🐱
以 inline修饰的函数叫内联函数,编译时C++编译器会在 调用内联函数的地方展开, 没有函数调用栈帧的开销,内联函数提升程序运行的效率。
不知道读者在看到对于内联函数的介绍时有没有感觉到很熟悉,对,它确实和我们在C语言阶段所学习的宏定义函数很相似。那么,可能有的读者比较困惑,为什么有了宏, 他也能替换,也能不调用函数,不消耗栈帧,那么引入内联函数的意义是什么呢?
我们在C语言阶段,学习的宏它确实有不少优点,比如说:它不需要调用,比函数运行更快;它直接替换;它是类型无关的等等。但是他也有不少缺点:
1、不能调试;
2、没有类型安全检查;
3、容易写出带有副作用的宏参数,比如x++等等;
4、写宏的时候经常需要考虑运算符优先级问题,会加上不少的括号,很容易出错。
内联函数就是C++提出解决这些问题的。
2.2内联函数在编译器的表现🐱
看到这里,可能有的读者就要发出疑问了,我们不是使用了内联函数吗,为什么还和普通函数一样需要call(调用)呢?
这是因为这是在debug模式下,编译器为了方便我们调试,而不发生替换。
如果在release模式下,编译器生成的汇编代码是不会存在call Add的。
在debug模式下,我们需要对编译器进行设置,否则不会展开(因为在debug模式下,编译器默认不会对代码进行优化)。
设置方式(vs 2019):

设置之后我们再进行调试,就可以发现没有调用函数,而是直接发生了替换:
2.3特性🐱
1、inline是一种以空间换时间的做法,如果编译器将函数当成内联函数处理,在编译阶段,会用函数体替换函数调用,缺陷:可能会使目标文件变大,优势:少了调用开销,提升程序运行效率。
2、inline对于编译器而言只是一个建议,不同编译器关于inline实现机制可能不同,一般建议:将函数规模较小(即函数不是很长,具体没有准确的标准,取决于编译器内部实现)、不是递归、且频繁调用的小函数采用inline修饰,否则编译器会忽略inline特性。下图为《C++prime》第五版关于inline的建议:
3、inline不建议声明和定义分离,分离会导致链接错误。因为inline在预处理阶段被展开,没有有效函数地址了,链接会找不到。
4、如果函数调用过多,比如有10000个调用的地方,这时候就不建议使用内联函数了。因为比如说内联函数有30行代码,inline展开就有30W行代码,而这些代码是冗余的,无用重复代码,他会大大增加可执行程序的大小。这时就使用普通函数即可,它只需要消耗10000+30行代码。
对于第三点,我们可以在编译器上来查看一下:
// 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 中被引用
这里为什么会报链接错误呢?我们在学习C语言预处理时知道,程序执行要经过预处理-->编译-->汇编-->链接这几个阶段,而链接要完成的任务是:
1、合并段表,相同的段进行合并;
2、符号表的合并和重定义,选取有效地址。
这里报错是因为符号表合并找不到有效地址而出错。为什么找不到呢?
因为在C++编译器中,当编译器遇到inline关键字后,除了会替换之外,内联函数就不会进入符号表,(这一点和宏是契合的,预处理之后不进入符号表)所以当替换的是内联函数的声明,而不是定义且在当前文件下找不到定义的时候,就会出现错误。
所以这里的建议是,编写内联函数在头文件的时候,不要声明,直接定义在头文件。
如果内联函数是解决宏函数缺陷的问题,那么对于宏定义的比如常量等其他问题我们是如何解决的呢?
C++通常用这些技术替代宏:
1、常量定义换用const enum
2、短小函数定义 换用内联函数。
三、auto关键字🐅
3.1类型别名思考🐱
为什么引入auto这个关键字呢?因为随着程序越来越复杂,程序中用到的类型也越来越复杂,经常体现在:
1、类型难于拼写
2、含义不明确导致容易出错
比如:
#include <string>
#include <map>
int main()
{
std::map<std::string, std::string> m{ { "apple", "苹果" }, { "orange",
"橙子" },
{"pear","梨"} };
std::map<std::string, std::string>::iterator it = m.begin();
while (it != m.end())
{
//....
}
return 0; }
这里std::map<std::string, std::string>::iterator是一个类型,但是该类型太长了,特别容易写错。有读者可能就会提出:可以通过typedef给类型取别名,比如说:
#include <string>
#include <map>
typedef std::map<std::string, std::string> Map;
int main()
{
Map m{ { "apple", "苹果" },{ "orange", "橙子" }, {"pear","梨"} };
Map::iterator it = m.begin();
while (it != m.end())
{
//....
}
return 0; }
但是还是不够方便。在编程时,常常需要把表达式的值赋给变量,这就要求在声明变量的时候清楚地知道表达式的类型。然而有时候要做到这点并不容易,因此C++11给auto赋予了新的含义。
3.2auto简介🐱
auto会根据表达式的类型自行调整为对应的类型,auto 声明的变量必须由编译器在编译时期推导而得 。
int TestAuto()
{
return 10;
}
int main()
{
int a = 10;
auto b = a;
auto c = 'a';
auto d = TestAuto();
cout << typeid(b).name() << endl;
cout << typeid(c).name() << endl;
cout << typeid(d).name() << endl;
return 0;
}
这里的typeid().name()的作用是拿到变量的类型。
注意:
使用auto定义变量时必须对其进行初始化,在编译阶段编译器需要根据初始化表达式来推导auto的实际类型。因此auto并非是一种"类型"的声明,而是一个类型声明时的“占位符”,编译器在编译期会将auto替换为变量实际的类型。
3.3 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;
cout << typeid(b).name() << endl;
cout << typeid(c).name() << endl;
*a = 20;
*b = 30;
c = 40;
return 0;
}
②.在同一行定义多个变量😀
当在同一行声明多个变量时,这些变量必须是相同的类型,否则编译器将会报错,因为编译器实际只对第一个类型进行推导,然后用推导出来的类型定义其他变量 。

3.4 auto不能推导的场景🐱
①auto不能作为函数的参数😀
void Func(auto x)
{
printf("Func(int x)\n");
}
void Func(int x)
{
printf("Func()\n");
}
int main()
{
Func(5.2);
Func(5);
return 0;
}
//错误 C2668 “Func”: 对重载函数的调用不明确
这里,很显然,如果使用auto,编译器是无法区分参数类型的,因为auto任何类型都可以兼容,这样和函数重载也会冲突。
②auto不能直接用来声明数组😀
void TestAuto()
{
int a[] = { 1,2,3 };
auto b[] = { 4,5,6 };
}
//错误 C2119 "b": 无法从空的初始值设定项推导 "auto []" 的类型
//错误 C3318 “auto []”: 数组不能具有其中包含“auto”的元素类型
为了避免与 C++98 中的 auto 发生混淆, C++11 只保留了 auto 作为类型指示符的用法。auto 在实际中最常见的优势用法就是跟以后会讲到的 C++11 提供的新式 for 循环,还有lambda 表达式等进行配合使用。
四、基于范围的for循环🐅
4.1范围for的语法🐱
在C++98中如果要遍历一个数组,可以按照以下方式进行:
void TestFor()
{
int array[] = { 1, 2, 3, 4, 5 };
for (int i = 0; i < sizeof(array) / sizeof(array[0]); ++i)
array[i] *= 2;
for (int* p = array; p < array + sizeof(array)/ sizeof(array[0]); ++p)
cout << *p << endl;
}
对于一个有范围的集合而言,由程序员来说明循环的范围是多余的,有时还会容易犯错,因此C++11中引入了基于范围的for循环。for循环后的括号由冒号":"分为两部分:第一部分是范围内用于迭代的变量,第二部分则表示被迭代的范围。
比如我们想打印一个数组,这样就可以:
void TestFor()
{
int array[] = { 1, 2, 3, 4, 5 };
for (auto e : array)
cout << e << " ";
return;
}
但是,如果我们想对数组里的元素操作,这样是否可以呢?
我们发现,数组里的数据并没有乘以2,说明我们遍历的数组,不是数组本身,而是一个临时拷贝,并不影响数组的值,所以我们如果想对数组里的数操作,需要使用引用:
注意:范围for与普通循环类似,是可以用continue来结束本次循环,也可以用break来跳出整个循环。
4.2范围for的使用条件🐱
①.for循环迭代的范围必须是确定的😀
对于数组而言,就是数组中第一个元素和最后一个元素的范围;对于类而言,应该提供begin和end的方法,begin和end就是for循环迭代的范围。
注意:对于传参的数组,是不能用范围for循环的。
void TestFor(int a[])
{
for (auto& e : a)
cout << e << endl;
}
int main()
{
int a[] = { 1,2,4,5,6,7 };
TestFor(a);
return 0;
}
像这样,传过去的只是一个指针,也就是数组首个元素的地址。
五、指针空值nullptr(C++11)🐅
5.1 C++98中的指针空(NULL)🐱
我想先问一个问题,NULL是一个地址吗?如果你觉得NULL是一个地址,那么请看以下代码:
如果NULL是地址,那么为什么会调用第二个函数而打印出f(int)呢?
这其实和C++98的bug有关,C98中对于NULL的定义,我们可以在头文件(stddef.h)中看到
#ifndef NULL
#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void *)0)
#endif
#endif
我们看到,NULL实际是个宏,NULL可能被定义为字面常量0,或者被定义为无类型指针(void*)的常量,上面很显然程序将它作为0来处理而不是空值的指针。
5.2 空值指针nullptr🐱
为了应对这种错误,C++11引入了nullptr,将其定义为(void*)0,它是一个关键字,不需要引头文件。
注意:
1、在C++11中,sizeof(nullptr)与sizeof((void*)0)所占的字节数相同。
2、为了提高代码的健壮性,在后续表示指针空值时建议最好使用nullptr。