先序文章请看:
盘一盘C++的类型描述符(二)
盘一盘C++的类型描述符(一)
引用类型
引用类型说简单也挺简单,说复杂也相当复杂,所以在此之前我们必须要先明确「引用」这个语法的语义和实现。
引用语义
引用(reference)其实想表达一个「替身」的意思。如何理解这个「替身」呢?有一些基础资料可能会推荐读者用「别名」来理解引用,但笔者觉得「别名」和「替身」还是有明显区别的,这里用「替身」也许更合适一些。
我们先来看一个简单的例子:
int a = 0;
int &r = a; // r是a的引用
r = 5; // 对r的任何操作都等价于对a的操作
单从语义上来理解,在同一个作用域内,我们认为引用是「别名」其实没什么问题,比如说上例,就可以认为一个变量同时有a
和r
这两个名称,用哪个名称都代表相同的变量。
但如果跨作用域则无法再用「别名」来解释:
void f(int &r) {
r = 5;
}
void Demo() {
int a = 0;
f(a); // 引用传递
int b = 1;
f(b); // 另一次引用传递
}
如上面例程所示,a
和r
在不同的作用域,因此我们不能说r
是a
的别名,只能说在f(a)
这一次调用内,r
成为了a
的「替身」,既然是替身,那么替身本身也是有实体的,只不过对替身操作会映射到对原身的操作去。而替身本身在生命周期结束后也会释放,因此当再去调用f(b)
的时候,r
会成为b
的替身。
因此,「别名」和「替身」最大的区别就在于是否有实体。别名本身没有实体,多个别名代表了同一实体;替身本身是有实体的,只不过对替身的操作会映射为对原身的操作。
所以,引用这个语义很特殊,与其他各种数据类型都不同的一点便是在此。请读者先接受这一点,后文会详细来分析。
引用实现
照理讲,语法的实现问题应当在编译器层面,不属于本文的范畴,但引用这个语法太特殊了,很多人非常容易混淆,所以笔者觉得还是有必要单独拉出一节来强调一下。
既然引用并不是「别名」而是「替身」,那么这个指导性意见自然会传达到实现层面。我们来看一个例子:
void Demo() {
int a = 0;
int &r = a;
r = 5;
}
请读者判断一下,上面这段代码编译成AMD64汇编应该是下面的哪一种?
选项A:
Demo():
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], 0
mov DWORD PTR [rbp-4], 5
nop
pop rbp
ret
选项B:
Demo():
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-12], 0
lea rax, [rbp-12]
mov QWORD PTR [rbp-8], rax
mov rax, QWORD PTR [rbp-8]
mov DWORD PTR [rax], 5
nop
pop rbp
ret
选项A就是把引用当「别名」来对待了,而选项B则像是把引用当做了指针来处理。答案显而易见,应当是选项B。所以所谓「替身」其实是用指针来实现的,引用本身就是指向原身的指针,当我们对引用进行操作时,会转换为对解指针运算后进行操作。
但读者需要注意的是,「指针」其实是「替身」的一种实现方式,但这与语义本身是无关的。也就是说,在C++标准中,从来没有规定过「引用就是指针」这种类似的说明。而引用和指针本身也是不同的语义,我们一定要分开来理解。
引用类型描述符
引用既然是某一个对象的替身,那么引用类型自然描述的是「哪种类型的替身」。例如int &
表示「这是一个替身,它的原身是int
类型」,表述为「int
类型的引用类型」。
对于复杂一些的描述,我们也要找到其本质,也就是最内部的「&
」符号,代表这是一个引用类型,而它外部的所有内容都表示的是「原身的类型」。我们看下面几个例子:
int &r1; // r1是一个引用类型,原身是int类型
int *&r2; // r2是一个引用类型,原身是一个指针类型,指针的解类型是int【指针的引用】
int (&r3)[3]; // r3是一个引用类型,原身是一个数组类型,数组含有3个int类型的元素【数组的引用】
int (*&r4)(void); // r4是一个引用类型,原身是一个指针类型,指针指向一个函数,函数的返回值是int,参数为空【函数指针的引用】
int &d1(void); // d1是一个函数,参数为空,返回值是一个引用,引用原身是int类型【返回引用的函数】
int ((&d2(void))[3]); // d2是一个函数,参数为空,返回值是一个引用,引用的原身是一个数组,数组有3个int类型的元素【返回数组引用的函数】
int (*(&d3)[3])(void); // d3是一个引用,原身是一个数组,数组有3个指针类型的元素,指针指向函数,函数的返回值为int,参数为空【函数指针数组的引用】
经历过复杂类型解读洗礼的读者相信到这一步都不会有太大问题,其实整体来讲跟指针类似,指针主要找*
,*
的外层是「解类型」;而引用主要找&
,&
的外层是原身的类型。而且引用有一个特点是指针不具备的,那就是不可嵌套,因为不存在「引用的引用」这种类型,因此相比指针更容易一些,不需要找最内层,全局应当只能有一个引用符(用&
标记的非静态成员函数后的那个&
准确来说并不是引用符,这个需要额外注意)。
const引用与引用类型转换
const引用与const_cast
由于「引用」只能作为「替身」存在,因此,引用本身是不含额外属性的。类比指针,指针本身可以用const
修饰,解类型也可以用const
修饰,但引用本身是不能的。
int a = 0;
const int &r1 = a; // const修饰的是int,也就是原身类型
int &const r2 = a; // ERR,引用本身不能有附加属性
const int *p1 = &a; // const修饰的是int,也就是指针的解类型
int *const p2 = &a; // const修饰的是指针本身
上例中r2
就是一个非法的类型,因为引用本身不能再用其他修饰符来修饰。而这里的r1
则出现了一种新的语义,我们来详细解释一下。
首先,从「实现」的角度很好理解,由于引用是用指针来实现的,那么r1
的行为就跟p1
类似,指针的解类型是只读类型,指向了a
的地址。
但如果从语义上来说呢?前面章节提到过,引用的类型指的是原身的类型,但这里显然,原身类型是const int
,跟实际a
的类型int
是不匹配的,所以这里是发生了隐式类型转换,我们把它显式写出来应该是这样的:
int a = 0;
const int &r1 = const_cast<const int &>(a);
这下语义就很明确了,r1
仍然是a
的替身,但通过r1
操作时认为它只读。也就是说,r1
强制认为原身是只读类型了。
因此,const
引用可能含有两种语义:
- 一个只读类型的引用(
const int a = 0; const int &r = a;
) - 一个可变类型的只读引用(
int a = 0; const int &r = a;
)
当原身本身是只读类型是,const
自然就是原身类型中的一部分;而当原身是可变类型时,const
是经历了隐式转换之后的结果,此引用表示的是通过这个引用来操作时,认为原身是只读类型。
因此,殊途同归,引用类型中的const
修饰的一定是原身类型。
const引用与reinterpret_cast
既然原身类型可以加const
,那么理所应当也可以做其他的变换,比如说:
int a = 0;
char &r = reinterpret_cast<char &>(a); // OK
r = 'A';
大家理解的时候可以用指针实现的方式来理解:
int a = 0;
char *p = reinterpret_cast<char *>(&a);
*p = 'A';
但实际上从语义来解释,r
已经不是a
的替身了,而是a
的一部分(前1个字节)所代表的一个具有全新意义的对象(它是char
类型,一个假想的对象,与a
首地址相同)的替身。
【※非常重要※】那么问题来了,如果说一个对象有一个类型不同的引用,那么如果原对象发生了改变,这里引用的内容会不会发生改变呢?
int a = 0;
const int &r = a;
a = 8;
std::cout << r << std::endl; // 这里应该是0还是8?
uint32_t b = 0;
uint16_t &r2 = reinterpret_cast<uint16_t &r>(b);
b = 0xabcd1234;
std::cout << std::hex << r2 << std::endl; // 这里应该是0x1234还是0x0?
这个例子大家测试一下就可以知道,都是会发生改变的,道理也很简单,无论是const引用也好,还是其他类型的引用也好,都是跟原身(或原身的一部分)相关,所以原身变化时,替身所代指的部分也会发生变化。
但换一种场景就未必了,我们来看下面这个例子:
uint32_t a = 10;
const uint16_t &r1 = a;
a = 8;
std::cout << r1 << std::endl; // r1的值是多少?
大家可以尝试一下,答案是10
。是不是有点意外?
这就不得不引出const引用的第三个语义——快照。
const引用的快照语义
快照语义,顾名思义,就是保存某一个状态下的值,既然是快照,那么在之后原对象发生改变则不会影响当前快照。
需要注意的是,快照语义只有在以下两种场景才会触发:
- 绑定纯右值(例如常量、函数返回值)时
- 绑定不同类型,并且允许发生隐式构造的对象时
我们先来解释第二种,因为这跟我们前面的例子是同一种情况,前面的例子是这样写的:
uint32_t a = 10;
const uint16_t &r1 = a;
要注意,a
的类型是uint32_t
,而r1
的类型是const uint16_t &
,原身类型不同,因此不能命中前面章节所描述的『只读的替身』这种语义了,因此,这一句将会理解为『快照』。也就是说,用a
当前的值,来创建一个uint16_t
类型的快照。
那么既然是快照,所以a
再发生变化时也就不会影响到r1
了。
那么这种快照如何实现呢?编译器一般会构造一个匿名的临时变量,并且用快照的值来初始化,随后再创建一个这个临时变量的替身(也就是引用)。比如,上面的代码其实被解读为了:
uint32_t a = 10;
const uint16_t tmp = a; // 注意这里tmp其实是匿名的
const uint16_t &r1 = tmp;
通过汇编指令也可以很好地说明这个问题:
Demo():
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], 10 ; 初始化a
mov eax, DWORD PTR [rbp-4] ; 暂存a的值
mov WORD PTR [rbp-18], ax ; 用寄存器中的值(的低2字节)来构造临时变量
lea rax, [rbp-18] ; lea指令,把临时变量的地址保存再寄存器中
mov QWORD PTR [rbp-16], rax ; 把临时变量的地址(此时在寄存器中)赋值给替身(引用的本身)
nop
pop rbp
ret
前面我还提到了『隐式构造』,就是说,除了这种内建类型的转换以外,如果引用类型恰好可以用被引用类型来构造的话,那么const引用也会被解释为快照,其本质就是隐式构造了一个临时对象,再引用它,比如说:
char cs[] = "Hello!";
const std::string &str = cs; // 隐式构造
这个例子中,临时对象就是由cs
隐式构造来的,也就是说它其实等价于:
char cs[] = "Hello!";
const std::string tmp(cs); // 作为构造参数
const std::string &str = tmp; // 临时对象的引用
看一眼汇编也同样很明确,这里就不啰嗦了:
Demo():
push rbp
mov rbp, rsp
push rbx
sub rsp, 56
mov DWORD PTR [rbp-32], 1819043144
mov DWORD PTR [rbp-29], 2191212 ; 初始化cs(数组)
lea rax, [rbp-25]
mov rdi, rax
call std::allocator<char>::allocator() ; 为构造string所使用的内存分配器
lea rdx, [rbp-25]
lea rcx, [rbp-32] ; 作为参数入寄存器
lea rax, [rbp-64]
mov rsi, rcx
mov rdi, rax
call std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::basic_string<std::allocator<char> >(char const*, std::allocator<char> const&) ; 调用了string的构造函数
lea rax, [rbp-64]
mov QWORD PTR [rbp-24], rax ; 临时对象的地址赋值给引用的实体
; 析构的部分省略
接下来我们解释一下绑定纯右值的情况,纯右值主要有两种形式,一个是常量,另一个是函数返回值。我们知道纯右值和函数返回值都是没有实体的(要么在寄存器中,要么就是个纯值),所以这种情况下引用也无法作为『替身』存在,毕竟根本就没有『原身』。
因此,对于这种情况来说,const引用也表『快照』语义,也就是在这种情况下状态的一种保存。而从是想上来说,同样也是构造匿名的临时变量。我们先来看一个绑定常量的情况:
const int &r = 1;
实现上等价于:
const int tmp = 1;
const int &r = tmp;
汇编如下:
Demo():
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-12], 1
lea rax, [rbp-12]
mov QWORD PTR [rbp-8], rax
nop
pop rbp
ret
再来看一个函数返回值的情况:
int f() {
return 5;
}
void Demo() {
const int &r = f();
}
实现上等价于:
int f() {
return 5;
}
void Demo() {
const int tmp = f();
const int &r = tmp;
}
汇编如下:
f():
push rbp
mov rbp, rsp
mov eax, 5 ; 返回值保存在寄存器中
pop rbp
ret
Demo():
push rbp
mov rbp, rsp
sub rsp, 16
call f()
mov DWORD PTR [rbp-12], eax ; 临时变量接收返回值
lea rax, [rbp-12]
mov QWORD PTR [rbp-8], rax ; 引用实体是临时变量的地址
nop
leave
ret
当然,函数返回值的情况不止这一种,如果读者感兴趣可以参考《C++为什么会有这么多难搞的值类别》。
这里多啰嗦一句,const引用接收『将亡值』并不会命中快照的语义,这是因为类型相同的情况下,会优先命中『不可变替身』这层语义,比如说:
struct Test {
int a, b;
};
void Demo() {
Test t {1, 2};
const Test &r = std::move(t); // const引用绑定将亡值
t.a = 5;
std::cout << r.a << std::endl; // 会输出5而不是1
}
道理很简单,因为Test &&
类型是Test
的右值引用类型,而const Test &
是Test
的const引用类型,所以本质上都是基于Test
类型的,因此类型一致的情况下会命中『替身』的语义。既然r
是t
的替身,那么t
发生改变时r
自然也会改变。
type_traits处理引用类型
前面章节介绍过,type_traits
处理指针类型时,指针的解类型是不会发生改变的,只会处理它本身的类型。那么同理,对于引用来说,也是不会动到它的原身类型里面去的。
举例来说:
std::remove_const_t<const int &>; // const int &
因为这个const
是修饰int
的,而引用本身又不会被const
修饰,因此它其实并不存在去掉const
的问题。
同理,如果给一个引用类型加上const
,其实也是无效的,例如:
std::add_const_t<int &>; // int &
std::add_const_t<int *>; // int *const
这一点读者在使用时一定要注意。