好好活就是有意义的事,有意义的事就是好好活
C++, 如何理解引用
C++, 如何理解引用

C++, 如何理解引用

前言

因为最近在用C++写项目,因为之前C++的基础为0,所以对引用的理解非常浅显,一直将其当作指针来看待,然而现在对其产生了巨大的疑惑,包括:

  • 为什么引用总会和const关键字扯上关系
  • 引用究竟是如何减少copy的?
  • 引用和指针到底有什么区别?
  • 到底什么是右值引用?他是干嘛的?
  • std::move()函数将左值变成右值,作用是什么?

我看了很多关于引用的介绍,说实话,看完之后依然似懂非懂,可能我没找到好的博客(我也懒得找,反正百度出来的千篇一律),大概停留在引用就是别名,右值就是等号右边的那些不能被赋值和取地址的值,当然了并不是说他们错了,而是仅凭此根本不能了解到引用的真正作用,比如我最近看得Pistache的项目,为啥这里要用引用?我就完全解释不了。

但是我找到一篇介绍引用做返回值的博客(这个人不是转载,也没贴原作者的地址)虽然博客说的很清楚,但是首先引用做返回值并不是引用的主要用途,其次博客中的关于临时变量的部分已经是几万年前的编译器了,他提供的源代码,编译器即使在-O0关闭任何的优化的情况下编译出来的汇编代码在不使用引用的情况下效率更高!也就是说对引用的原理介绍的不能说错,但是依然没把引用的优势说出来。

直到昨天我从头将《C++ Primer plus(第六版)》关于引用的部分(原书,p255-274和p801-813),我才豁然开朗,一切都明白了!

废话了这么多,本文的主要目的就是希望能将自己这两天学到的,给全部写出来!但是到动笔键盘之前,我真的很难捋出一条线来,所以行文上可能没那么好,万一有读者,希望能耐心,并加上自己的思考。

引用的定义及使用

引用变量

用一个最简单的例子:

int main() {
    int a = 1;
    int &ra = a;
    int *pa = &a;
    return 0;
}

这里有一个规定:引用变量必须在声明的时候同时进行初始化,而不能先声明再赋值,其实这一点并不重要,这就是语法规则,不然我完全可以这样定义引用:
int &ra;
ra = #a;
%a = 10;
其中我称#为取引用符,称%为取引用值符。我之所以想在这里内涵一下指针,是想让大家明白,所谓语法就是编译器定义的规则,最终编译器要将根据语法规则写出的C++代码变成汇编代码,汇编代码将最终实现编译器所规定的语法的含义!这有助于我们从汇编的角度,区分指针和引用。

我们看一下上面这段代码编译成的汇编代码(我是用的是Ubuntu 20.04下,默认的最高版本即 g++ 9.3.0):

    pushq   %rbp                # 保存"栈底"寄存器 %rbp

    movq    %rsp, %rbp          # 分配32字节大小的函数栈帧空间
    subq    $32, %rsp

    movl    $1, -28(%rbp)       # 定义变量a,并将初始化值1放入a的内存空间中,即-28(%rbp)

    leaq    -28(%rbp), %rax     # 定义引用ra,取a的地址,然后将其放入ra的内存空间中,即-24(%rbp)
    movq    %rax, -24(%rbp)

    leaq    -28(%rbp), %rax     # 定义指针pa,取a的地址,然后将其放入pa的内存空间中,即-16(%rbp)
    movq    %rax, -16(%rbp)
    
    movl    $0, %eax            # 设置main()的返回值

如果你不懂汇编代码或者不了解linux函数栈帧的设计,那我只能骚凹瑞,你只能相信我的注释和结论。我们可以看到,指针和引用变量,都是占用内存空间的,他们的内容,都是所引用或所指向的变量的起始地址

到这里我们先解决了了一个经典的问题:引用占内存吗?显然,在当前编译器的实现中(这句话很重要),引用是需要占据内存空间的,大小等于你架构的位数,即在x86_64上就是8个字节。因为引用有一个广为人知的说法,就是变量的别名,从描述上,好像引用是不占内存的,仅仅是个名字,可能老的编译器也是这样实现的,但是现代的编译器,不是这样的!

引用与指针

接下来是另一个误区,引用就是指针!显然这种看法也是错误的,引用和指针的确是在用法上相似,而且他们在汇编语言的级别,都是所指向和引用的对象的地址,但是,编译器赋予了指针和引用完全不同的语义,比如:

int main() {
    int a = 1;
    int &ra = a;
    int *pa = &a;

    auto addr_ra = &ra;
    auto addr_pa = &pa;

    a += 1;
    ra += 1;
    pa += 1;
    *pa += 1;


    return 0;
}

将上述代码转为汇编之后(为了方便阅读,我将诸如-32(%rbp)直接转为了$(变量名的形式)):

    pushq   %rbp
    
    movq    %rsp, %rbp
    subq    $48, %rsp
    
    movl    $1, $(a)       #   int a = 1;

    leaq    $(a), %rax     #   int &ra = a;
    movq    %rax, $(ra)
    
    leaq    $(a), %rax     #   int *pa = &a;
    movq    %rax, $(pa)

    movq    $(ra), %rax    #   auto addr_ra = &ra;
    movq    %rax, $(addr_ra)

    leaq    $(pa), %rax    #   auto addr_pa = &pa;
    movq    %rax, $(addr_pa)

    movl    $(a), %eax     #   a += 1;
    addl    $1, %eax
    movl    %eax, $(a)

    movq    $(ra), %rax    #   ra += 1;
    movl    (%rax), %eax
    leal    1(%rax), %edx   #   这是一行实现的加法比较诡异,没有使用Add命令,而是lea取址命令作用等价于 addl $1, rax , movl %eax, %edx
    movq    $(ra), %rax
    movl    %edx, (%rax)

    movq    $(pa), %rax    #   pa += 1;
    addq    $4, %rax
    movq    %rax, $(pa)
    
    movq    $(pa), %rax    #   *pa += 1;
    movl    (%rax), %edx
    movq    $(pa), %rax    #   这一行是多余代码,当然因为我们是-O0参数,编译器没有任何优化
    addl    $1, %edx
    movl    %edx, (%rax)

    movl    $0, %eax        #   return 0;

其实不用看汇编代码,我们也知道对应的语句做了什么!可以看到,虽然引用和指针相似,但是编译器对两者还是有截然不同的语义的,这里将引出本文最核心的问题,引用到底是干嘛的?为什么要添加这样一个与指针如此接近的引用呢?

为什么需要引用?

先给结论

《C++ Primer plus》中有一句原话:“类设计的语义常常要求使用引用,这是C++新增这项特性的主要原因。”也就是说,引用是为了引用对象。可这又是为什么呢?引用的真正目的是什么呢?我的回答是:为了减少临时变量的copy

这是我思考了很久,得出的我认为最能插入灵魂的回答。

有人可能马上反驳,指针也可以减少临时变量的copy,不急,我们还有没有对临时变量这个重要的概念下定义。我们先来看看你们(也是原来的我)认为的“临时变量”:也就是所谓值传递、指针传递和引用传递。

值传递、指针传递和引用传递

我相信,大家对这三种方式,几乎已经不能再熟悉了:

void swap_value(int a, int b) {
    int tmp = a;
    a = b;
    b = tmp;
}
void swap_point(int *a, int *b) {
    int tmp = *a;
    *a = *b;
    *b = tmp;
}
void swap_ref(int &a, int &b) {
    int tmp = a;
    a = b;
    b = tmp;
}

对于需要修改变量的时候,只能使用指针传递和引用传递。但是对于不需要修改的时候:

  • 如果变量是内置类型或很小的结构体、类对象,如int,那么推荐值传递
  • 如果变量是很大的结构体或者类对象,那么使用const 指针或const 引用将是首选

需要传递的是内置变量或者很小的结构体时,编译器将直接使用寄存器进行操作,显然这是最快的,如果要用指针和引用,那么会多一次放存操作;当需要传递的值很大,寄存器不够用时,那么使用指针或者引用,将只需要传递变量的地址就可以了!

这里就是我们认为指针和引用都可以减少“临时变量”的原因,我们把函数的参数,当成了临时变量!函数的参数可太惨了,因为在汇编中,参数和普通变量一样,都是存在与栈帧中,都是有名字的,变量,其生命周期是整个函数,因此参数并不是我们的临时变量,那谁是?

谁是临时变量?为什么需要引用

我们先直接给出例子:

string string_add(string s1, string s2) {
    return s1 + s2;
}

int main() {
    string a("456");
    string b("789");
    string_add("123", a + b);
    return 0;
}

这个代码并不难理解,但是我们需要分析一下参数传递过程,显然翻译成汇编后分析难度有点大,因为这段代码翻译成汇编后,足足有350行,因为有很多string类的实现。我们就不分析汇编了,我们把自己变成一个编译器,来思考这段代码如何实现参数传递.

与之前的代码不同,在这里我们并没有直接传递相应类型的参数,而是传了一个" "C语言的标准字符串和两个string对象相加的表达式,那么这个时候怎么办呢,这就需要我们首先构造临时变量,即首先在当前函数的栈帧中留出一块空间(编译器负责),将临时对象构造到这个空间中,然后再将临时对象的值,复制给形式参数,然后临时变量就不需要了。

其中,构造临时变量的过程是必须的!但是copy临时变量的过程是多余的,如果调用的函数能够直接使用临时变量就好了?怎么做到呢,比如将临时变量的地址传给调用的函数?好方法,怎么实现呢,指针可以吗?不行,因为指针无法获取临时变量的地址,那怎么办呢?引用!

string string_add(const string& s1, const string& s2) {
    return s1 + s2;
}

当我使用引用时,编译器就知道不需要copy,而是将临时变量的地址给到了引用,然后由引用将其传递给调用函数,而这一过程指针是做不到,这就是我为什么我们需要引用!

至此,我已经给除了我的理解,这就是为什么要使用引用的终极答案。

但是这里还有几个问题没有说到:

  • 临时变量的定义依然没定
  • 右值引用呢?move()呢
  • 为什么说,C++为类对象,引入了引用的概念

右值引用

临时变量的定义

我们在上一小节,交代了引用是如何优化临时变量的copy的,因为我们得知了一件事:引用可以指向临时变量,那那些人在引用眼里属于临时变量呢?

int func() {
    return 0;
}
int main() {

    int a = 1;

    // 右值类
    const int &r1 = 1;
    const int &r2 = a * 2 + 1;
    const int &r3 = func();
    // 类型转化类
    const long &r4 = a;
    
    return 0;
}

根据《C++ Primer plus》分为了两类:

  • 右值类,所谓右值即只能出现在=右边的值,他们不能被赋值,不能被取地址
    典型的如,常量" "字符串除外,因为他本质上是指针变量)、表达式非引用返回值的函数(引用返回值函数最后说)等。
  • 类型转化类
    如上一节中先将" "字符串转为string对象,构造了一个string临时变量

需要注意,在执行上述引用时,都会在栈帧中分配空间来存放临时变量,使之不再临时,而引用就是他们的名字,这样就让临时变量和普通变量一样,有自己的名字和内存空间,可以通过引用来赋值和取址。

并且,从这里我们get到两点:

  1. 如果真的是像我上面给出的例子,都是内置的数据类型,那么引用完全就是在添乱,因为首先对于常量,根本不需要占用内存(栈内存)和寄存器,是可以写在汇编代码中的,占用的是代码段的空间,还有两外两种情况,因为临时变量就是int,那么我完全可以用寄存器来存放,速度更快,因为引用使用的是内存地址,这样会增加一次内存访问,这就是为什么引用是为类对象而生的原因之一,因为对象一般很大,无法使用寄存器来存放临时变量,之二的理由放在下一节
  2. 为啥,使用的是 const int &,这是有历史原因的,比如下面的代码:
void swap(int &a, int &b) {
   int tmp = a;
   a = b;
   b = tmp;
}
int main() {
   long a = 1, b = 2;
   swap(a, b);
   swap(1, 2);
   return 0;
}

如果int &可以引用临时变量,那么当我们修改引用时就意味着在修改临时变量,那么上面的swap()函数将失效,甚至出现swap(1, 2);这种滑稽的调用。于是,在现代编译器中,禁止非const引用指向临时变量。可是这将大大限制引用的使用,因为如果我就是想修改参数的值呢?于是就出现了:右值引用

右值引用

右值引用就可以引用临时变量,并对其进行修改,因此引用其实有四类:

  • int &ra = a;
    即左值引用,只能引用左值
  • const &rb1 = b;/const &rb1 = b;
    const引用,可引用右值和左值
  • int &&rc = c;
    即右值引用,只能引用右值
  • const int &&rc = c;
    const右值引用,只能引用右值

很多地方统称前两种为左值引用,包括《C++ Primer plus》,我认为这样会混淆视听,因为const引用可以引用右值。

根据 @szouc 同学的评论中的提议,现在增加关于对左值、右值以及临时临时变量的讨论:

  • 左值就是可以取地址的变量,又分为常规左值变量和const 左值变量;
  • 右值是不可取地址的临时变量,包括常量、非引用函数返回值、表达式等;

而右值仅仅是临时变量的一种,其还可以在发生数据类型转化时产生。

临时变量只有在被引用的时候才会拥有变量的属性,即内存空间和名字,否则可能就是一个寄存器或者一个没名字的临时栈内存区域,对于如int到long的类型转化,仅仅是对寄存器的截断或填充。

因此可引用的内容其实是:左值+临时变量。因此在上面介绍四种引用类型时说法上使用了大家熟悉右值,但是其实应该是临时变量。

注意:右值引用修改的是<临时变量>,对于类型转化类的临时变量,此修改是不会上传到原值的:

void func(int &&a, int &&b) {
    int tmp = a;
    a = b;
    b = tmp;
}
int main() {
    long a = 1, b = 2;
    func(a, b);
    printf("%ld %ld", a, b);
}

运行结果是 1 2

既然说到了右值,那么std::move()函数,也该出场了。

std::move()

std::move()函数通常的解释是,将左值转变为右值,C库给出来其源码,其解释也是这么说的:

  /**
   *  @brief  Convert a value to an rvalue.
   *  @param  __t  A thing of arbitrary type.
   *  @return The parameter cast to an rvalue-reference to allow moving it.
  */
  template<typename _Tp>
    constexpr typename std::remove_reference<_Tp>::type&&
    move(_Tp&& __t) noexcept
    { return static_cast<typename std::remove_reference<_Tp>::type&&>(__t); }

很多人爱贴这个代码,但是真的有人能完全解释,这个短短的4行代码吗?我觉得很难,使用了template、typename(模板函数),constexpr(不变表达式?),noexcept(无异常),static_cast(强制类型转化)等关键字,以及std::remove_reference<_Tp>::type&&,这种看了头大语法。

我当然要根据之前说法给出我的解释:std::move()作用是基于当前的左值创建一个可引用的临时变量来处理。我的这个定义我认为是非常精准的,不过还需要进行补充解释:

  • std::move()是创建新临时变量,但原变量依然是左值的普通变量,而非临时变量
void func(int &&a, int &&b) {
   int tmp = a;
   a = b;
   b = tmp;
}
int main() {
   int a = -1, b = -2;
   std::move(a);
   std::move(b);
   func(a, b);
}

上面的代码依然是语法错误

  • 正确的用法应该是:
void func(int &&a, int &&b) {
   int tmp = a;
   a = b;
   b = tmp;
}
int main() {
   int a = 1;
   long b = 2;
   func(std::move(a), std::move(b));
   printf("%d %ld", a, b);
}

我在这个代码里,玩了一手花的,以巧妙的解释move()是如何创造可引用的临时变量,简直是神来之笔,首先这个代码的返回值是惊人的2 2,为什么呢?

首先,我们前面说过,引用将临时变量变得和普通变量一样,也就是说,普通变量其实已经具备了临时变量的一切(主要是内存空间!),那么此时我们不需要做任何工作,只需要将普通变量的内存空间当作临时变量的内存空间即可!对应了std::move(a),这种情况下对临时变量的修改是会体现在普通变量a上的,这就是为啥a的值变成了2。

但是,如果是需要进行类型转化而产生的临时变量,对应于std::move(b),是没办法直接用内存空间的,比如long &&使用int,就会导致错误的内存访问,此时就必须创建新的临时变量,那么这个时候,对临时变量的修改是不会会体现在普通变量上的,这就是为啥b的值不变,最终导致了2 2的结果。

来看一下汇编代码:

    movl    $1, -24(%rbp)       # int a = 1;
    movq    $2, -16(%rbp)       # long b = 2;
    
    leaq    -16(%rbp), %rax     # std::move(b)
    movq    %rax, %rdi
    call    _ZSt4moveIRlEONSt16remove_referenceIT_E4typeEOS2_
    movq    (%rax), %rax
    movl    %eax, -20(%rbp)
    
    leaq    -24(%rbp), %rax     # std::move(a)
    movq    %rax, %rdi
    call    _ZSt4moveIRiEONSt16remove_referenceIT_E4typeEOS2_
    
    movq    %rax, %rdx          # func()
    leaq    -20(%rbp), %rax
    movq    %rax, %rsi
    movq    %rdx, %rdi
    call    _Z4funcOiS_ 

可以看到在执行std::move(b)的时候,用了一块4字节的栈帧!!!

到这里可以说,引用的所有原理就全部说完了。

但是,还有,但是。

std::move()有什么用?确实,将int、long转为右值,就是脱裤子FP,毫无用处,真正的作用,体现在类对象中,尤其是:

  • 实现移动构造函数
  • 类的运算符重载

类与引用

准备

我们假设定义一个Buff类:

class Buff {
public:
    Buff(char *data, size_t size) : data_(data), size_(size) {};
private:
    char *data_;
    size_t size_;
};
char p[100];
int main() {
    Buff f1{p, sizeof(p)};

}

如果我需要,用f1来初始化一个新的对象,有两种方法:

  • 使用默认的赋值构造函数
    Buff f2(f1);Buff f2 = f1;
  • 使用赋值语句
    Buff f2;
    f2 = f1;

但是无论那一种,他们都将采用”浅复制”,即,仅仅赋值字段的值,如:

char p[100];
int main() {
    Buff f{p, sizeof(p)};
    Buff f1(f);
    Buff f2 = f;
    Buff f3;
    f3 = f;
}

运行结果:

Clion 的Debug结果

可以看到他们的data_字段完全相同。

那么要想实现”深复制”,就需要我们自己重载默认赋值构造函数:

左值引用,复制构造函数

Buff(const Buff &b) {
        size_ = b.size_;
        data_ = static_cast<char *>(malloc(size_));
        memcpy(data_, b.data_, size_);
    }

右值引用,移动(复制)构造函数

Buff(Buff &&b) noexcept {
        size_ = b.size_;
        data_ = b.data_;
        b.data_ = nullptr;
    }

我们发现,基于右值引用实现的移动(复制)构造函数,竟然与默认构造函数很想,区别在于我们会在移动(复制)构造函数中修改参数的值,甚至将其设置为nullptr,这是为什么呢,因为右值引用,引用的是临时变量,因此我们完全可以“剥夺其资源”,从而大大的加快了构造函数的执行效率,这一过程也是引用真正的能区别与指针,且发挥其作用的地方,诠释了为什么引用是为类的对象而生

之所以需要b.data_ = nullptr;是因为临时变量在将来执行析构函数时,会释放data_,但是我们的f2在执行析构时,也会执行相同的操作,一块内存是不能delete两次的,但是delete nullptr是没有问题的。

std::move()

此外,std::move()函数,也将在这里体现成他的价值,比如,有一个RawBuff类,使用了我们的Buff类:

class Buff {
public:
    Buff() = default;

    Buff(char *data, size_t size) : data_(data), size_(size) {};

    Buff(Buff &b) {
        size_ = b.size_;
        data_ = static_cast<char *>(malloc(size_));
        memcpy(data_, b.data_, size_);
    }

    Buff(Buff &&b) noexcept {
        size_ = b.size_;
        data_ = b.data_;
        b.data_ = nullptr;
    }

private:
    char *data_;
    size_t size_;
};

class RawBuff {
public:
    explicit RawBuff(Buff &buff) : buff_(buff) {}

    explicit RawBuff(Buff &&buff) : buff_(buff) {}

private:
    Buff buff_;
};

Buff getBuff() {
    return Buff();
}

char p[100];

int main() {
    Buff f{p, sizeof(p)};
    RawBuff rf1(f);
    RawBuff rf2(getBuff());
}

我们可以看到,基于我们之前说的,这个代码是没有问题的,但是如果我提出这样的一个要求,我构造的RawBuff对象,需要修改传入的Buff对象之后再赋值给自己的字段,但是这个修改又不能反映到让原buff对象中,怎么办呢?其实答案很简单,只需要使用值传递就好了:

explicit RawBuff(Buff buff) {
        //Make some changes to the buff
        buff_ = buff;
    }

但是值传递带来的问题,如果处理呢?于是就可以使用std::move(),修改上述构造函数:

explicit RawBuff(Buff buff) {
        //Make some changes to the buff
        buff_ = std::move(buff);
    }

因为buff本身就是一个在执行完构造函数就会被抛弃的,那使用std::move(),将其变成临时变量,然后再由Buff()的移动(复制)构造函数剥夺其内存空间,完美!!!!

重载赋值运算符

我在上一小节的结尾,故意留了一个错误,我说是Buff()的移动(复制)构造函数剥夺了参数buff,其实是不对的,因为buff_ = std::move(buff);使用的是赋值语句,如果我们没有重载默认复制构造函数,那么赋值运算符也是“浅复制”,但是当我们重载了之后,赋值运算符就无法使用了,必须也对其重载:

    Buff &operator=(Buff const &b) {
        if (this == &b)
            return *this;
        size_ = b.size_;
        memcpy(data_, b.data_, size_);
        return *this;
    }

    Buff &operator=(Buff &&b) noexcept {
        if (this == &b)
            return *this;
        size_ = b.size_;
        data_ = b.data_;
        b.data_ = nullptr;
        return *this;
    }

其他

引用用于类继承

《C++ Primer plus》中还提到,引用是可以实现类继承的,也即:

class A {};
class A_child : public A {};


void func(A &a) {}
void func(A *a) {}

int main() {
    A a;
    A_child a_c;
    func(a);
    func(&a);

    func(a_c);
    func(&a_c);
}

但是指针也可以!

引用实现重载运算符

此外,我看到过一个有意思的知户回答:C++ 中的引用真的比指针好用吗?​links.jianshu.com/go?to=https%3A%2F%2Fwww.zhihu.com%2Fquestion%2F287795857

根据评论区和 @7mile 的讨论,这个回答想表达的意思,对象重载运算符是不能使用指针实现的,比如operator+:

std::string concat(const std::string* a, const std::string* b)
{
    return a + std::string(": ") + b;
}
str

如果这样实现,那么就会出现这样的代码:

int main() {
    string a = "123";
    string b = "456";
    string *pa = &a;
    string *pb = &b;
    string c = pa + pb;
}

这样就会产生歧义,因为指针本身就是一种数据类型,其已经内置了*、[]、+、++等操作。

因此在实现对象的运算符重载时,指针是不能使用的,而且使用值传递显然会增加一次内存copy。因此引用是最佳的选择!这也是引用是为对象而生的一个重要原因吧,这一点我一开始的确没有想到!

但是原回答中的说法,存在很大的误导性,让我以为指针无法使用运算符重载方法,显然这是不对的,后来我以为是原回答是忽略了(*p)的用法,但经过 @7mile 的指正,原回答的意思更有可能是上面的说法!

对结论的补充

我之前指出,引用的真正目的是,为了减少临时变量的copy,是因为我们只要在比较区分,指针和引用,核心原因就是指针不能引用临时变量,除此以及上一小节的情况之外,引用可以做到的,指针同样也可以,比如想对于值传递,指针和引用都避免了形式参数的多余copy操作

结束语

我认为这是我写过的最好的博客之一,特别是对于std::move()的那个神来之笔,真的是我在写的过程中想到的。我自诩在中文互联网上的有关C++引用的资料里面,我的分析可谓是是如木三分了吧!!当然了这一切内容都是基于《C++ Primer plus》的。但是我自己的理解和总结,以及使用g++查看汇编的实现,真的可以说有理有据,不得不吹一下!

其实最后,对于一开始的提到的,关于引用做返回值的用法,我说博客(这个人不是转载,也没贴原作者的地址)说的很详细,但是存在缺陷。其实真的很简单:

  • 如果返回值是非引用值,那么函数返回值就是一个右值的类型的临时变量而已,
  • 如果返回值是引用,那么需要注意几点:
  1. 不能返回函数的动态变量的引用,因为函数结束后,动态变量的内存就被回收了
  2. 可以返回static静态变量
  3. 可以返回引用类型的参数,这是《C++ Primer plus》的例子

真的真的真的没有了!

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注