C++引用的秘密

0. 一个错误的概念

1
2
3
4
5
6
int main() {
int a = 111;
int &b = a;
b = 222;
std::cout<<&a<<&b;
}

我们能看到这里输出的两个值相同。

  • 错误1:

    很多人认为这里的b就是a,a就是b,a和b的地址是一样的,如下图。

    但是笔者要说,其实这个概念是有问题的,a是a,b是b,a和b并不是同一个地址。

1. 从STD的tie类型说起

笔者在阅读ClickHouse源码的时候发现了有趣的现象,该源码中有如下代码,我们注意第7-9行,可以发现这使用了STD的tie,该类型让C++实现了一次性返回两个值的效果。下面的executeQueryImpl函数返回了两个值,分别写入到了ast和streams中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
BlockIO executeQuery(
const String & query,
ContextMutablePtr context,
bool internal,
QueryProcessingStage::Enum stage)
{
ASTPtr ast;
BlockIO streams;
std::tie(ast, streams) = executeQueryImpl(query.data(), query.data() + query.size(), context, internal, stage, nullptr);

if (const auto * ast_query_with_output = dynamic_cast<const ASTQueryWithOutput *>(ast.get()))
{
String format_name = ast_query_with_output->format
? getIdentifierName(ast_query_with_output->format)
: context->getDefaultFormat();

if (format_name == "Null")
streams.null_format = true;
}

return streams;
}

2. 如何实现的

实际上C++中可以在结构体中指定一个引用字段,通过构造函数将外界的变量传递进结构体,再通过该结构体的拷贝构造函数实现赋值。

3. 结构体中如何储存引用

结构体如何储存其他值的引用?按照前文的说法,如果引用的地址是一样的,结构体如何储存其他值的引用呢,如下图。

实际上唯一的办法只能使用指针,让变量b指向变量a,当然这里的变量b的类型就是指针类型了,这么说肯定很多人不能接受,我定义的引用类型,怎么就成了指针了。

4. 揭秘结构体中的引用

先来看下面的代码,下面这两个函数,写法不一样,但是被GCC编译器编译以后的结果是一模一样的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
struct Ref {
explicit Ref(int &ref) : ref(ref) {}

int &ref;
int value;
};

struct Point {
explicit Point(int *ref) : ref(ref) {}

int *ref;
int value;
};

void ref() {
int x = 222;
Ref a(x);
a.ref = 333;
a.value = 444;
}

void point() {
int x = 222;
Point a(&x);
*a.ref = 333;
a.value = 444;
}

int main() {
}

读者可以通过指令 gcc -S -O0 main.cpp来编译该文件。可以看到两个函数都被编译结果为下面的内容,注意14-15行,这里就是ref赋值的地方,我们很容易发现,第一步是把rbp栈寄存器指向的地址偏移24的位置的值放入了寄存器rax中,第二步是将数据333写入rax寄存器所指向的地址。所以引用不过是指针的另一种写法而已。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
__Z5pointv:                             ## @_Z5pointv
.cfi_startproc
## %bb.0:
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset %rbp, -16
movq %rsp, %rbp
.cfi_def_cfa_register %rbp
subq $32, %rsp
movl $222, -4(%rbp)
leaq -24(%rbp), %rdi
leaq -4(%rbp), %rsi
callq __ZN5PointC1EPi
movq -24(%rbp), %rax
movl $333, (%rax) ## imm = 0x14D
movl $444, -16(%rbp) ## imm = 0x1BC
addq $32, %rsp
popq %rbp
retq
.cfi_endproc
## -- End function
.globl __ZN5PointC1EPi ## -- Begin function _ZN5PointC1EPi
.weak_def_can_be_hidden __ZN5PointC1EPi
.p2align 4, 0x90

5. 普通引用是如何实现的

我们使用gcc -S -O0 main.cpp编译下面的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void check() {
int a = 111;
int &b = a;
b = 222;
}

void check2() {
int a = 111;
int *b = &a;
*b = 222;
}

int main() {
}

不难发现两个函数都被编译成了相同的代码,于是乎,现在应该不会再有人认为这里的b就是a,a就是b,a和b的地址是一样的了吧

很明显b就是指针啊,他怎么能是和a的地址相同呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
__Z5checkv:                             ## @_Z5checkv
.cfi_startproc
## %bb.0:
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset %rbp, -16
movq %rsp, %rbp
.cfi_def_cfa_register %rbp
movl $111, -4(%rbp)
leaq -4(%rbp), %rax
movq %rax, -16(%rbp)
movq -16(%rbp), %rax
movl $222, (%rax)
popq %rbp
retq
.cfi_endproc
## -- End function
.globl __Z6check2v ## -- Begin function _Z6check2v
.p2align 4, 0x90

正确的引用图应该是下面这张