C++引用詳解


 

 

1.   引用的實現原理

引用一般的概念稱為變量的別名,定義的時候必須初始化綁定一個指定對象,且中途不可更改綁定對象,那么引用的原理是怎樣的呢?

先看一段簡單的代碼測試

class SimpleReference {
private:
	char& m_r;
};

void PrintSimpleReference(){
	std::cout << "Size of the class with a simple reference is " << sizeof(SimpleReference) << std::endl;
}

輸出結果

可以看到只有一個引用成員對象的類,sizeof是4,跟只有一個指針成員對象的類是一樣的,那么先大膽假設引用其實就是一個指針,看下面這個例子,分別定義一個指針和引用並初始化

void ReferencePointerTest() {
	int i = 100;
	int *pi = &i;
	std::cout << "Pointer Value is " << *pi << std::endl;
	int& ri = i;
	std::cout << "Reference Value is " << ri << std::endl;
}

  通過ctrl+F11查看反匯編代碼,調用如下

從反匯編的匯編代碼來看,使用的命令完全一樣,這樣看來引用就是通過指針的方式來實現的

那么拋出第一個問題,既然引用等價於指針,為什么還要使用引用這個方式呢?

簡單說,引用只是為了優化指針的試用,主要的區別在於:

指針地址可以修改,可以為空,而引用不行,一個未初始化的引用是不能使用的,避免了用戶傳遞無效指針

通過sizeof的計算,指針只能得到指針本身的大小,而引用優化為得到指向對象本身的大小,這點可以推導編譯器記錄對象符號的時候,指針記錄 指針自身地址,引用則記錄引用對象自身地址

從引用的使用方式上,可以推導出T& == T* const,一個不可修改指向地址的指針,但指向內容是非const,依然可以修改內容

2.  const引用

const引用是一種特殊的引用,從字面意思看const引用只是限制了引用對自身引用對象的修改權限,參考如下代碼:

void ConstReferenceTest() {
    int i = 100;
    const int ci = 110;
    const int& rci0 = i;
    const int& rci1 = ci;
    const int& rci2 = 120;
    std::cout << "rci0 " << rci0 << " rci1 " << rci1 << " rci2 " << rci2;
}

最重要的一點,const引用可以綁定一個常量值,而不一定是一個類型對象,這樣作為參數的時候,const引用可以使用臨時對象和常量值作為參數,而非const引用作為參數,只能使用一個引用對象作為參數,參考如下代碼

void ReferenceArgTest(int& ri) {
	std::cout << "Reference Argument is " << ri << std::endl;
}

void ConstReferenceArgTest(const int& cri) {
	std::cout << "Const Reference Argument is " << cri << std::endl;
}
void Test() {
	ReferenceArgTest(100); //error, 常量值不能作為引用對象使用
	ConstReferenceArgTest(100); //correct, 常量可以作為const引用參數
}

 

結論:使用const引用可以包含函數返回臨時對象,常量等非類型對象,這也就是為什么編譯器默認給的復制構造函數參數要用const T&形式;這類對象一般稱為右值

3.   什么是左值,右值

左值右值的概念基於賦值表達式,比如:a = b + 10, a就是一個左值,而b+10得到的結果就是一個右值。那么具體如何區分左右值呢?

簡單說,可以通過&運算符獲取地址的,就是左值;若否就是右值,一個簡單的例子

int a = 10;
++a = 5;   //correct, ++a的返回值是a對象自身,是一個左值可以使用
a++ = 5;    //error, a++的返回值是一個將亡值是沒有地址的,不能作為左值使用

右值又分為純右值和將亡值,純右值就是常量值,100, ‘a’, “abcd”, false這樣的字面值都是純右值;而將亡值則是指臨時對象, a+10表達式,getvalue接口返回等結果都屬於將亡值,當將亡值賦值給具體的左值之后,其自身就會自動析構掉資源

4.   右值引用

可以綁定右值的引用稱為右值引用,傳統的引用因為需要綁定一個具體的對象,所以稱為左值引用。C++98和C++03只有左值引用的概念;從C++11開始,引入了右值引用的概念,為了區分於左值引用,使用&&符號來聲明。比如int&& rr = 10;注意,右值引用只能綁定右值,左值引用也只能綁定左值,如下

int i = 100;
int& r = 100;    //error,左值引用只能綁定左值
int&& rr = i;    //error,右值引用只能綁定右值

注意:右值引用與右值是兩個概念,右值引用本身是可以取地址的,所以右值引用是一個左值,所以rr是一個可以取地址的左值對象

先看一個例子,看看右值引用到底解決了什么問題

假設有兩個相同容量的水池,其中一個空的,其中一個已經注滿了水,現在我們要把空池子注滿水而另一個放空

class WaterPool {
    char* m_pWaterStream = nullptr;
public:
    WaterPool() {
        std::cout << "Construct a empty waterpool" << std::endl;
    }

WaterPool(char* pStream) : m_pWaterStream(pStream) {
        std::cout << "Constrcut a full waterpool" << std::endl;
    }

    ~WaterPool() {
        if (m_pWaterStream) {
            delete[] m_pWaterStream;
            std::cout << "Destruct a full waterpool" << std::endl;
        }
        else {
            std::cout << "Destruct a empty waterpool" << std::endl;
        }
    }
    WaterPool(WaterPool& other) {
        std::cout << "Construct a full waterpool by copy" << std::endl;
        m_pWaterStream = new char[strlen(other.m_pWaterStream) + 1];
        memset(m_pWaterStream, 0, strlen(other.m_pWaterStream) + 1);
        strcpy_s(m_pWaterStream, strlen(other.m_pWaterStream)+1, other.m_pWaterStream);
    }


void draw_off_water() {
  WaterPool w1(new char[100]);
  WaterPool w2 = w1;
}

 

輸出打印:

 

可以看到,傳統的復制構造用了一種比較蠢的方式,先不管第一個滿水池直接用水管注滿第二個池子,然后放空第一個水池;既然已經決定要放空第一個水池的水,為何不直接考慮把第一個池子的水通過一個管道注入第二個水池呢,這樣也節省了一整池子的水資源。因為第一個水池是准備要放水的,也就是說放完水之后這個水池不會再使用了,很符合前面提到的將亡值概念。從將亡值的概念知道,臨時對象交給一個左值之后,就會析構掉資源,如果通過賦值則需要把臨時對象的全部資源拷貝給左值對象,那么是不是可以直接不析構臨時對象的資源而只交接資源所有權給左值對象呢?

如下圖所示

為了解決深拷貝帶來不必要的資源和性能問題,C++11引入了一個新的概念叫move(移動),而右值引用則是為實現移動而出現的解決方案。在C++11中類的默認函數多出兩個移動構造函數和移動賦值函數

先看右值引用的解決方案

WaterPool(WaterPool&& other) {
        std::cout << "Construct a full waterpool by move" << std::endl;
        m_pWaterStream= other.m_pWaterStream;
        other.m_pWaterStream = nullptr;
}

void draw_off_water() {
  WaterPool w1(new char[100]);
  WaterPool w2 = std::move(w1);
}

輸出打印

 

本來的深拷貝賦值動作變成了淺拷貝的移動動作。從析構的打印可以看到,前一個水池直接把水倒入后一個水池,析構掉的是一個空水池。而代表水的這段字符串直接從w1交給了w2控制,而並非在堆上又分配回收一次

 

目前c++11的規則中,如果聲明一個類什么都不寫,但在使用中又使用了這些函數,編譯器是會默認幫助生成以下函數的,包括

默認構造函數

析構函數

復制構造函數  (沒有聲明復制構造,且代碼中調用了復制構造)

復制賦值函數

移動構造函數 (沒有聲明復制構造,沒有聲明析構,沒有聲明移動構造,且代碼中使用了移動構造)

移動賦值函數

注意,雖然復制構造,復制賦值和析構沒有必須的關聯關系,不會因為自定義了復制構造,就無法靠編譯器自動生成復制賦值,但這三者在資源創建和回收應該有管理依賴關系,如果其中一個自定義了,最好還是自定義其他兩個

所以如果自己類中的資源需要特殊處理,最好是自己定義這些構造賦值函數, 

一般來說,需要以下的自定義函數 

class Base {
public:
    Base(); // 默認構造函數
    ~Base(); // 析構函數
    Base(const Base & rhs); // 復制構造函數
    Base & operator=(const Base & rhs); // 復制賦值函數
    Base (Base && rhs); // 移動構造函數
    Base & operator=( Base && rhs); // 移動賦值函數
};

從上面的聲明可以比較清晰得看出,如果在賦值時給出一個左值則會調用復制賦值,如果給出一個右值則會調用移動賦值,而C++還有一個規則是如果沒有定義移動構造且編譯器也未達到自動生成移動構造的條件下而使用了右值引用作為構造參數,會自動調用復制構造來完成(因為復制構造的參數是const T&, 可以綁定給一個右值)

std::swap的具體實現,就是借用了右值引用的概念交換兩個參數對象的資源

這里使用了一個接口叫做std::move, 簡單講就是將一個非右值強轉換為右值,因為只有參數為右值才會觸發這個類對應的移動構造和移動賦值,進行資源轉移所有權的操作,

智能指針中的std::auto_tr在C++11中被棄用而開始使用std::unique_ptr,后者即是基於移動語義來實現的異常安全的獨占資源指針

 

這里先不講move的具體實現,先引申出另一個概念,完美轉發

5.   完美轉發

解析概念之前,先看一個例子

void Print(const BaseClass& b) {
    std::cout << "Print BaseClass by Left Reference" << std::endl;
}

void Print(BaseClass&& b) {
    std::cout << "Print BaseClass by  Right Reference" << std::endl;
}

void PrePrint(BaseClass&& b) {
    Print(b);
}
void ForwardPrint(BaseClass&& b) {
    Print(std::forward<BaseClass>(b));
}

PrePrint函數傳遞的外部參數是一個右值引用,但是在內部直接使用變成了調用左值引用,是因為b作為右值引用對象,其本身是一個左值。但這種實現則違背了我們的初心,我們是期望調用右值引用參數的方法

再來看ForwardPrint,將參數做了一次轉發轉換為右值引用的值,則保證了傳遞給Print的參數是右值引用,std::forward這個轉發機制則是用於解決這個問題,但是完美轉發的作用主要還是作用於模板編程,看下面的問題

對於模板編程來說,T&&一定是右值引用嗎?

看一個例子

template<class T>
void TemplateReferenceCollapsing(T&& t) {
    Print(std::forward<T>(t));
}

void ReferenceCollapsingTest() {
    BaseClass b;
    BaseClass &rb = b;
    TemplateReferenceCollapsing(b);
    TemplateReferenceCollapsing(rb);
    TemplateReferenceCollapsing(Create());
}

參數填入左值,右值均可通過編譯,說明傳入不一樣的實參,T&&會變成不一樣的類型,這種引用既不是左值引用也不是右值引用,但又既可以作為左值引用又可以作為右值引用,稱為萬能引用

這里提出一個新的概念叫引用折疊(reference collapsing),折疊的規則

傳入實參

推導的T

折疊后實參型

A

A&

A& + && = A&

A&

A&

A& + && = A&

A&&

A

A + && = A&&

(類型推導不再這里介紹,折疊規則一個簡單的記憶就是只有調用傳入右值引用,T&&才會實例為真正的右值引用參數,否則都是左值引用)

這里解決了一個問題,如果我們傳遞的參數既可能是左值又可能是右值,如果以具體類型重載接口,那么一個參數就需要重載兩個接口,N個參數就需要重載2N個接口,這顯然工程巨大且不現實。有了引用折疊自動推導參數后,只需要帶上一個完美轉發,一個接口就處理了全部的情況,看一個實際應用的例子

template<class Function, class... Args>
inline auto FuncWrapper(Function && f, Args && ... args) -> decltype(f(std::forward<Args>(args)...))
{
return f(std::forward<Args>(args)...);
}

這是一個萬能的函數包裝器,無論帶不帶返回值,帶不帶參數,帶不定數量的參數均可使用這個包裝器

理解了引用折疊,std::move的實現就比較好理解了,看實現源碼

std::remove_reference的作用是去除模板類型的引用屬性

std::remove_reference<T&>::type = T

std::remove_reference<T&&>::type = T

std::remove_reference<T>::type = T

所以無論模板參數T是哪種情況,move都可以強制將參數轉換為T&&得到一個右值引用

 

總結

  1. 左值引用的實現原理是T* const,所以引用一旦初始化就不能更改對象但可以修改內容
  2. 右值引用不是RVO(Return Value Optimization), 后者比右值引用更厲害,是直接編譯器優化了代碼內部執行的重復構造和臨時對象復制。右值引用並不能減少構造函數的調用,但是它可以選擇移動構造避免堆內存反復的分配回收,當然,移動構造的移動實現可能需要你自己來實現(c++內部的數據結構大部分已經做了默認處理,比如stl容器)
  3. 完美轉發是為了解決右值引用參數在函數內部的二次調用,右值引用對象不是右值而是左值
  4. 如果我們期望用到右值引用相關的效果(比如移動構造,右值引用參數重載函數),請用std::move把左值強制轉換成右值(但是函數返回臨時對象不建議加,因為已經被RVO優化過了)

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM