盘一盘C++的类型描述符(三)

先序文章请看:
盘一盘C++的类型描述符(二)
盘一盘C++的类型描述符(一)

引用类型

引用类型说简单也挺简单,说复杂也相当复杂,所以在此之前我们必须要先明确「引用」这个语法的语义和实现。

引用语义

引用(reference)其实想表达一个「替身」的意思。如何理解这个「替身」呢?有一些基础资料可能会推荐读者用「别名」来理解引用,但笔者觉得「别名」和「替身」还是有明显区别的,这里用「替身」也许更合适一些。

我们先来看一个简单的例子:

int a = 0;
int &r = a; // r是a的引用
r = 5; // 对r的任何操作都等价于对a的操作

单从语义上来理解,在同一个作用域内,我们认为引用是「别名」其实没什么问题,比如说上例,就可以认为一个变量同时有ar这两个名称,用哪个名称都代表相同的变量。

但如果跨作用域则无法再用「别名」来解释:

void f(int &r) {
  r = 5;
}

void Demo() {
  int a = 0;
  f(a); // 引用传递
  int b = 1;
  f(b); // 另一次引用传递
}

如上面例程所示,ar在不同的作用域,因此我们不能说ra的别名,只能说在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引用可能含有两种语义:

  1. 一个只读类型的引用(const int a = 0; const int &r = a;)
  2. 一个可变类型的只读引用(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引用的快照语义

快照语义,顾名思义,就是保存某一个状态下的值,既然是快照,那么在之后原对象发生改变则不会影响当前快照。

需要注意的是,快照语义只有在以下两种场景才会触发:

  1. 绑定纯右值(例如常量、函数返回值)时
  2. 绑定不同类型,并且允许发生隐式构造的对象时

我们先来解释第二种,因为这跟我们前面的例子是同一种情况,前面的例子是这样写的:

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类型的,因此类型一致的情况下会命中『替身』的语义。既然rt的替身,那么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

这一点读者在使用时一定要注意。

盘一盘C++的类型描述符(四)

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

borehole打洞哥

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值