理解 C++ 引用的实现

理解 C++ 的引用实现

在 C++ 中,引用(reference)是一种特殊的机制,它允许你为一个已有的变量创建一个 “别名(alias)”。通过引用,你可以直接操作原始变量,而不是创建一个新的副本。引用本质上是一个变量的 “第二个名字”,它与原始变量绑定在一起,任何对引用的操作都会直接影响原始变量。

引用通过在变量声明时使用 & 符号来定义。定义引用时,必须立即初始化,并且它必须绑定到一个已存在的变量上。引用本身不是一个独立的实体,而是直接指向它所引用的变量。

语法示例:

int a = 10;      // 定义一个整数变量 a
int& ref = a;    // 定义一个引用 ref,绑定到变量 a

在这个例子中,refa 的引用,refa 是同一个内存地址的两个名字。


博主的理解为引用本质上就是对某个变量 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;
}

汇编代码分析与注释

  1. 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 在函数中明确作为一个地址变量处理,需要解引用。
  2. 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);
    

汇编对比分析

  1. 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& vs int*),但未优化汇编中,编译器生成的逻辑一致。
  2. 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 = xint* ref = &x 在未优化情况下都被翻译为将 x 的地址存储到 [rbp-8]
      • 引用版本中,[rbp-8] 是调试符号的冗余,优化后可能消失;指针版本中,[rbp-8]ref 的实际存储空间。
  3. 赋值操作

    • 引用版本: 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] 的直接修改。
  4. 函数调用

    • 引用版本: 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++ 标准的支持也是不同的。

能力有限,如有错误欢迎指正。