关于C++的引用

2021-07-12  本文已影响0人  蟹蟹宁

前言

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

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

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

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

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

一、引用的定义及使用

1. 引用变量

用一个最简单的例子:

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

我们定义了一个变量 a,一个引用ra引用了变量a,以及一个指针pa指向了变量a的地址。

这里有一个规定:引用变量必须在声明的时候同时进行初始化,而不能先声明再赋值,其实这一点并不重要,这就是语法规则,不然我完全可以这样定义引用:
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个字节。因为引用有一个广为人知的说法,就是变量的别名,从描述上,好像引用是不占内存的,仅仅是个名字,可能老的编译器也是这样实现的,但是现代的编译器,不是这样的!

2. 引用与指针

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

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;

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

二、为什么需要引用?

1、先给结论

《C++ Primer plus》中有一句原话:“类设计的语义常常要求使用引用,这是C++新增这项特性的主要原因。”也就是说,引用是为了引用对象。可这又是为什么呢?书没有明说,不过我看到过一个有意思的知乎回答


我之所以截图上来,并不是想嘲讽这个回答,因为我也不敢保证自己的理解是否真的完全正确,或者也有可能18年的编译器真的如此,况且我在没搞懂引用之前,也信了这个回答,但是显然,在2021年的今天,这个回答完全错误:
int main() {
    string a = "123";
    string b = "456";
    string *pa = &a;
    string *pb = &b;
    string c = (*pa) + (*pb);
}

那么引用的真正目的是什么呢,为什么说是为了引用类对象而生的呢?我的回答是:为了减少临时变量的copy

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

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

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

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

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;
}

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

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

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

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

我们先直接给出例子:

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,而是将临时变量的地址给到了引用,然后由引用将其传递给调用函数,而这一过程指针是做不到,这就是我为什么我们需要引用!

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

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

三、右值引用

1、临时变量的定义

我们在上一小节,交代了引用是如何优化临时变量的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》分为了两类:

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

并且,从这里我们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引用指向临时变量。可是这将大大限制引用的使用,因为如果我就是想修改参数的值呢?于是就出现了:右值引用

2、右值引用

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

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

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

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()函数,也该出场了

3、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()作用是基于当前的左值创建一个可引用的临时变量来处理。我的这个定义我认为是非常精准的,不过还需要进行补充解释:

来看一下汇编代码:

    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来初始化一个新的对象,有两种方法:

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

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()的移动(复制)构造函数剥夺其内存空间,完美!!!!

2、重载赋值运算符

我在上一小节的结尾,故意留了一个错误,我说是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);
}

但是指针也可以!

结束语

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

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

真的真的真的没有了!

上一篇下一篇

猜你喜欢

热点阅读