目录
1.引言:
从这篇开始我们来讲讲C++的知识,因为近期在学C++,所以数据结构相关的内容要过段时间才能补上来,为什么用C++而不用C语言了呢,因为C++包容了C语言,还附带了很多便捷的功能,比如封装继承多态,以及STL库之类的
那么这篇我们先来了解了解C++的发展历程和C++中的一些基础语法内容

2.C++发展历程
C++的起源最早可以追溯到1979年,但是C++真正定档是在1998年的时候,这一年标志着C++真正成熟了,这一年有个重要的标志,就是模板被引用进来了,也就是我们熟知的STL库,我们之后讲的C++也主要是围绕98版本展开讲,之后的版本内容我会在C++拓展专辑里给各位讲解,C++的发展历程如下图所示


3.C++参考文档
我们学习一门语言的话,肯定跟C语言一样是需要网站去学习语言的相关知识点的,我这里有三个相关的网站
https://zh.cppreference.com/cpp
注:第一个链接不是C++的官方链接,标准也只更新到了C++11,但是网址是以头文件呈现的,内容比较容易看懂,后面俩个链接分别是C++官方的中文版和英文版链接,信息很全,每个版本的信息也是有的,但是相比于第一个不那么容易看懂,所以这俩种各有优劣,我们可以结合着看
4.C++的重要性
4.1.编程语言排行榜
C++有多重要我们可以看一下2026年的TIOBE榜,如下图所示

4.2.C++在工作领域中的应用
C++虽然还有很多不完善的地方,但因为C++在很多应用领域上都有所涉猎,所以现在C++依旧很能打,上图便可体现出
C++在工作领域的应用也是很广的,有大型系统软件的开发,音视频处理,PC客户端开发,服务端开发,游戏引擎开发,嵌入式开发,机器学习引擎开发,测试开发等岗位
5.C++学习建议和书籍推荐
5.1.C++学习难度
这个应该是挺多人好奇的问题,其实C++是一个相对来说难学难精的语言,相比其他一些语言,学习难度还是要高上一些的
5.2.C++学习建议
我建议是学的同时自己也可以写点博客总结一下,毕竟看懂了自己真的会还是有很大区别的
5.3.C++书籍推荐
这里我推荐三本C ++的书籍,后面俩本我是比较推荐的
第一本:C++ Primer:这本书适合当语法字典,注意!是C++ Primer不是C++ Primer Plus,这俩本书是有点不一样的,我推荐买Priimer
第二本:Effective C++:这本书适合C++学的差不多的时候看,以及工作一俩年时候看,因为写的非常好,讲了很多如何正确高效使用C++的条款
第三本:STL源码剖析:这本书也是适合中后期去看,是从源码的角度来讲解STL的
6.C++的第一个程序
首先在写程序前,我们需要对源文件的后缀进行更改,我们在学C语言的时候,源文件的后缀是.c,当我们要用C++的时候,我们要将源文件改成.cpp为后缀的形式,如下图

在VS里,会根据你的后缀调用不同的编译器,如果是.c结尾的VS就会调用C语言的编译器,如果是.cpp结尾的VS就会调用C++的编译器编译,在Liinux下要用g++编译,不再是gcc
因为C++是兼容C语言的,所以在.cpp环境下也是可以写C语言的代码的,这里我就不演示了
当然,C++也是有一套自己的输入输出的,严格来说C++版本的hello world是这么写的,代码如下
cpp
#include <iostream>
using namespace std;
int main()
{
cout<<"hello world"<<endl;
return 0;
}
现在看不懂没关系,接下来我们来开始讲解C++的基础内容
7.命名空间
7.1.namespace的价值
在C语言中,很容易发生命名冲突的情况,以下面的代码举例,如下图

我们可以发现这个时候是没有问题的,但是如果我们加上stdlib头文件之后,就会提示rand重命名的问题,如下图

这是为什么呢,因为stdlib头文件中有rand函数,用来生成随机数,而我们之前在C语言的时候蹭讲过,在同一个域里面,不可以定义同名的东西,因为计算机区分不开
用C语言进行编写代码,除开上面所可能产生的问题,最大的问题来源于交接时候的问题,就比如在工作的时候,一个项目肯定是有多个人一起敲的,每人负责一部分,但是合并的时候就有可能会有人用一样的变量名,导致程序发生异常冲突
所以,此时,C++就引入了一个新的东西,即命名空间,这个命名空间就完美的解决了命名冲突的问题
接下来我们来看一下什么是命名空间
7.2.namespace的定义
我们先来总的看一下namespace的相关知识,如下
1.定义命名空间,需要使用到namespace这个关键字,后面跟命名空间的名字,然后接一对{}即可,{}中即为命名空间的成员,命名空间中可以定义变量/函数/类型等
2.namespace本质是定义出一个域,这个域跟全局域各自独立,不同的域可以定义同名变量
3.C++中域有函数局部域,全局域,命名空间域,类域,域影响的是编译时语法查找一个变量/函数/类型出处(声明或定义)的逻辑。所以有了域隔离,名字冲突就解决了。局部域和全局域除了会影响编译查找逻辑,还会影响变量的生命周期,但命名空间域和类域不影响变量的声明周期
4.namespace只能定义在全局 ,当然也可以嵌套定义
5.项目工程中多文件中定义的同名namespace会认为是一个namespace,不会冲突
6.C++标准库都放在一个叫std(standard)的命名空间中
我们来一点点讲,先看第一点,这个很简单,就比如上面发生命名冲突的代码,我们将rand放到我们自己定义的命名空间里,就不会有命名冲突的问题了,代码如下
cpp
#include <stdio.h>
#include <stdlib.h>
namespace space
{
int rand = 10;
}
int main()
{
return 0;
}
那么,我想访问命名空间里的rand该怎么访问呢,如果直接访问rand访问的是全局域的rand,这个其实很简单,只需要命名空间后面接俩个冒号然后就可以访问对应域里的成员了,俩个冒号(即::)就是所谓的域作用限定符,用代码演示更简洁明了,如下代码
cpp
#include <stdio.h>
#include <stdlib.h>
namespace space
{
int rand = 10;
}
int main()
{
printf("%d",space::rand);
return 0;
}
有了域作用限定符了后,我们也可以实现C语言无法实现的效果,我们在C语言里如果在全局创了一个a,然后在局部又创了一个a,此时我们在局部访问a时,无法访问到全局的a,但有了C++的域作用限定符后,就可以在局部域访问全局域的变量,域作用限定符前不加命名空间默认访问的就是全局域,模拟代码如下
cpp
#include <stdio.h>
#include <stdlib.h>
int a = 0;
int main()
{
int a = 1;
printf("%d",::a);
return 0;
}
此时输出的就是0不是1了
接下来我们来看看上面的第四点,我们可以知道namespace是可以嵌套定义的,所以,我们就可以实现如下代码的形式
cpp
#include <stdio.h>
#include <stdlib.h>
namespace space
{
int rand = 10;
namespace k
{
int rand = 20;
}
}
int main()
{
printf("%d %d", space::rand,space::k::rand);
return 0;
}
此时就是嵌套定义了,输出的结果也没有问题,即10 20
讲了嵌套定义,我们顺便讲一讲在命名空间中特殊的成员------结构体
与其他的成员不同,结构体的域作用限定符所放的位置是比较特殊的,是要放在struct后面的,因为真正封装的名字在struct之后,演示代码如下
cpp
#include <stdio.h>
#include <stdlib.h>
namespace space
{
int rand = 10;
struct Node
{
int a;
int b;
};
}
int main()
{
struct space::Node p1;
return 0;
}
接下来我们来看第五点,这也是非常巧妙的一点,在C语言中,我们做项目时会用到多文件的编写,在编写数据结构时,也会用到多文件来编写,一个文件包含函数名,一个文件包含对应函数实现功能,此时,为了防止我们和别人实现的是相似的功能,防止发生冲突,我们可以用同一个命名空间包住他们,因为多文件的同一命名空间会认为是一个namespace的这个特性,就给我们做项目带来了很多的便利。
最后一点就是C++的标准库全放在std里面,像cin,cout,list,string等等都是在std里面的,所以一般使用是std::cin,std::cout这样,但为什么我们写helloworld的时候加了个using namespace std之后就可以不用加std了呢,这是命名空间的另一种使用方法,我们在接下来的命名空间的使用里去讲
7.3.命名空间的使用
编译查找一个变量的声明、定义时,默认只会在局部或者全局中查找,不会到命名空间里面去查找,那么我们想要使用命名空间中定义的成员,主要有三种方式,其实我们先前也已经讲了一种了
1.指定命名空间访问(项目中推荐这种方式)
这个就是我们刚刚所讲的一种方式,用域作用限定符来指定命名空间访问
2.using将命名空间中某个成员展开(项目中经常访问的但不会存在冲突的成员推荐这种方式)
每次用都要指定命名空间遇到常用的会很麻烦,这个时候我们就可以将某个成员展开,这种方式既能环节频繁指定命名空间的次数,也可以避免跟第三种方式一样展开过多导致的冲突
这种使用方式也会用到using关键字,但这个using之后不用接namespace,直接接命名空间名随后再用域作用限定符指定想要展开的成员就可以了,示例代码如下
cpp
#include <iostream>
#include <stdlib.h>
using namespace std;
namespace space
{
int r = 10;
int rand = 20;
}
using space::r;
int main()
{
cout << r << endl;
return 0;
}
这个代码中,我们只展开了space中的r,没有展开rand,所以我们想用r的时候直接用就可以,而rand也不会跟头文件中的rand发生冲突
当然,这种方式要避免的就是全局不要有同名的变量或者函数,因为我们把这个成员展开了,直接访问时如果全局也有,电脑就分不清我们要访问的是什么了,当然局部没有关系,我们只需要关心全局和展开的有没有重名就可以了
3.展开命名空间中全部成员(项目不推荐,冲突风险很大,因为全展开的话那开这个命名空间就没什么大意义了,自己练习时候为了方便可以使用)
这也是我们一开始写hello world 代码时候所使用的方式,即在想展开的命名空间前接上一个using关键字,这样就展开了整个命名空间,模拟代码如下
cpp
#include <iostream>
using namespace std;
namespace space
{
int r = 10;
struct Node
{
int a;
int b;
};
}
using namespace space;
int main()
{
cout << r << endl;
return 0;
}
注:想要展开命名空间中的全部成员,要把using这句放在你所对应的命名空间的后面,不能 放在前面,因为这段相当于就是展开的指令,但是你如果放在了前面,你都没定义那怎么展开,自然就会出现问题了
拓展:
using展开命名空间之后再指定命名空间也是可以的
我们对头文件和命名空间都用的展开这一词,但是这两个展开的意思是完全不一样的,
我们先来讲展开头文件,在C语言中,我们讲过,展开头文件是吧头文件里的内容全部拷贝过来,这也就是在编译的预处理过程中解决的。
而展开命名空间不是吧命名空间里的东西拷贝过来,而是相当于把命名空间的阻挡墙给拆掉了,使得编译的时候可以找到这个成员
8.C++的输入&输出
接下来我们来讲一讲C++的输入输出,跟命名空间一样,我先把所有的关键点统揽出来,随后再进行讲解和拓展
1.<iostream>是Input Output Stream的缩写,也就是标准的输入输出流库,定义了标准的输入,输出对象
2.std::cin 是 istream类 的对象,它主要面向窄字符的标准输入流
3.std::cout 是 ostream类 的对象,它主要面向窄字符的标准输出流
4.std::endl 是一个函数,流插入输出时,相当于插入一个换行字符加刷新缓冲区
5.<<是流插入运算符(也叫流输出运算符),>>是流提取运算符(也叫流输入运算符)C语言还用这俩个运算符作为位运算的左移右移运算符
6.使用 C++输入输出更方便,不需要像printf/scanf输入输出时那样,需要手动指定格式,C++的输入输出可以自动识别变量类型(本质是通过函数重载实现的,这个之后会讲到),其实最重要的是C++的流能更好的支持自定义类型对象的输入输出
7.IO流涉及类和对象,运算符重载,继承等很多面向对象的知识,这些知识我们还没讲解,所以这里我们只能简单认识一下C++IO流的用法,后面我会专门来讲解IO流这块的知识
8.cout/cin/endl等都属于C++标准库,C++标准库 都放在一个叫std的命名空间中,所以要通过命名空间的使用方式去用他们
9.一般日常莲师狮吼我们可以用using namespace std来展开std,但实际项目开发中不推荐展开std
10.我们C++的iostream头文件,在不同编译器下有不同的情况 ,有可鞥包了stdio.h,有可能没包,我们在实际写代码过程中如果需要用到stdiio里的库函数,可以先使用 ,报错了再包,像VS里iostream头文件就间接包含了stdio.h这个头文件
第2和第3点所提到的类,可以先把它理解为C语言的结构体,C++的结构体和C语言的结构体也有所不同,这些之后会在类和对象中详细讲,宽窄字符会在之后提到,这里就不拓展了
然后这边要拓展一下的是只有在内存里有整形、浮点之类的概念 ,在其他的地方,比如文件、网络等等都是字符流的概念,所以在2,3点提的都是字符,我举个例子来讲解下,代码如下
cpp
int i = 1234;
int j = -1234;
在C语言中,我们已经讲过了整形有对应的原反补码,浮点型也有对应的存储规则,但这些是存储在内存之中的,只有在内存中才有整形的存储,原反补的存储,浮点的存储等等这些类似的概念,为什么内存中有这些概念呢,是因为这样方便运算,但是到了其他地方就只有字符了,所以我们不管是用cout还是用printf,这些整形什么的其实都是转换成了字符再输出出去的
然后cout输出到的地方默认是终端控制台
接下来我们来看第4点,这个endl 挺复杂的,他其实是个函数,很多人会以为这个的底层就是换行符,但实际不是的,我们来看下endl的源码
cpp
{{cpp/title|endl}}
{{cpp/io/manip/navbar}}
{{ddcl|header=ostream|
template< class CharT, class Traits >
std::basic_ostream<CharT, Traits>& endl( std::basic_ostream<CharT, Traits>& os );
}}
我们可以发现这其实是个函数,但是底层相当复杂,简单的说是运算符重载函数指针然后调用endl,现在你可以把它的行为等价于是put了一个"\n",这个会在之后IO流的讲解中讲解,其实cout和cin也是相当复杂的,我们看下图,这是IO流的一套体系,我们简单了解一下

接下来我们来看第6点,C++的自动识别类型是什么意思呢,就是不用像C语言那样敲占位符来表示是什么类型的,因为会自动识别类型,所以可以直接输出,而且还可以通过流运算符进行多次输入或输出,比C语言的scanf和printf方便很多,我们可以通过代码来对比一下
首先是C语言版的代码如下
cpp
#include <iostream>
#include <stdio.h>
int main()
{
int a;
char b;
float c;
scanf("%d %c %lf", &a, &b, &c);
printf("%d %c %lf", a, b, c);
return 0;
}
然后这是C++版的
cpp
#include <iostream>
using namespace std;
int main()
{
int a;
char b;
float c;
cin >> a >> b >> c;
cout << a << " " << b << " " << c;
return 0;
}
我们可以发现,C++的输入输出相比C语言来说便捷了很多。
然后第6点所说的自定义类型对象的输入输出主要指的是类的输入输出 ,这个会在之后类和对象部分讲解
然后对于换行,我们不仅可以用C++的endl,也可以用 "\n"或'\n',这些都可以换行,能这样玩的原因也是因为能自动识别类型
特别注意:流插入是和流输出是二元操作符,所以只有俩个操作数,不可以出现cout<<a " "这样的情况,因为cout ,a ," "这就成三个操作数了
附:C++和C一样是有控制精度等等的,但是我不建议各位用C++的方式来控制精度,因为相比于C++控制精度,我们先前所学的C语言控制精度的方式其实是更方便的,C++要控制进度要用对应的函数( precision ),C++的控制精度方式如下,想要详细了解的可以去官网了解下
cpp
#include <iostream>
using namespace std;
int main()
{
float c = 3.231234141341;
cout.precision(4);
cout << c << endl;//3.231
cout.precision(1);
cout << c << endl;//3
return 0;
}
这些学完后,最基础要知道的是,cin,cout可以输入输出多个,会自动识别类型,他们在iostream头文件里面,他们在std命名空间里面
最后的拓展 :因为C++要兼容C语言等等原因,因此要付出一些代价,我们在C语言的时候也讲过流的概念,这些IO输入输出都是在缓冲区的,C++和C有各自的缓冲区,那么C++要兼容C,自然要关注C的缓冲区,所以C++IO流性能会比较低,那么这个时候有的算法竞赛用cin,cout就会超时(TLE),这个时候我们有俩种办法解决
第一种:用scanf和printf取代cin和cout的输入输出
第二种:提高C++IO效率,这需要加上下面的三行代码(为什么加这三行可以提高 C++IO效率,这个我会在之后IO流部分具体讲)
cpp
ios_base::sync_with_stdio(false);
cin.tie(nullptr);
cout.tie(nullptr);
9.缺省参数
接下来我们来看C++新加的一个概念,缺省参数,首先我们来看下他的概念
1.缺省参数是声明或定义函数时为函数的参数指定⼀个缺省值。在调用该函数时,如果没有指定实参则采用该形参的缺省值,否则使用指定的实参,缺省参数分为全缺省和半缺省参数。有些地方把缺省参数也叫默认参数
2.全缺省就是全部形参给缺省值,半缺省就是部分形参给缺省值。C++规定半缺省参数必须从右往左依次连续缺省,不能间隔跳跃给缺省值。
3.带缺省参数的函数调用,C++规定必须从左到右依次给实参,不能跳跃给实参。
4.函数声明和定义分离时,缺省参数不能在函数声明和定义中同时出现,规定必须函数声明给缺省值。
我们先来看第一点,什么是缺省参数呢,我们可以通过代码明显的感知到,样例如下
cpp
void solve(int a = 0)
{
...
}
这个代码我们先前所学常见的自定义函数,但是形参部分和我们先前不一样,我们给形参了一个值,这个值就是缺省参数 ,缺省参数可以是常数,也可以是全局变量,但一般来说常用的都是常数
然后当我们调用这个函数的时候,带有缺省参数的形参就会根据是否传了对应实参决定采不采用缺省参数的值,这个我们也可以通过代码来加深理解,代码如下
cpp
#include <iostream>
uusing namespace std;
void solve(int a = 0)
{
...
}
int main()
{
solve();//没有传参,使用缺省参数
solve(100);//传参了,使用指定实参
return 0;
}
上述代码中有俩种情况,一种是传参没有传a的值,此时solve函数中a的值就是缺省参数的值,即0,另一种传了一个100过去,此时solve函数中a的值就是100了
那么,我们先前说了,缺省参数可以是常数,也可以是全局变量,那么为什么全局变量用的少呢,我们来看如下代码分析
cpp
#include <iostream>
using namespace std;
int ret = 100;
void solve(int a = ret)
{
cout << a << endl;
}
int main()
{
solve();
ret = 10;
solve();
solve(11);
return 0;
}
因为a的缺省参数是全局变量,所以a的缺省参数无法固定住,如果更改了对应全局变量,a的缺省参数值也就变了,这就可能导致程序出现点小bug,上图的代码就是因为改变了全局变量导致a的缺省参数值变了,最终输出 100 10 11,如下图

所以为了避免这种风险,一般缺省参数还是用常数的多,在想要实现特殊目的时候才会把将全局变量作为缺省参数
那么讲完单个缺省参数后,我们再来讲讲一个函数整体的缺省参数,缺省参数分为全缺省和半缺省,全缺省就是一个函数的所有形参都有缺省参数,半缺省就是一个函数的形参只有部分有缺省参数,我们来看代码加深理解
cpp
void solve1(int a = 1,int b = 2,int c = 3)
{
...
}
void solve2(int a,int b = 2,int c = 1)
{
...
}
solve1函数就是全缺省,solve2就是半缺省
然后全缺省和半缺省有相关的规定,也就是上面概述中的第二第三点,要记住,要特别注意的是,半缺省参数给缺省值的时候必须要从左往右给,而调用带缺省参数的函数的时候,必须要从左往右给实参,不能跳着给
最后是第四点,函数声明和定义如果是分离的,那么只能在函数声明里给缺省值,定义中就不要给缺省值了(这是为了避免声明和定义时给的缺省值不一样的情况),我们来看代码加深理解
cpp
#include <iostream>
using namespace std;
void solve(int a = 0);
void solve(int a)
{
cout << a << endl;
}
int main()
{
solve();
solve(11);
return 0;
}
那么这个缺省参数有什么用呢,我们来举个应用场景
假设有一个队列,我们已知要往这个队列中塞1000个数据,此时我们如果用先前数据结构的方式初始化,那么一开始只能生成极小的空间然后慢慢扩 ,但是我们可以在队列初始化的函数中加一个缺省参数,如果没有传值就用缺省值进行开辟,如果传值了,就开辟实参大小的空间,这样就可以在空间操作上减少大量时间
缺省参数在之后讲类和对象部分也会经常用到,非常好用之
还有一个便于理解缺省参数的话:做人不要做缺省参数,为什么这么说呢,缺省参数形象一点是不是就是现实中的备胎或者舔狗,如果人家不需要的时候(也就是有实参传的时候),缺省参数就没什么用了,当需要的时候,就出现。所以要做就要做实参之~
10.函数重载
这也是一个C++的小语法,C语言在同一作用域是不支持同名函数,但是C++在同一作用域是支持同名函数的,但是要求这些同名函数的形参不同,可以是参数个数不同,也可以是参数类型不同,这样C++函数调用就表现出了多态行为(这个会在C++进阶中讲),使用更灵活,这个在C++中叫函数重载。
我们来体会一下函数重载的便利之处
首先是参数类型不同的重载,代码如下
cpp
#include <iostream>
using namespace std;
void Add(int a, int b)
{
cout << a + b << endl;
}
void Add(double a, double b)
{
cout << a + b << endl;
}
int main()
{
Add(10, 20);
Add(10.3, 20.9);
return 0;
}
我们如果想要实现加法,通过函数重载就实现了,但是在C语言中,如果想要实现这种效果因为不能用同名函数,所以就要用到函数指针来解决这种类型不同的问题,但是相当麻烦,因为解决这种不仅需要函数指针还要函数回调,不太了解或者感兴趣的可以去看下这篇中的函数指针部分C语言指针详解_c语言的指针讲解 csdn-CSDN博客。由此便可以体现出C++函数重载的方便之处
还有参数个数不同的情况,如下
cpp
void fnc1()
{
...
}
void fnc1(int a)
{
...
}
参数顺序不同(本质也是类型不同,因为是看类型是否相同是从左往右一一比对类型)如下
cpp
void fnc1(int a,char b)
{
...
}
void fnc1(char a,int b)
{
...
}
特别注意,函数重载要注意缺省参数的影响,举个例子
cpp
void f(int a = 10)
{
cout << a << endl;
}
void f()
{
cout << 0 << endl;
}
这俩个函数是构成函数重载的,一个带参,一个无参,但是调用他们的时候如果不传参,那么就会报错,因为缺省参数不带参也可以进入,下面那个函数不带参也可以进入,那么系统就不知道要进入的是哪个函数了,所以调用时候会产生歧义,之后在构造函数部分也会讲这个问题,如果硬是想要这俩种一起用,那只能把他们放到不同域里面才能这么搞,但是这样就不是函数重载了
拓展:函数重载的底层是C++通过一个叫函数名修饰规则的一个东西支持,这个会在之后
11.引用
11.1引用的概念和定义
引用不是定义一个新的变量,而是给已存在的变量取别名,编译器不会为引用变量开辟内存空间,它和它引用的变量共用同一块内存空间。这就好比古代有人给自己取谥号,又例如鲁迅和周树人一样的道理。常见使用方式如下:
类型& 引用别名 = 引用对象
C++中为了避免 引入太多的运算符,会复用C语言的一些符号,比如前面的<<和>>,这里引用也和取地址使用了同一个符号&,要注意区分
注:可以对别名取别名
我们通过代码来加深一下理解
cpp
#include <iostream>
using namespace std;
int main()
{
int a = 0;
int& b = a;
int& c = a;
int& d = b;
cout << &a << endl
<< &b << endl
<< &c << endl
<< &d << endl;
return 0;
}
放在对象前面就是取地址,放在类型后面就是引用,上面的代码中,b是a的别名,c是a的别用,d是b的别亦难,但因为b是a的别名,所以d是a的别名,那么就说明,a,b,c,d都是同一个对象,我们可以通过他们的地址发现他们确实是一个对象,这四个变量的地址如下

11.2.引用的特性
引用在定义时必须初始化
像下面这种情况就是定义时没初始化,这种是不行的。
cpp
int& a;
引用可以给别名取别名
这个我们在前面就用到过了,就比如如下这个代码,a,b,c都是一个对象
cpp
int a = 0;
int& b = a;
int& c = b;
一个变量可以有多个引用
引用一旦引用一个实体,就不能引用其他实体
cpp
#include <iostream>
using namespace std;
int main()
{
int a = 0;
int& b = a;
int c = 20;
b = c;
return 0;
}
像这个代码,因为引用不能改变指向,b已经是a的别名了,所以b=c不是说b的别名变成c,而是将c赋值给b
11.3.引用的使用
接下来我们来讲讲引用的实践,首先我们来统一看应用在实践中的点
引用在实践中主要是引用传参和引用做返回值 ,这样可减少拷贝提高效率 以及改变引用对象时同时改变被引用对象
引用传参跟指针传参是类似的,引用传参相对更方便一些
引用返回值的场景相对比较复杂,主要会在后续博客中讲解
引用和指针在实践中相辅相成,各有特点,互相不可替代,C++的引用跟其他语言的引用是有很大区别的,除了用法,最大的点在于C++的引用定义后不能改变指向,Java的引用是可以改变指向的
拓展:引用的底层依旧是指针
说完点后,我们来通过案例体会引用的便捷之处
首先我们先来讲讲引用传参
先看如下代码
cpp
#include <iostream>
using namespace std;
void Swap(int* a, int* b)
{
int p = *a;
*a = *b;
*b = p;
}
int main()
{
int a = 0, b = 10;
Swap(&a, &b);
cout << a << endl << b << endl;
return 0;
}
这是我们用先前C语言所学的知识也就是用指针的形式实现,那么,我们来看一下C++库里自带的swap函数是如何使用的,代码如下
cpp
#include <iostream>
using namespace std;
int main()
{
int a = 0, b = 10;
swap(a, b);
cout << a << endl << b << endl;
return 0;
}
我们可以发现,C++库函数中的swap函数传参时并不需要取地址,这就是引用传参的好处,我们来看如下代码
cpp
#include <iostream>
using namespace std;
void Swap(int& a, int& b)
{
int c = a;
a = b;
b = c;
}
int main()
{
int a = 0, b = 10;
Swap(a, b);
cout << a << endl << b << endl;
return 0;
}
我们可以发现,有了引用传参后,就会比C语言方便很多,以前实参传给形参是拷贝的方式,但这个方式,相当于是取别名,而别名的改变就会导致原实参的改变。
当然C++中,上面俩种交换也可以构成函数重载,如下
cpp
#include <iostream>
using namespace std;
void Swap(int& a, int& b)
{
int c = a;
a = b;
b = c;
}
void Swap(int* a, int* b)
{
int c = *a;
*a = *b;
*b = c;
}
int main()
{
int a = 0, b = 10;
Swap(a, b);
cout << a << " " << b << endl;
Swap(&a, &b);
cout << a << " " << b << endl;
return 0;
}
我们通过运行完后的效果可以发现确实是发生了函数重载

我们再来看一个指针和引用都用到的情况,我们一步步来推演
首先看最熟悉的代码
cpp
typedef struct ListNode
{
int val;
struct ListNode* next;
}LTNode;
void ListPushBack(LTNode** phead, int x)
{
....
}
int main()
{
LTNode* p1 = NULL;
ListPushBack(&p1, 1);
return 0;
}
我们在数据结构部分中比如链表部分会用到二级指针,上面那个代码便是链表最初的样子,随后通过C语言中我们所学的知识,可以优化成这样,即对结构体指针重命名为PNode
cpp
typedef struct ListNode
{
int val;
struct ListNode* next;
}LTNode, * PNode;
void ListPushBack(PNode* phead, int x)
{
....
}
int main()
{
PNode p1 = NULL;
ListPushBack(&p1, 1);
return 0;
}
那么学了引用后,我们可以在这个的基础上,基础优化,最后可以优化成如下代码
cpp
typedef struct ListNode
{
int val;
struct ListNode* next;
}LTNode, * PNode;
void ListPushBack(PNode& phead, int x)
{
....
}
int main()
{
PNode p1 = NULL;
ListPushBack(p1, 1);
return 0;
}
一般常用的数据结构书里很多就是用这种写法来实现链表的,当然,下面这俩种方式其实都是可以的,如下
cpp
void ListPushBack(PNode& phead, int x)
{
....
}
void ListPushBack(LTNoode*& phead, int x)
{
....
}
第一个对PNode类型取别名其实就是给LTNode*类型取别名,通过这个样例,也进一步认证了指针和引用是相辅相成的,不能完全替代对方
接下来就是引用做返回值的场景,这个主要在类和对象部分会讲,这里就简单的举个例子
首先,我们要分清什么是传值返回,什么是传引用返回
这是传值返回
cpp
int solve()
{
...
}
这是传引用返回
cpp
int& solve()
{
...
}
首先,在C语言的函数栈帧部分,我们讲过,如果是传值返回,那么返回的值会先寄存在寄存器里,也就是生成了一个临时对象用来存储返回值,但是临时对象是有常性的,也就是说是不能修改的,那么下面这种写法就行不通,如下代码
cpp
#include <iostream>
using namespace std;
int Add(int& a)
{
return a;
}
int main()
{
int k = 10;
Add(k) += 1;
cout << k << endl;
return 0;
}
但是我们改成传引用返回的话就可以了,代码如下,此时输出结果为11
cpp
#include <iostream>
using namespace std;
int& Add(int& a)
{
return a;
}
int main()
{
int k = 10;
Add(k) += 1;
cout << k << endl;
return 0;
}
引用作返回值的意思就是返回他的别名,改变引用就是改变被引用对象,所以这就很方便了,在C++中引用做返回值的场景是非常多的,这里就浅浅提一嘴
当然也不是什么情况都可以用引用返回,就比如下面这个情况
cpp
#include <iostream>
using namespace std;
int& solve()
{
int a = 10;
return a;
}
int main()
{
solve() = 3;
return 0;
}
这种情况是不能用引用返回的,因为a是solve函数内的变量,当solve函数结束的时候,a的空间我们就无权访问了,但是我们返回了a的别名,我们先前提过,引用的底层是指针,那么这种就跟野指针的操作类似了
11.4.const引用
首先,我们来统揽下const引用的关键点
1.可以引用一个const对象,但是必须用const引用。const引用也可以引用普通对象,因为对象的访问权限在引用过程中可以缩小,但不能放大(因为引用的底层是指针,所以引用也跟指针一样,有权限放大缩小情况)
2.不需要注意的是类似 int& rb = a*3; double d = 12.34;int& a = d; 这样一些场景下a*3的结果会保存在一个临时对象中,int& a = d也是类似,在类型转换中会产生临时对象存放中间值,也就是说,a和rb引用的都是临时对象,而C++规定临时对象具有常性,所以这里就出发了权限放大,必须要用常引用才可以
3.所谓临时对象就是编译器需要一个空间暂存表达式的求值结果时临时创建的一个未命名的对象,如果小会存放在寄存器里,大的话会存放在栈空间里,C++中把这个未命名对象叫做临时对象
首先我们来看第一点 ,我们先来解释引用const对象必须用const引用这句话是什么意思
首先,const在C++中给对象赋予了常性,也可以说const修饰的变量在C++中就是常量,但我们知道引用底层是指针,所以会有权限放大缩小问题,那么const修饰的变量是只读不能改的,如果引用时候不加const,那么就会有权限放大的问题,这个在引用中是不允许的,引用只能权限缩小。我们来看代码
cpp
#include <iostream>
using namespace std;
const int a = 10;
int main()
{
int ll = 0;
int& b = a;//权限放大了,这是错误的
const int& c = a;//权限平移,正确的
const int& d = ll;//权限缩小,可以的
return 0;
}
在上面的代码中,给a对象起的别名b可读可写,权限放大了,所以是错误的
给a对象起的别名c只读 ,和a一样,权限平移,是可以的
ll是可读可写的变量,给ll取别名为d,d只读,权限缩小了,也是可以的
我们来看第二点
我们可以给常量或表达式取别名,因为常量是只读的,所以我们只需要把引用的别名权限也限制成只读就可以了(本质其实是常量和表达式最后的值会在临时对象里,相当于取别名是在对临时对象取别名),比如下面的代码
cpp
#include <iostream>
using namespace std;
int main()
{
int a = 10, b = 20;
const int& c = 20;
const int& d = a + b;
return 0;
}
在给常量或表达式取别名的情况下,前后的数据类型是就算不匹配也不会报错,因为在把常量值附给别名前,常量会先跟别名的类型进行匹配,也就是C语言的类型转化过程,在类型转换中会产生临时对象存放中间值,别名引用的就是临时对象。如下
cpp
#include <iostream>
using namespace std;
int main()
{
const int& a = 12.13;
return 0;
}
注:如果取别名的对象是变量但是数据类型不一样也要加const,因为会发生类型转化,而 类型转化就会和我上面说的情况一样了
我们可以发现,const引用的作用是很大的,const不仅可以引用普通对象,还可以引用const对象,还能引用临时对象。而且,当const引用临时对象后,临时对象的生命周期就会跟着引用去走。只有当对应的引用销毁后,临时对象才会销毁
这个东西以后的价值主要体现在函数传参里面,这里浅浅提一下
首先看下面的代码
cpp
template<class T> //函数模板,T可以是任意类型
void solve(T val)
{
...
}
上面代码中T可以是任意类型,但是我们如果函数这么写,那么就是传值传参了,但是传值传参需要拷贝,如果T类型很大的话,那么就会浪费很多内存和时间,这个使用,引用就能完美解决这个问题
那么,我们就可以把上面的函数优化到下面的这种代码
cpp
template<class T> //函数模板,T可以是任意类型
void solve(T& val)
{
...
}
但是这种引用无法把常量和运算表达式给引用过去,这个时候如果这个类型里的元素我们不用的话,就可以用const引用来优化,也就是如下代码
cpp
template<class T> //函数模板,T可以是任意类型
void solve(const T& val)
{
...
}
我们来应用下就发现有了const引用后方便了很多,既可以传常量,又可以传普通对象,还可以传临时对象,如下
cpp
#include <iostream>
using namespace std;
template<class T> //函数模板,T可以是任意类型
void solve(const T& val)
{
...
}
int main()
{
int t = 10;
int& b = t;
const int& a = 12.13;
const int& c = a + b;
solve(a);
solve(t);
solve(c);
return 0;
}
这个主要是为以后做铺垫的,一定要理解透
11.5.指针和引用的关系
最后,我们来看下指针和引用的差别
1.语法概念上引用是一个变量的取别名不开空间,指针是存储一个变量地址,要开空间
2.引用在定义时必须初始化,指针是建议初始化为NULL,但是也是可以不初始化的
引用在初始化时引用一个对象后,就不能再引用其他对象,而指针可以不断改变指向的对象
3.引用可以直接访问指向对象,指针需要解引用才是访问指向对象
4.sizeof中含义不同,引用结果为引用类型的大小 ,但指针始终是地址空间所占字节个数(即32位平台下为4个字节,64位平台下为8个字节)
5.指针很容易出现空指针和野指针的问题,引用相对而言很少出现
接下来的拓展一和拓展二都是了解内容,知道就行了
拓展一:我们已经知道在语法层面上,引用是取别名不开空间,指针是开空间,但是引用的底层是指针,那么引用是怎么实现的呢,我们可以通过反汇编的来看一下
我们通过一个例子来看一下,下面的是样例代码
cpp
#include <iostream>
using namespace std;
int main()
{
int a = 0;
int* p = &a;
*p = 1;
int& b = a;
b = 2;
return 0;
}
转到反汇编后,我们来看核心部分的汇编代码是如何实现的

在这块核心部分,只有俩个指令,一个是mov,一个是lea,mov指令是把第二个值移动到第一个位置,lea指令是取第二个的地址随后存储到第一个位置
有的人会有疑问,为什么这些汇编代码中空间会用到寄存器,这里再提一嘴,因为寄存器是在CPU中,CPU的处理速度是远高于内存的处理速度的,所以会用寄存器做中转。但因为寄存器能存的很小且寄存器数量很少,所以当数据量很大的时候,会用缓存做中转,但无可避免都是要做中转的,因为内存的操作速度太慢了
指针部分的操作就是先将 a 的地址取出来,然后放到 rax 寄存器中,随后将rax中的值移动到p上。
通过指针改值的操作也是俩步,第一步是把 p 移动到 rax 寄存器中,因为 p 就是地址,所以 rax 存的就是 a 的地址,随后第二步就是将1移动到 rax 的位置,也就是 a 的位置,这也就实现了通过指针对 a 的值的更改
接着我们来看引用部分的操作,我们可以发现引用部分的操作和指针部分的操作在汇编代码上是一模一样的,所以引用和指针其实底层是一样的,都是指针。
拓展二:拓展一了解了后,我们来讲讲拓展二,首先我们看上面的第五点,引用和指针一样存在空引用也存在野引用
野引用在上面我们就已经讲过了,比如在传引用返回的情况下,如果返回的是函数体内的对象,那么就相当于产生了野引用
这里重点讲的是空引用,首先,我们先看下面的这个代码
cpp
#include <iostream>
using namespace std;
int main()
{
int* p = NULL;
int& rb = *p;
return 0;
}
这个代码是没有问题的,因为p是指针,p指向的地址是空,rb 是 *p 的别名 ,*p就是NULL,那么为什么没报错呢,我们刚讲了拓展一,从引用的底层来看就会明了很多,这个rb其实就是个指针,他存的就是*p的取地址,也就是p,即NULL,将NULL移动到了rb上,但是这个时候我们并没有对rb进行操作,所以没有报错,当加上这一行后就会报错了
cpp
#include <iostream>
using namespace std;
int main()
{
int* p = NULL;
int& rb = *p;
rb++;
return 0;
}
因为这个时候rb的地址其实是NULL的,我们对rb++,底层其实是先将rb地址移动到寄存器里,随后对那个位置进行运算 ,但是rb存的地址是NULL,这也就导致了对空指针的操作,这也就导致了空引用
12.inline
接下来我们来看下C++新增的一个关键字------inline,这个关键词是用来修饰函数的,被这个关键字修饰的函数叫内联函数
1.用inline修饰的函数叫做内联函数,编译时C++编译器会在调用的地方展开内联函数,这样调用内联函数就不需要建立栈帧了,可以提高效率
2.inline对于编译器而言只是一个建议,也就是说,如果你加的inline编译器也可以选择调用的地方不展开,不同编译器对于inline什么情况展开各不相同,因为C++标准没有规定这个。inline适用于频繁调用的短小函数,对于递归函数或者代码相对多一些的函数,加上inline也会被编译器忽略
3.C语言实现宏函数也会在预处理时替换展开,但是宏函数实现很复杂且很容易出错,最主要的是还不方便调试,因此C++涉及inline这个关键字的目的就是替代C的宏函数
4.VS编译器debug版本下面默认是不展开inline的,因为这样方便调试
5.inline不建议声明和定义分离到俩个文件,分离会导致链接错误,因为inline被展开,就没有函数地址,链接时会出现报错
首先来看第一点,展开内联函数就可以把他理解成C语言的宏函数的替换,那么怎么使用inline呢,相比宏函数而言十分简单,只需要你正常写好函数,然后在前面加上一个inline关键字就好了,我举个实例如下
cpp
#include <iostream>
using namespace std;
inline int Add(int a, int b)
{
return a + b;
}
int main()
{
cout<< Add(1, 2);
return 0;
}
然后是第二点,inline虽然加了,但是是否展开还是取决于编译器(这是为了预防代码膨胀),这个就和我们C语言时候讲的那个register关键词很像,register关键字的作用就是将变量放到CPU寄存器里,而不是内存中,但是否放还是取决于编译器,就算不写register关键字,编译器还是会看情况将部分变量隐式加上register,inline也是如此,你就算不写inline,系统也会根据情况加隐式inline
随后是第三点,inline内联函数实现的效果其实是和宏函数差不多的,但是为什么不用宏函数而用inline呢,因为inline相比于宏函数而言更容易创建,且更不容易犯错误,并且inline可以调试,宏函数不能调试
我们来看下宏函数的弊端,首先,宏函数虽然是宏函数,但是他的写法和函数其实关系不大,并且写的时候有很多注意事项,就以加法函数的实现举例,常见的写法有五种,如下
cpp
#define Add(a,b) a+b
#define Add(a,b) (a)+(b)
#define Add(a,b) (a+b)
#define Add(a,b) ((a)+(b));
#define Add(a,b) ((a)+(b))
前四种写法全是错的,只有最后一种是对的,为什么最后一种是对的不知道但感兴趣的可以去看下这篇博客C语言预处理详解(C语言知识完结篇)-CSDN博客,这里就不讲了
我们可以发现,宏函数和inline内联函数的差别是相当大的,尤其是如果遇到比较复杂的函数的时候,用宏函数写是非常难受的
接下来我们来看第四点,Debug版本下inline默认不展开,那么可以让inline在debug版本下也展开吗,是可以的
首先右击项目点属性,如图

然后点击C/C++,再点击常规,将调试信息格式改为程序数据库,然后再点优化,将内联函数拓展改为只适用...即可,如图


最后一点就是内联函数不能声明和定义分离到俩个文件中,为什么会出现这个问题呢,这里就小小拓展一下
内联函数编译器默认是认为不需要地址的,因为都在调用的地方展开了,所以链接的时候,找不到地址,但是展开也展开不了 ,因为声明和定义和定义分离了,但是一般包头文件包的都是声明的头文件,这就导致找不到这个函数具体是怎么实现的,也就无法展开了,所以只能转到call,但是call又因为地址找不到,这就导致了声明定义分离俩个文件时会出问题
那么如何解决这个问题呢,只需要把内联函数的定义也写在.h里面就可以了,这样方便调用时的展开
13.nullptr
这个是C++11后才添加的一个关键字,这也就是空指针,那么已经有了C语言的NULL了为什么C++又新添了一个空指针呢
我们先来回顾下C语言的空指针,也就是NULL,在C语言中我们讲过,其实NULL实际就是一个宏,他的定义是这样定义的
cpp
#ifndef NULL
#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void *)0)
#endif
#endif
我们可以发现是用条件编译实现的,条件编译这里就不具体讲了,不了解的可以去看下这篇博客C语言预处理详解(C语言知识完结篇)-CSDN博客。如果是在C语言里就是将0强转成void*,如果在C++里就是一个0。但是这种方式会有一个巨大的缺陷
我们通过一个例子来看看缺陷在哪
cpp
#include <iostream>
using namespace std;
void f(int a)
{
cout << "f(int)" << endl;
}
void f(int* a)
{
cout << "f(int*)" << endl;
}
int main()
{
f(0);
f(NULL);
return 0;
}
首先上面那个代码中函数f构成了函数重载,f(0)走的毋庸置疑是第一个,但是f(NULL),我们本意是传空指针,但是因为NULL在C++中被替换成了0,这就导致实际跑到的优势第一个函数上,这就和我们的目的是有违的,运行结果如下图

那么我们把C++的NULL改成C语言的写法可行吗,也就是如下代码
cpp
f((void*)NULL);
f((void*)0);
但是我们可以发现,这种写法在C++中报错了,这是为什么呢,依旧是函数重载的问题,首先我们传过去的参数是void*类型的,C语言和C++的void*类型指针是有区别的,C 语言的void*指针可以给任何指针不用强转,但是C++中void*指针是需要强转的(所以像malloc函数开辟空间这种返回void*类型的,在C语言接收的时候就不用强转成对应类型,但是C++接收的时候就一定要强转)。但是俩个函数一个是int类型,一个是int*类型,都不匹配,那么就会发生隐式类型转换,但是,要隐式转换成int呢还是隐式转换成int*呢,这个时候系统就不知道了,这就跟函数重载时候用缺省参数可能导致错误是一个情况。
为了解决这种情况,C++引入了一个新的东西来彻底解决,也就是我们要将的nullptr
nullptr是一种特殊类型的字面量,它可以转换成任意其他类型的指针类型(这是编译器在编译时候处理的)。使用nulllptr定义空指针就可以避免类型转换的问题,因为nullptr只能被隐式转换为指针类型,而不能被转换为整数类型
我们看一下用nullptr的效果
cpp
#include <iostream>
using namespace std;
void f(int a)
{
cout << "f(int)" << endl;
}
void f(int* a)
{
cout << "f(int*)" << endl;
}
int main()
{
f(nullptr);
return 0;
}
运行结果如下图
当然,nullptr虽然解决了指针和别的类型函数的重载问题,但是如果遇到函数重载中有指针 类型不同的情况下,nullptr也是不知道选择哪个函数的。这么时候要么强转nullptr要么就用对应的指针类型调用重载函数
总结:之后C++用空指针用nullptr就行,C语言的NULL有坑
14.结语
那么,C++入门基础部分就讲解完毕啦,希望以上内容对你有所帮助,感谢观看,若觉得写的还可以,可以分享给朋友一起来看哦,毕竟一起进步更有动力嘛,当然能关注一下就更好啦
