【C++】—— 从 C 到 C++ (下)
六、引用
6.1、什么是引用
在大家学习C语言指针的部分时,是不是觉得指针特别难学,还有点繁琐,每次使用都要取地址、解引用、还有二级指针什么的。为此,C++引入了引用的语法
引用并不是新定义一个变量,而是在已有变量的基础上给它取一个别名,编译器不会为引用变量开辟新的内存空间
,它和它的引用变量公用一块内存空间。
什么意思呢?就拿《水浒传》中的任务来说,林冲别名叫豹子头、李逵别名叫黑旋风;林冲和豹子头指向的是同一个人,李逵和黑旋风也是指向同一个人
类型& 应用别名 = 引用对象;
C++ 中为了避免引入太多的运算符,会复用 C语言 的一些符号比如 <<
和 >>
,这里引用也和取地址用了同一个符号 &,大家应注意区分:放在类型后面是引用
,放在对象前面是取地址
。
#include<iostream>
using namespace std;
int main()
{
int a = 0;
// 引⽤:b和c是a的别名
int& b = a;
int& c = a;
// 也可以给别名b取别名,d相当于还是a的别名
int& d = b;
++d;
cout << &a << endl;
cout << &b << endl;
cout << &c << endl;
cout << &d << endl;
return 0;
}
上述代码中,给 a a a 取别名 b b b,再给 a a a 取另一个别名 c c c,最后再给 a a a 别名 b b b 取别名 d d d。
可以看到是可以给别名取别名的
什么意思呢?
我们看下面的图就明白了
a a a、 b b b、 c c c、 d d d 指的是同一块空间,这块空间放着 0,什么这是这块空间的不同称谓而已,
我们取地址发现方面的地址都是一样
的,也可以印证这一点
6.2、引用在传参的使用
6.2.1、例一
引用在实践中主要用于 函数的传参和返回值 中,引用的使用会比指针更容易
和方便
我们写一个交换函数来感受一下
众所周知,传参分为传值传参
和传值传参
,传值传参仅仅是实参的一份拷贝,要想改变实参的值,必须用传址传参
而现在,除了用指针来处理,还可以用引用,并且引用比指针更加方便
#include<iostream>
using namespace std;
void swap(int& rx, int& ry)
{
int temp = rx;
rx = ry;
ry = temp;
}
int main()
{
int a = 10;
int b = 20;
swap(a, b);
cout << "a = " << a << endl << "b = " << b << endl;
return 0;
}
运行结果:
可以看到,交换是成功的
在void swap(int& rx, int& ry);
函数中,传递实参 a a a 和 b b b 后, r x rx rx 和 r y ry ry 就是 a a a 和 b b b 的别名,既然是别名他们就指向同一块内存空间
,对别名 r x rx rx 和 r y ry ry 的修改就是对 a a a 和 b b b 的修改
可以看到,相比于使用指针,用引用就方便了很多,首先是传参时不用再取地址
,函数内部也不用再需要解引用
。
6.2.2、例二
在一些主要由 C语言 写成的数据结构的书本中,作者会认为指针太难,怕读者难以理解,而使用引用代替指针
,以达到简化程序的目的
举个栗子
typedef struct ListNode
{
int val;
struct ListNode* next;
}LTNode, * PNode;
void ListPushBack(PNode& phead, int x)
{
PNode newnode = (PNode)malloc(sizeof(LTNode));
newnode->val = x;
newnode->next = NULL;
if (phead == NULL)
{
phead = newnode;
}
else
{
//...
}
}
该段代码用 t y p e d e f typedef typedef 将 s t r u c t struct struct L i s t N o d e ListNode ListNode 重命名为 L T N o d e LTNode LTNode, s t r u c t struct struct L i s t N o d e ListNode ListNode* 重命名为 P N o d e PNode PNode
void ListPushBack(PNode& phead, int x)
这段代码中形参PNode& phead
我们一起来理解一下
这是链表的尾插函数,当链表中无数据时,尾插相当于头插,是要改变指针 p l i s t plist plist(代码中没写)的指向的.
因为 p l i s t plist plist 本身是一个指针,因此要传二级指针
。这里是用了引用
,即给传递过来的 p l i s t plist plist 指针 s t r u c t struct struct L i s t N o d e ListNode ListNode* p l i s t plist plist 起别名
为 p h e a d phead phead, p l i s t plist plist 即 p h e a d phead phead,这样就不用二级指针了。
形参 s t r u c t struct struct L i s t N o d e ListNode ListNode * & p h e a d phead phead,因为将 s t r u c t struct struct L i s t N o d e ListNode ListNode * 重命名
为 P N o d e PNode PNode,所以是 P N o d e PNode PNode& p h e a d phead phead。
6.3、引用在做返回值的使用
引用做返回值
在类和对象
中有很好的应用,这里我们简单提一下
现在有一个栈,我们想改变他的栈顶元素,怎么改呢?
void STModityTop(ST& rs, int x)
{
rs.a[rs.top - 1] = x;
}
现在我们可以运用引用做返回值
来完成
我们先来看看传值返回
int STModityTop(ST& rs)
{
assert(rs.top);
return rs.a[rs.top - 1];
}
int main()
{
ST st;
STInit(st);//初始化栈
STPush(st, 1);//插入数据
//修改栈顶元素
STModityTop(st) = 3;
return 0;
}
可以看到报错了,显示的是左操作数必须为可修改的左值
首先我们要知道一个知识点:
函数的返回值并不是直接返回运算结果,它会将最终要返回的运算结果拷贝
到在一个临时对象中(与传值传参类似),这个临时对象再做为函数的返回值,而临时对象具有常性(规定),因此是不能直接修改返回值
的。
所谓临时对象,就是编译器需要一个空间
暂存函数、表达式等返回值结果时创建的一个未命名的对象
,C++中吧这个未命名的对象叫做临时对象
但是,今时不同往日,现在引用
来啦
int& STModityTop(ST& rs)
{
assert(rs.top);
return rs.a[rs.top - 1];
}
int main()
{
ST st;
STInit(st);//初始化栈
STPush(st, 1);//插入数据
//修改栈顶元素
STModityTop(st) = 3;
return 0;
}
函数用引用作为返回值就可以直接修改函数返回值
了
这是因为引用做返回值就是返回它的引用,中间没有产生临时对象
那既然引用这么好用,那我们能不能全部用引用返回呢?
可定是不能的
int& func()
{
int a = 0;
return a;
}
这个函数我返回 a a a 的别名(引用)会怎样?
要知道 a a a 在出了函数作用域就销毁
了,这是返回 a a a 的别名会出现类似野指针一样的情况
上述例子可用引用返回是因为引用返回的对象不会因为出了函数作用域而销毁
,它是在堆
上创建的。
6.4、引用的特性
了解了引用的使用,相信大家对引用由了初步的感觉,接下来我们一起来了解一下引用特性
- 引用在定义时必须初始化
int main()
{
int a = 10;
int& b;
return 0;
}
一个变量可以有多个引用
这一点,在前面介绍引用的使用时已经介绍过了,这里就不再介绍了
引用一旦引用一个实体就不能再引用其他实体
我们想一个问题:
void ListPushBack(PNode& phead, int x);
void ListPushBack(LTNode&& phead, int x);
既然可以用一次引用让二级指针变为一级指针,那能不能直接用两次引用 && 替代二级指针呢?
答案是不能
:因为引用时不能改变指向的,而链表这个头结点的指针是需要不断改变指向
的
同理,链表中的指向下一个节点的变量 n e x t next next 也只能用指针
指针和引用更多的是 相辅相成 的效果,而不是取代
6.5、引用的使用总结
- 引⽤在实践中主要是于引⽤
传参
和引⽤做返回值
中减少拷⻉提⾼效率和改变引⽤对象时同时改变被引⽤对象。 - 引⽤传参跟指针传参功能是类似的,引⽤传参相对
更⽅便
⼀些。 - 引⽤返回值的场景相对⽐较复杂,我们在这⾥只是简单讲了⼀下场景,还有⼀些内容后续类和对象章节中会继续深⼊讲解。
- 引⽤和指针在实践中
相辅相成
,功能有重叠性,但是各有特点,互相不可替代
。C++的引⽤跟其他语⾔的引⽤(如Java)是有很⼤的区别的,除了⽤法,最⼤的点,C++引⽤定义后不能改变指向
, J a v a Java Java 的引⽤可以改变指向。
6.6、 c o n s t const const 引用
6.6.1、 c o n s t const const 引用的规则
在【C语言】—— 指针二 : 初识指针(下)中,我们了解到 c o n s t const const 修饰变量和 c o n s t const const 修饰指针。这里简单回顾一下。
- c o n s t const const 修饰变量:变量
不可被修改
,变成只读
- c o n s t const const 修饰指针:
- c o n s t const const在 ∗ * ∗ 的左边:修饰的是
指针所指向的内容
,指针所指向的内容不可被修改 - c o n s t const const在 ∗ * ∗ 的右边:修饰的是
指针本身
,指针的指向不能被修改。
- c o n s t const const在 ∗ * ∗ 的左边:修饰的是
下面我们来看看 c o n s t const const 引用。
那我们对 c o n s t const const 修饰的变量进行引用,是不是要用 c o n s t const const 引用呢
int main()
{
const int a = 10;
const int& ra = a;
return 0;
}
可以看到编译是通过的
那如果用普通的引用会发生什么
int main()
{
const int a = 10;
int& ra = a;
return 0;
}
可以看到是编不过去的。
其实也是,我自己本身的访问权限都只是只读,你变成我的别名后你竟然告诉我可以读写,这合理吗?不合理。
这里我们了解一下权限放大和权限缩小的概念
- 权限放大:当某个变量或文件等只有只读的权限,我们赋予它读写的权限,就是权限放大
- 权限缩小:当某个变量或文件有读和写的权限,我们限制它其中某一项,就是权限缩小
第二个例子就是权限放大,引用是不允许权限放大的。
既然权限放大不行,我们试试权限缩小
int main()
{
int a = 10;
int& ra = a;
return 0;
}
没有问题!
C++中规定,对象的访问权限在引用过程中,只能缩小,不能放大
同时, r a ra ra 并没有缩小 a a a 的访问权限,用 a a a 访问可读可写
,用 r a ra ra 访问是只读
为什么呢?举个不是很恰当的例子:鲁迅先生,原名周树人,当它是周树人身份时,是个普通人,可以很随意;当他是鲁迅的身份时,他是一个文人,要注意文人的风度,举止就被限制了。这里周树人和鲁迅都是指同一个人,但别名鲁迅相比周树人是权限缩小了的。
6.6.2、 c o n s t const const 引用的情况
了解了 c o n s t const const 引用的规则后,我们来看看下面几种情况
6.6.2.1、常量
我们可不可以对常量取别名
呢?听起来很神奇是不是,其实是可以的。
int main()
{
const int& ra = 30;
return 0;
}
当然,普通的引用是不行的,常量值只读量
,对常量的引用要用 c o n s t const const 引用
6.6.2.2、表达式
除了对常量取别名,给表达式取别名
也是可以的
int main()
{
int a = 3;
int b = 5;
const int& c = a * b;
return 0;
}
像是 a + 3 a + 3 a+3、 a ∗ b a * b a∗b 等表达式,他们的计算结果返回值都是存放在一个临时对象中,前面曾提到。临时对象具有常性,即只读性质
现在我想给它取个名字,要用 c o n s t const const 引用。
int c = a * b;
大家思考一下,将表达式的结果赋值给 c c c,这样是权限放大吗?
不是的,因为这是拷贝,将临时对象的值拷贝到整型 c c c 中,而临时对象本身的权限并没有改变
。
这是两块空间
,权限放大针对的是同一块空间
const int a = 10;
int b = a;
同理,这也没有发生权限放
大,是将 a a a 空间中的 10 拷贝到 b b b 空间中
6.6.2.3、隐式类型转换
int main()
{
double a = 3.6;
int& ra = a;
return 0;
}
这样引用可以吗?是不可以的
首先我们来看看下面这种情况:
double a = 3.6;
int i = a;
a a a 是直接赋值给 i i i 吗?不是的,他们中间有个隐式类型转换的过程
,它中间会产生一个临时对象进行存储,既然是临时对象,那就要用 c o n s t const const 引用啦
int main()
{
double a = 3.6;
const int& ra = a;
return 0;
}
因此这样才是对的,这里的 r a ra ra 并不是 a a a,而是中间产生的临时对象
。
int main()
{
double a = 3.6;
double& ra = a;
return 0;
}
那这样对吗?是正确的
为什么这里又不用 c o n s t const const 引用呢?
我们需要搞清楚上面为什么要 用 c o n s t const const 引用
因为上面发生了隐式类型转换,中间产生了临时对象
,用 c o n s t const const 引用的是中间的临时对象
这里没有产生临时对象,引用的就是 a a a 变量
有小伙伴问这个临时对象销毁了,那这个引用不就出问题了吗?
不会的,当 c o n s t const const取引用了这个临时对象以后,这个临时对象的生命周期就会跟着这个引用去走
即 r a ra ra 销毁了,该临时对象才销毁
6.7、引用的本质
在这里,我要告诉大家引用的底层其实是指针!
我们通过观察汇编代码来印证这一点
int main()
{
int a = 0;
int* p = &a;
*p = 10;
int& ra = a;
ra = 20;
return 0;
}
观看汇编代码方式如下:进入调试模式
(按F10),鼠标右键,点击反汇编
可以看到,取地址的指令和取别名的汇编指令是一样的
;通过解引用修改变量值,与修改别名的汇编指令是一样的
。可见引用的底层就是指针。
这里简单讲解一下汇编指令:
- l e a lea lea 是取地址的意思,
00062576 lea eax,[a]
表示取出 a a a 的地址放入 e a x eax eax 中- m o v mov mov 是移动的意思,把逗号右边的值移动给左边,
00062579 mov dword ptr [p],eax
表示把 e a x eax eax 给 p p p
虽然引用底层还是指针,但是大家在学习的时候不要往这方面想,应还是按照却别名的思想来理解引用。
6.8、指针与引用的关系
C++ 中指针和引用就像两个性格迥异的亲兄弟,指针是哥哥,引用是弟弟,在实践中他们相辅相成,功能由重叠性,但各有自己的特点,互相不可替代
- 语法概念上引用是一个变量的
取别名不开空间
,指针是存储一个变量的地址,要开空间
- 引用在定义时
必须初始化
,指针建议初始化,但语法上不是必须
的 - 引用在初始化时引用一个对象后,就
不能再引用其他对象
;而指针可以在不断地改变指向对象
- 引用可以
直接访问
指向对象,指针需要解引用
才能访问指向对象 - s i z e o f sizeof sizeof 含义不同,引用结果为
引用类型的大小
,但指针始终是地址空间锁占字节个数
(32 位平台下占 4 个字节,64 位平台下是 8 字节) - 指针很容易出现空指针和野指针的问题,引用很少出现,引用使用起来相对
更安全
一些
七、 i n l i n e inline inline 内联函数
7.1、引子
我们都知道,函数调用时会建立栈帧,但建立栈帧是需要额外的开销的。对于一些短小并且需要大量调用
的函数,每次调用都要建立栈帧难免效率太低
。
有什么办法呢?通过【C语言】 —— 预处理详解(上)我们知道,可以运用宏函数
比如实现一个 ADD宏函数:
#define ADD(int a, int b) return a + b;
#define ADD(a, b) a + b;
#define ADD(a, b) (a + b)
上面哪个是对的?
都是错的,正确答案如下:
#define ADD(a, b) ((a) + (b))
可见,写一个宏函数要注意的点还是很多的,很容易写错
C++ 看这个宏函数在就不爽了,因此引入了内联函数的概念。
7.2、 i n l i n e inline inline 的特点
用 i n l i n e inline inline 修饰的函数叫做内联函数,编译时 C++ 的编译器会在调用的地方直接展开内联函数
,这样函数就不用建立栈帧
了,就可以提高效率
内联函数的定义十分简单,只需要在 正常函数前加上 i n l i n e inline inline 即可。
注:展开相当于编译器直接将函数写在主函数中
inline int Add(int a, int b)
{
return a + b;
}
int main()
{
int a = 10;
int b = 20;
Add(a, b);
return 0;
}
在 d e b u g debug debug 环境下,为了方便调试,内联函数是不展开
的,还是会建立栈帧,可以通过以下方法使内联函数展开
展开对于编译器来说只是一个建议,也就是说即使加了 inline 编译器并不是一定会将函数展开
。这取决于编译器自身,不同的编译器对 i n l i n e inline inline 什么时候展开并不相同,因为 C++ 中并没有规定这个。当代码行数过多编译器就会选择不展开
,有可能是 5 行,也可能是 10 行。
为什么呢?
想象一下,当函数有 100 行,要调用一万次,那全部展开代码就要增加 一万 ∗ 100 一万*100 一万∗100 行 ,而建立栈帧代码永远只有那一份,当调用该函数时再跳到该函数地址。要是这是个递归函数,那就代价更大了,同时也会导致代码膨胀。
注: i n l i n e inline inline 不建议声明和定义分离到两个文件
(在同一个文件没问题),分离会导致链接错误。因为 i n l i n e inline inline 被展开,就没有函数地址,连接时就会出现报错
原因是内联函数编译器是默认不需要地址的,因为它在调用的地方展开了。所以在进行链接的时候,链接器不会把内联函数的地址放进符号表
,这样就找不到该内联函数的地址。
内联函数直接声明和定义都放到 . h .h .h 文件,不要声明在 . h .h .h,定义在 . c .c .c。
八、 n u l l p t r nullptr nullptr 空指针
在学习 n u l l p t r nullptr nullptr 之前,我们先回顾一下 NULL
在 C语言 中,定义了一个宏 NULL,在头文件 < s t d d e f . h stddef.h stddef.h> 中可以看到他的定义
#ifndef NULL
#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void *)0)
#endif
#endif
在 C++ 中 NULL 其实就是 0,而 C语言 中将 0 强转成 v o i d void void*,即 ( v o i d void void *) 0
这样定义有什么弊端呢?搞得 C++ 要单独定义一个 n u l l p t r nullptr nullptr 呢
#include<iostream>
using namespace std;
void f(int x)
{
cout << "f(int x)" << endl;
}
void f(int* ptr)
{
cout << "f(int* ptr)" << endl;
}
int main()
{
f(0);
f(NULL);
return 0;
}
你会发现都是调用第一个函数
,因为 NULL 是宏,替换成 0
那如果是 C语言 的 ( v o i d void void *) 0 可以吗?会发现编译报错
:
int main()
{
f((void*) 0);
return 0;
}
你会发现这样也不行,甚至还报错。因为这时要发生隐试类型转换,那是转换成 i n t int int 还是转换成 i n t int int* 呢?
所以 NULL 不论是 C语言 的定义还是 C++ 的定义都是不好的
这里顺便说一下,为什么在 C语言 中好像没问题呢?因为 C语言 中的类型检查没有那么严格
v o i d void void* 类型可以隐式转换成其他类型的指针
,而 C++ 的类型检查更严格, v o i d void void* 不能隐式转换成其他指针
举个栗子:
int main()
{
void* p1 = NULL;
int* p2 = p1;
return 0;
}
这段代码在 C语言 中是可以编译过去的,但在 C++ 中不行,必须把 p 1 p1 p1 强制类型转换
int main()
{
void* p1 = NULL;
int* p2 = (int*)p1;
return 0;
}
因此 C++11(C++98中还未引入)中引入 n u l l p t r nullptr nullptr, n u l l p t r nullptr nullptr 是一个特殊的关键字
, n u l l p t r nullptr nullptr 是一种特殊类型的字面量
,它可以转换成任意其他类型的指针类型
。使用 n u l l p t r nullptr nullptr 定义空指针可以避免类型转换的问题,因为 n u l l p t r nullptr nullptr 只能被隐式地转换为指针类型,而不能转换为整数类型。
int main()
{
f(nullptr);
return 0;
}
使用 n u l l p t r nullptr nullptr 后就没有歧义了,直接调用第二个函数
以后,C++ 中尽量都用 n u l l p t r nullptr nullptr,不用 NULL