關于C++的引用

前言

因為最近在用C++寫項目,因為之前C++的基礎為0,所以對引用的理解非常淺顯,一直將其當作指針來看待,然而現在對其產生了巨大的疑惑,包括:

  • 為什么引用總會和const關鍵字扯上關系
  • 引用究竟是如何減少copy的?
  • 引用和指針到底有什么區(qū)別?
  • 到底什么是右值引用?他是干嘛的?
  • std::move()函數將左值變成右值,作用是什么?

我看了很多關于引用的介紹,說實話,看完之后依然似懂非懂,可能我沒找到好的博客(我也懶得找,反正百度出來的千篇一律),大概停留在引用就是別名,右值就是等號右邊的那些不能被賦值和取地址的值,當然了并不是說他們錯了,而是僅憑此根本不能了解到引用的真正作用,比如我最近看得Pistache的項目,為啥這里要用引用?我就完全解釋不了。

但是我找到一篇介紹引用做返回值的博客(這個人不是轉載,也沒貼原作者的地址)雖然博客說的很清楚,但是首先引用做返回值并不是引用的主要用途,其次博客中的關于臨時變量的部分已經是幾萬年前的編譯器了,他提供的源代碼,編譯器即使在-O0關閉任何的優(yōu)化的情況下編譯出來的匯編代碼在不使用引用的情況下效率更高!也就是說對引用的原理介紹的不能說錯,但是依然沒把引用的優(yōu)勢說出來。

直到昨天我從頭將《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的地址。

這里有一個規(guī)定:引用變量必須在聲明的時候同時進行初始化,而不能先聲明再賦值,其實這一點并不重要,這就是語法規(guī)則,不然我完全可以這樣定義引用:
int &ra;
ra = #a;
%a = 10;
其中我稱#為取引用符,稱%為取引用值符。我之所以想在這里內涵一下指針,是想讓大家明白,所謂語法就是編譯器定義的規(guī)則,最終編譯器要將根據語法規(guī)則寫出的C++代碼變成匯編代碼,匯編代碼將最終實現編譯器所規(guī)定的語法的含義!這有助于我們從匯編的角度,區(qū)分指針和引用。

我們看一下上面這段代碼編譯成的匯編代碼(我是用的是Ubuntu 20.04下,默認的最高版本即 g++ 9.3.0):

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

    movq    %rsp, %rbp          # 分配32字節(jié)大小的函數棧幀空間
    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個字節(jié)。因為引用有一個廣為人知的說法,就是變量的別名,從描述上,好像引用是不占內存的,僅僅是個名字,可能老的編譯器也是這樣實現的,但是現代的編譯器,不是這樣的!

2. 引用與指針

接下來是另一個誤區(qū),引用就是指針!顯然這種看法也是錯誤的,引用和指針的確是在用法上相似,而且他們在匯編語言的級別,都是所指向和引用的對象的地址,但是,編譯器賦予了指針和引用完全不同的語義,比如:

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參數,編譯器沒有任何優(yōu)化
    addl    $1, %edx
    movl    %edx, (%rax)

    movl    $0, %eax        #   return 0;

其實不用看匯編代碼,我們也知道對應的語句做了什么!可以看到,雖然引用和指針相似,但是編譯器對兩者還是有截然不同的語義的,這里將引出本文最核心的問題,引用到底是干嘛的?為什么要添加這樣一個與指針如此接近的引用呢?

二、為什么需要引用?

1、先給結論

《C++ Primer plus》中有一句原話:“類設計的語義常常要求使用引用,這是C++新增這項特性的主要原因?!币簿褪钦f,引用是為了引用對象。可這又是為什么呢?書沒有明說,不過我看到過一個有意思的知乎回答


我之所以截圖上來,并不是想嘲諷這個回答,因為我也不敢保證自己的理解是否真的完全正確,或者也有可能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;
}

對于需要修改變量的時候,只能使用指針傳遞和引用傳遞。但是對于不需要修改的時候:

  • 如果變量是內置類型或很小的結構體、類對象,如int,那么推薦值傳遞
  • 如果變量是很大的結構體或者類對象,那么使用const 指針或const 引用將是首選

需要傳遞的是內置變量或者很小的結構體時,編譯器將直接使用寄存器進行操作,顯然這是最快的,如果要用指針和引用,那么會多一次放存操作;當需要傳遞的值很大,寄存器不夠用時,那么使用指針或者引用,將只需要傳遞變量的地址就可以了!

這里就是我們認為指針和引用都可以減少“臨時變量”的原因,我們把函數的參數,當成了臨時變量!函數的參數可太慘了,因為在匯編中,參數和普通變量一樣,都是存在與棧幀中,都是有名字的,變量,其生命周期是整個函數,因此參數并不是我們的臨時變量,那誰是?

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,而是將臨時變量的地址給到了引用,然后由引用將其傳遞給調用函數,而這一過程指針是做不到,這就是我為什么我們需要引用!

至此,我已經給除了我的理解,這就是為什么要使用引用的終極答案。

但是這里還有幾個問題沒有說到:

  • 臨時變量的定義依然沒定
  • 右值引用呢?move()呢
  • 為什么說,C++為類對象,引入了引用的概念

三、右值引用

1、臨時變量的定義

我們在上一小節(jié),交代了引用是如何優(yōu)化臨時變量的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》分為了兩類:

  • 右值類,所謂右值即只能出現在=右邊的值,他們不能被賦值,不能被取地址
    典型的如,常量( " "字符串除外,因為他本質上是指針變量)、表達式、非引用返回值的函數(引用返回值函數最后說)等。
  • 類型轉化類
    如上一節(jié)中先將" "字符串轉為string對象,構造了一個string臨時變量

需要注意,在執(zhí)行上述引用時,都會在棧幀中分配空間來存放臨時變量,使之不再臨時,而引用就是他們的名字,這樣就讓臨時變量和普通變量一樣,有自己的名字和內存空間,可以通過引用來賦值和取址。

并且,從這里我們get到兩點:

  1. 如果真的是像我上面給出的例子,都是內置的數據類型,那么引用完全就是在添亂,因為首先對于常量,根本不需要占用內存(棧內存)和寄存器,是可以寫在匯編代碼中的,占用的是代碼段的空間,還有兩外兩種情況,因為臨時變量就是int,那么我完全可以用寄存器來存放,速度更快,因為引用使用的是內存地址,這樣會增加一次內存訪問,這就是為什么引用是為類對象而生的原因之一,因為對象一般很大,無法使用寄存器來存放臨時變量,之二的理由放在下一節(jié)
  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、右值引用

右值引用就可以引用臨時變量,并對其進行修改,因此引用其實有四類:

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

很多地方統(tǒng)稱前兩種為左值引用,包括《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()函數通常的解釋是,將左值轉變?yōu)橛抑?,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()作用是基于當前的左值創(chuàng)建一個可引用的臨時變量來處理。我的這個定義我認為是非常精準的,不過還需要進行補充解釋:

  • std::move()是創(chuàng)建新臨時變量,但原變量依然是左值的普通變量,而非臨時變量

    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()是如何創(chuàng)造可引用的臨時變量,簡直是神來之筆,首先這個代碼的返回值是驚人的2 2,為什么呢?

    首先,我們前面說過,引用將臨時變量變得和普通變量一樣,也就是說,普通變量其實已經具備了臨時變量的一切(主要是內存空間!),那么此時我們不需要做任何工作,只需要將普通變量的內存空間當作臨時變量的內存空間即可!對應了std::move(a),這種情況下對臨時變量的修改是會體現在普通變量a上的,這就是為啥a的值變成了2。

    但是,如果是需要進行類型轉化而產生的臨時變量,對應于std::move(b),是沒辦法直接用內存空間的,比如long &&使用int,就會導致錯誤的內存訪問,此時就必須創(chuàng)建新的臨時變量,那么這個時候,對臨時變量的修改是不會會體現在普通變量上的,這就是為啥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_ 

可以看到在執(zhí)行std::move(b)的時候,用了一塊4字節(jié)的棧幀?。。?/p>

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

但是,還有,但是。

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

我們發(fā)現,基于右值引用實現的移動(復制)構造函數,竟然與默認構造函數很想,區(qū)別在于我們會在移動(復制)構造函數中修改參數的值,甚至將其設置為nullptr,這是為什么呢,因為右值引用,引用的是臨時變量,因此我們完全可以“剝奪其資源”,從而大大的加快了構造函數的執(zhí)行效率,這一過程也是引用真正的能區(qū)別與指針,且發(fā)揮其作用的地方,詮釋了為什么引用是為類的對象而生

之所以需要b.data_ = nullptr;是因為臨時變量在將來執(zhí)行析構函數時,會釋放data_,但是我們的f2在執(zhí)行析構時,也會執(zhí)行相同的操作,一塊內存是不能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本身就是一個在執(zhí)行完構造函數就會被拋棄的,那使用std::move(),將其變成臨時變量,然后再由Buff()的移動(復制)構造函數剝奪其內存空間,完美?。。?!

2、重載賦值運算符

我在上一小節(jié)的結尾,故意留了一個錯誤,我說是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()的那個神來之筆,真的是我在寫的過程中想到的。我自詡在中文互聯(lián)網上的有關C++引用的資料里面,我的分析可謂是是如木三分了吧?。‘斎涣诉@一切內容都是基于《C++ Primer plus》的。但是我自己的理解和總結,以及使用g++查看匯編的實現,真的可以說有理有據,不得不吹一下!

其實最后,對于一開始的提到的,關于引用做返回值的用法,我說博客(這個人不是轉載,也沒貼原作者的地址)說的很詳細,但是存在缺陷。其實真的很簡單:

  • 如果返回值是非引用值,那么函數返回值就是一個右值的類型的臨時變量而已,
  • 如果返回值是引用,那么需要注意幾點:
    1. 不能返回函數的動態(tài)變量的引用,因為函數結束后,動態(tài)變量的內存就被回收了
    2. 可以返回static靜態(tài)變量
    3. 可以返回引用類型的參數,這是《C++ Primer plus》的例子

真的真的真的沒有了!

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容