理解 C++ 的引用实现
在 C++ 中,引用(reference)是一种特殊的机制,它允许你为一个已有的变量创建一个 “别名(alias)”。通过引用,你可以直接操作原始变量,而不是创建一个新的副本。引用本质上是一个变量的 “第二个名字”,它与原始变量绑定在一起,任何对引用的操作都会直接影响原始变量。
引用通过在变量声明时使用 &
符号来定义。定义引用时,必须立即初始化,并且它必须绑定到一个已存在的变量上。引用本身不是一个独立的实体,而是直接指向它所引用的变量。
语法示例:
int a = 10; // 定义一个整数变量 a
int& ref = a; // 定义一个引用 ref,绑定到变量 a
在这个例子中,ref
是 a
的引用,ref
和 a
是同一个内存地址的两个名字。
博主的理解为引用本质上就是对某个变量 ptr 执行 *(&ptr) 这样一种操作,在函数的值传递中依然符合拷贝的传递机制(C++ 称为引用传递)即传入实参的地址,只不过 C++ 的编译器会自动帮程序员进行 C 语言中类似解引用的操作,因为当函数内部不需要对地址本身进行操作时,地址本身对于程序员来说是没有意义的,程序员拿到地址后也是要对地址进行解引用的操作,那么 C++ 设计的引用就是在编译时期自动帮程序员执行解引用的操作。这也能合理解释为什么 C++ 的引用在定义时必须要绑定到一个变量上,而不能是 “空引用”,从定义的角度看,引用是为一个已有的变量 A 创建一个 “别名”,如果变量 A 本身就不存在何来别名之说?也就是说变量 A 本身就不存在的话执行解引用的操作是非法的,或者说对野指针进行解引用会导致不确定的行为,即 C++ 所称的悬垂引用,同理引用绑定的一个变量上,它就不能再绑定到其他变量,对某一个变量进行解引用自然不可能对其它变量进行解引用也是合理的,至于 “引用不是拷贝,它与原始变量使用相同的内存地址。修改引用等同于修改原始变量” 这句话在执行函数调用时的值传递机制中似乎不太贴合博主理解的直接拷贝传递变量的地址,为了搞清函数调用时传递的引用机制,博主通过两种操作编译后的汇编来进行比较。
#include <iostream>
void increment(int& ref) {
ref = ref + 1;
}
int main() {
int x = 10;
int& ref = x; // ref 是 x 的引用
ref = 20; // 修改 x
increment(ref); // 通过引用修改 x
std::cout << x << "\n"; // 输出 21
return 0;
}
#include <iostream>
void increment(int *ref) {
*ref = *ref + 1;
}
int main() {
int x = 10;
int *ref = &x; // ref 指向 x 的地址
*ref = 20; // ref 通过解引用修改 x
increment(ref); // 通过指针修改 x
std::cout << x << "\n"; // 输出21
return 0;
}
博主在 (https://godbolt.org/) 使用 x86-64 gcc 14.2 在不进行编译器优化得的情况下分别进行编译后得到的汇编如下:
increment(int*):
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-8], rdi
mov rax, QWORD PTR [rbp-8]
mov eax, DWORD PTR [rax]
lea edx, [rax+1]
mov rax, QWORD PTR [rbp-8]
mov DWORD PTR [rax], edx
nop
pop rbp
ret
.LC0:
.string "\n"
main:
push rbp
mov rbp, rsp
sub rsp, 16
mov DWORD PTR [rbp-12], 10
lea rax, [rbp-12]
mov QWORD PTR [rbp-8], rax
mov rax, QWORD PTR [rbp-8]
mov DWORD PTR [rax], 20
mov rax, QWORD PTR [rbp-8]
mov rdi, rax
call increment(int*)
mov eax, DWORD PTR [rbp-12]
mov esi, eax
mov edi, OFFSET FLAT:std::cout
call std::ostream::operator<<(int)
mov esi, OFFSET FLAT:.LC0
mov rdi, rax
call std::basic_ostream<char, std::char_traits<char>>& std::operator<<<std::char_traits<char>>(std::basic_ostream<char, std::char_traits<char>>&, char const*)
mov eax, 0
leave
ret
为什么博主只贴了一个呢?没错,在不进行编译器优化的情况下,两者编译出的汇编是一模一样的(除了形参定义的部分)
源代码回顾(指针版本)
#include <iostream>
void increment(int *ref) {
*ref = *ref + 1;
}
int main() {
int x = 10;
int *ref = &x; // ref 是一个指向 x 的指针
*ref = 20; // 通过指针修改 x
increment(ref); // 通过指针传递 x 的地址
std::cout << x << "\n"; // 输出 21
return 0;
}
汇编代码分析与注释
-
increment(int*)
函数的汇编increment(int*): push rbp ; 保存调用者的基址指针(栈帧切换) mov rbp, rsp ; 设置当前函数的基址指针为栈顶 mov QWORD PTR [rbp-8], rdi ; 将参数 ref(指针,rdi 寄存器传入)存储到栈上 [rbp-8] ; rdi 是 x86-64 调用约定中第一个参数寄存器 ; 这里 ref 是 x 的地址(&x) ; *ref = *ref + 1; 的实现: mov rax, QWORD PTR [rbp-8] ; 将 ref(x 的地址)从栈上加载到 rax mov eax, DWORD PTR [rax] ; 解引用 rax 中的地址,读取 ref 指向的值(x 的当前值)到 eax ; eax 是 32 位寄存器,因为 int 是 32 位 lea edx, [rax+1] ; 计算 *ref + 1,结果存入 edx(lea 用于加法) mov rax, QWORD PTR [rbp-8] ; 再次加载 ref(x 的地址)到 rax mov DWORD PTR [rax], edx ; 将计算结果 (edx) 写回 ref 指向的内存(即 x 的地址) nop ; 无操作,可能是对齐或占位符 pop rbp ; 恢复调用者的基址指针 ret ; 返回
- 参数传递:
rdi
寄存器传递了ref
的值,即x
的地址(&x
)。这与引用版本相同,表明底层都是地址传递。 - 操作: 通过
mov eax, DWORD PTR [rax]
读取x
的值,lea edx, [rax+1]
计算加 1,mov DWORD PTR [rax], edx
写回结果。这是对指针解引用的直接内存操作。 - 指针特性:
ref
在函数中明确作为一个地址变量处理,需要解引用。
- 参数传递:
-
main
函数的汇编main: push rbp ; 保存调用者的基址指针 mov rbp, rsp ; 设置当前栈帧的基址指针 sub rsp, 16 ; 为栈分配 16 字节空间(x 和 ref 的局部变量) ; int x = 10; mov DWORD PTR [rbp-12], 10 ; 将 10 存储到栈上 [rbp-12],这是 x 的位置 ; int *ref = &x; lea rax, [rbp-12] ; 计算 x 的地址(rbp-12),存入 rax mov QWORD PTR [rbp-8], rax ; 将 x 的地址存储到 [rbp-8],ref 是一个指针变量 ; *ref = 20; mov rax, QWORD PTR [rbp-8] ; 加载 ref 的值(x 的地址)到 rax mov DWORD PTR [rax], 20 ; 将 20 写入 ref 指向的地址(即 x 的位置 [rbp-12]) ; increment(ref); mov rax, QWORD PTR [rbp-8] ; 加载 ref 的值(x 的地址)到 rax mov rdi, rax ; 将地址传入 rdi,作为 increment 的参数 call increment(int*) ; 调用 increment 函数,传递 x 的地址 ; std::cout << x << "\n"; mov eax, DWORD PTR [rbp-12] ; 读取 x 的值到 eax mov esi, eax ; 将 x 的值传入 esi(第二个参数寄存器) mov edi, OFFSET FLAT:std::cout ; 将 std::cout 的地址传入 edi(第一个参数寄存器) call std::ostream::operator<<(int) ; 调用 cout 的 << 操作符输出 x mov esi, OFFSET FLAT:.LC0 ; 加载换行符 "\n" 的地址到 esi mov rdi, rax ; 将 cout 的返回对象传入 rdi call std::basic_ostream<char, std::char_traits<char>>& std::operator<<<std::char_traits<char>>(std::basic_ostream<char, std::char_traits<char>>&, char const*) ; 输出换行符 mov eax, 0 ; 返回值 0 leave ; 恢复栈帧(等价于 mov rsp, rbp; pop rbp) ret ; 返回 .LC0: .string "\n" ; 换行符的静态字符串
- 变量分配:
x
在[rbp-12]
,ref
在[rbp-8]
,后者存储了x
的地址(&x
)。这表明ref
是一个独立的指针变量。 *ref = 20;
: 通过ref
的值([rbp-8]
中的地址)间接修改x
,需要显式解引用。- 函数调用:
increment(ref)
传递ref
的值(x
的地址),底层与引用版本一致。 - 输出: 直接读取
x
的值,与引用版本相同。
- 变量分配:
指针版本与引用版本的汇编对比
-
引用版本:
void increment(int& ref) { ref = ref + 1; } int& ref = x; ref = 20; increment(ref);
-
指针版本:
void increment(int* ref) { *ref = *ref + 1; } int* ref = &x; *ref = 20; increment(ref);
汇编对比分析
-
increment
函数的实现-
引用版本:
mov rax, QWORD PTR [rbp-8] ; 加载 ref 的地址 mov eax, DWORD PTR [rax] ; 读取值 lea edx, [rax+1] ; 加 1 mov rax, QWORD PTR [rbp-8] ; 加载地址 mov DWORD PTR [rax], edx ; 写回
-
指针版本:
mov rax, QWORD PTR [rbp-8] ; 加载 ref 的地址 mov eax, DWORD PTR [rax] ; 读取值 lea edx, [rax+1] ; 加 1 mov rax, QWORD PTR [rbp-8] ; 加载地址 mov DWORD PTR [rax], edx ; 写回
-
区别:
- 两者的汇编代码完全相同!这是因为在底层,引用和指针都被实现为地址操作。引用版本的
ref
和指针版本的*ref
都被翻译为对地址[rax]
的读写。 - 函数签名虽不同(
int&
vsint*
),但未优化汇编中,编译器生成的逻辑一致。
- 两者的汇编代码完全相同!这是因为在底层,引用和指针都被实现为地址操作。引用版本的
-
-
main
函数中的初始化-
引用版本:
mov DWORD PTR [rbp-12], 10 ; x = 10 lea rax, [rbp-12] ; 计算 x 的地址 mov QWORD PTR [rbp-8], rax ; ref 绑定 x 的地址
-
指针版本:
mov DWORD PTR [rbp-12], 10 ; x = 10 lea rax, [rbp-12] ; 计算 x 的地址 mov QWORD PTR [rbp-8], rax ; ref = &x
-
区别:
- 汇编代码完全相同!
int& ref = x
和int* ref = &x
在未优化情况下都被翻译为将x
的地址存储到[rbp-8]
。 - 引用版本中,
[rbp-8]
是调试符号的冗余,优化后可能消失;指针版本中,[rbp-8]
是ref
的实际存储空间。
- 汇编代码完全相同!
-
-
赋值操作
-
引用版本:
ref = 20;
mov rax, QWORD PTR [rbp-8] ; 加载 ref 的地址 mov DWORD PTR [rax], 20 ; 写入 20
-
指针版本:
*ref = 20;
mov rax, QWORD PTR [rbp-8] ; 加载 ref 的值(地址) mov DWORD PTR [rax], 20 ; 写入 20
-
区别:
- 汇编代码完全相同!引用和指针的赋值都被翻译为对
[rbp-12]
的直接修改。
- 汇编代码完全相同!引用和指针的赋值都被翻译为对
-
-
函数调用
-
引用版本:
increment(ref);
mov rax, QWORD PTR [rbp-8] ; 加载 ref 的地址 mov rdi, rax ; 传递给函数 call increment(int&)
-
指针版本:
increment(ref);
mov rax, QWORD PTR [rbp-8] ; 加载 ref 的值(地址) mov rdi, rax ; 传递给函数 call increment(int*)
-
区别:
- 汇编代码完全相同!两者都传递了
x
的地址(rbp-12
)。
- 汇编代码完全相同!两者都传递了
-
印证了博主的想法,引用就是隐藏了取地址和解引用的显式操作,唯一不同之处在于如果编译器进行优化的话,increment
可能被内联,ref
完全被优化为对 x
的直接操作,而不再是地址传递,即不再保留指针变量。这些都是博主的猜想,具体这就要根据不同编译器的设计来讲了,毕竟 C++ 只定义了标准语法和编程范式,在实际的应用中还要根据所用的编译器实现来看,不同编译器对同一个 C++ 标准的支持也是不同的。
能力有限,如有错误欢迎指正。