C++11中的右值引用(對比左值引用和常引用)、移動構造函數和引用標識符

    Hello!各位同學們大家好!逗比老師最近說起來還是挺尷尬的,爲什麼這麼說呢?因爲以前我對自己的C++水平還是相當自信的,經常以“精通”來自我評價。但是最近發現自己好像對C++11當中很多特性還是一知半解,有的甚至完全沒聽過。實在是慚愧啊。雖然說C++11也是幾乎10年前的產物了,但是由於這次大更新已經完全改變了C++這門語言的整體畫風了,所以至今仍然值得我們去仔細研究。否則總感覺自己寫的代碼是C的感覺。

    那麼這次要給大家分享的是,C++11中的右值引用、移動構造函數以及成員函數的引用標識符。

    那麼首先,我們需要先來理清楚一個大家天天在用,但是可能一直忽略掉的一個概念——左值和右值。左值和右值的概念最早是在賦值符號上誕生的。假如我們由這樣一個賦值表達式:

a = b;

    那麼在這個表達式中,a就是左值,b就是右值。我們可以從這個表達式中看到很多左值和右值的特點,比如說,經過了賦值表達式後,右值是一定不會發生改變的,而左值大概率是會發生改變的。這種性質也就決定了,右值可以是常量,也可以是變量,自然也可以是用const修飾的(更恰當的叫法應該叫只讀)變量。但是左值就只能是可變的變量。脫離了賦值表達式以後,我們仍然用“左值”和“右值”的叫法來稱呼其他地方的變量表示其特性。比如說函數實參就一定是右值(別急,我知道你想說什麼),函數返回值也一定是右值(我知道,我知道,等一下嘛,別急)。

    好了,我知道我這話說出來就已經有人迫不及待要反駁我了,那我加一個範圍吧。C語言中的函數實參和返回值都一定是右值。這下沒有異議了吧?那爲什麼這句話放到C++裏就不成立了呢?答案很簡單,因爲C++有引用這種語法。

    雖說引用本質上還是通過指針來實現的,但是在語義層面,我們可以認爲,引用就是給變量起別名。比如這樣:

int &b = a;

    當然,這種說法還是有侷限性的。更準確的說法是:普通的引用相當於給變量起別名。當然,直接定義一個引用這種做法還是很少見的,意義不大,我們主要是把引用用作函數參數作爲出參,用來代替C語言當中傳入指針的方法。比如這樣:

void swap(int &a, int &b) {
    int tmp = a;
    a = b;
    b = tmp;
}

    上面這個用來交換兩個變量值的函數就是接受兩個引用作爲形參。其實效果和接收指針差不多,但是函數內部就省去了很多複雜的取址和解指針的表達式。

    我剛纔反覆強調,普通的引用其實就是指針運算的語法糖,讓我們感覺是給變量取了別名。這種說法如何驗證呢?很簡單,做個sizeof就可以了,但是注意,不能直接給引用取sizeof,因爲這樣取出來的是引用的變量的size。我們需要包裝一下,才能看到這個引用的廬山真面目。

struct Test {
    char &a;
};

int main(int argc, const char * argv[]) {
    std::cout << sizeof(Test) << std::endl;
    return 0;
}

    上面這段代碼在64位計算機中輸出結果是8,因此,說明a其實佔用的空間是一個指針的長度。什麼?你說內存對齊啊?那簡單,加一個變量就可以排除:

struct Test {
    char &a;
    char b;
};

    這樣的話,如果a確實佔8字節,那麼b就會到下一個對齊空間,Test的長度就應該是16;而如果a只佔一個字節,那麼b應該和a在同一個對齊空間中,Test的長度就應該還是8。運行之後我們驗證了之前的說法,Test的長度是16,所以,a確確實實是一個指針的長度。

    既然,普通的引用其實只是一個指針運算的語法糖,那麼,我們就不能給一個常數取引用,因爲常量不能取地址。比如下面的代碼就會報錯:

int &a = 5;

    不過,C++裏還是提供了可以綁定常量的引用的語法,我們稱之爲常引用。常引用這種語法非常特殊,要分兩種情況來對待。第一種,當常引用綁定一個只讀(const修飾的)變量的時候,效果和普通的引用一樣,比如:

const int a = 8; // 只讀變量
const int &b = a; // 用常引用綁定只讀變量

    第二種,當常引用綁定一個常量的時候,其效果相當於定義了一個普通的變量,比如:

const int &b = 8; // 常引用綁定常量

    此時,b的空間是一個int的大小,效果和我們寫一個const int b = 8;基本一致。

    所以常引用這種特殊的語法更爲普遍地用到了函數入參上。這樣可以避免對象的複製,還可以防止函數內對引用對象的修改,與此同時,還可以處理常量。(之所以這樣設計,大概是因爲,既然通過常引用不能改變對象本身,那麼常量也是不會改變的,我們就可以看作同一類事物處理。)

    這個特性在字符串類的使用中更爲普遍,比如:

void printString(const std::string &str) {
    std::cout << str << std::endl;
}

    在函數內部我們不會修改字符串的值,因此str是作爲入參的,但是,如果不用引用的話,就會造成字符串複製之後再傳入函數內,影響效率,而使用非const引用的話,則無法處理常量。使用常引用就可以完美解決所有問題。

std::string str = "123";
printString(str); // OK
printString("abc") // OK

    上面兩種調用方式都是支持的。所以對於基本數據類型來說,僅僅是引用作爲出參是有意義的,其他情況下拷貝指針那還不如拷貝自己本身的值,所以說其他的

    好了,白話了這麼這麼多,都還沒有說到我們今天的重點上。請大家看這樣一個例子:

Str func1(Str str) {
    return str;
}

    假如Str是我們自定義的一個類的話,調用這個函數會發生什麼?

Str str1 = func1(str2);

    首先,實參str2要賦值給形參str,然後str作爲返回值的臨時變量,返回值的臨時變量在作爲str1的參數進行構造。所以,前後一共調用了3次拷貝構造函數。當然,現在編譯器基本都會對這種情況有優化,但是,這種優化並不是標準,我們爲了看清本質,可以在編譯時添加-fno-elide-constructors來關閉返回值優化。

    我在這裏給出一個示例:

class Str {
private:
    bool newMark; // 標記是否有new空間
    char *buffer;
public:
    Str(const char *str);
    Str(const Str &);
    Str &operator =(const Str &);
    ~Str();
};

Str::Str(const char *str): buffer(new char[strlen(str) + 1]), newMark(true) {
    strcpy(buffer, str);
    std::cout << this << "->普通構造函數" << std::endl;
}

Str::Str(const Str &other): buffer(new char[strlen(other.buffer) + 1]), newMark(true) {
    strcpy(buffer, other.buffer);
    std::cout << this << "->拷貝構造函數" << std::endl;
}

Str::~Str() {
    if (newMark) {
        delete [] buffer;
    }
    std::cout << this << "->析構函數" << std::endl;
}

Str func1(Str str) {
    return str;
}

int main(int argc, const char * argv[]) {
    Str str2 = "123";
    Str str1 = func1(str2);
    
    return 0;
}

    我們把普通的構造函數、拷貝構造函數、賦值函數、析構函數都列出來,分別打印它們來觀察調用方式。得到的運行結果如下:

0x7ffee1292af0->普通構造函數
0x7ffee1292b00->拷貝構造函數
0x7ffee1292af0->析構函數
0x7ffee1292ab0->拷貝構造函數
0x7ffee1292ac0->拷貝構造函數
0x7ffee1292ad0->拷貝構造函數
0x7ffee1292ac0->析構函數
0x7ffee1292ab0->析構函數
0x7ffee1292ad0->析構函數
0x7ffee1292b00->析構函數

    可以看出,我們原本只是想複製一份對象,可是實際上卻拷貝了4次對象,中間很多都是臨時變量,僅僅是因爲作用域限定問題在很無聊的複製釋放。用引用優化一下會好一些,比如這樣:

Str func1(const Str &str) {
    return str;
}
0x7ffeee0d2af0->普通構造函數
0x7ffeee0d2b00->拷貝構造函數
0x7ffeee0d2af0->析構函數
0x7ffeee0d2ac0->拷貝構造函數
0x7ffeee0d2ad0->拷貝構造函數
0x7ffeee0d2ac0->析構函數
0x7ffeee0d2ad0->析構函數
0x7ffeee0d2b00->析構函數

    這樣確實減少了一次從str2到str的複製,但是還是多了很多無意義的複製。怎麼辦?我們來分析一下,這個無效的複製在哪裏。

    str由於是常引用了,不需要複製,但是當函數返回的時候,會生成一個臨時變量,來代替函數表達式。(Visual Basic中函數返回值的語法就非常能說明這個問題,因爲VB裏函數返回就是在函數中給函數名賦值來表示返回的。)所以,str對臨時變量的複製,以及臨時變量對str1的複製,這就是冗餘的複製。

    那可能會有同學說,我返回引用不就好了嘛!比如這樣:

Str &func1(const Str &str) {
    return str;
}

    這樣看似沒有問題,但是仔細想想問題很大。我們希望函數能夠返回一個str2的複製,但是這裏卻得到了str2的引用,如果我直接操作這個返回值的話,str2就會發生改變,比如這樣:

func1(str2).buffer = nullptr;

    所以這是不安全的,我們不應該指望其他調用我們程序的人考慮這些細節,而是應該我們考慮好,限制好這種行爲。

    C++11在這裏引入了右值引用的概念,語法是&&,注意兩個引用符號不是引用的引用,而是右值引用,一個新的運算符。當我們這樣調用函數的時候這樣做的話:

Str func1(const Str &str) {
    return str;
}

int main(int argc, const char * argv[]) {
    Str str2("123");
    Str &&str1 = func1(str2);
    return 0;
}
0x7ffee4b49b10->普通構造函數
0x7ffee4b49af8->拷貝構造函數
0x7ffee4b49af8->析構函數
0x7ffee4b49b10->析構函數

    這個結果是我們想要的。右值引用的作用就是綁定一個右值。比如說這裏func1的返回值在返回之後就會被析構,我們把這樣的值稱爲將亡值,也就是說,正常情況下,賦值給str1後就被析構了,但是,如果我們是用右值引用來接收這個返回值的話,它的聲明週期將會被延長,在新的生命週期中它有個了新的名字叫str1。這樣也就避免了不必要的複製。

    與常引用類似,右值引用也可以綁定一個常量,這時,它相當於一個普通的變量。比如這樣:

int &&a = 8; // 右值引用綁定常量

    不過需要注意的是,既然是右值引用,只能用來綁定右值。比如下面的語法就是錯的:

int a = 5;
int &&b = a; // ERROR 不能用右值引用綁定左值

    可能有同學會有這樣的疑問,表達式中,a不是在賦值符號的右邊嗎?爲什麼還是左值呢?關於這一點,我們應當把C++當中“=”符號的不同用法分開來理解。

1. 作爲賦值符號。比如 a = b; 這時a是左值,b是右值。

2.作爲初始化符號。比如int a = 5; 這時5相當於a的初始化參數,期間沒有賦值動作。

3.作爲引用綁定符號。比如 int &a = b; 這時不存在賦值動作,只是表達a是用來綁定b的。

那麼上面的代碼應該是第3中情況,只是表示b是a的引用,不存在賦值,也就不改變a的左右值性質。

    那麼,再詳細一點說就是,右值引用只可以綁定常量(此時相當於普通變量)以及返回值爲非引用類型的函數的返回值(此時用來延長將亡對象的生命週期)。

    如果剛纔我在解釋=的三種理解時第2中的時候你有關注的話,你可能會有這樣的疑問,如果是非POD類型(就是除過基本數據類型或是無方法的結構體之外的,實實在在的對象類型)的情況呢?請看下面代碼:

Str str = "123";

    我們還是用上面的Str作爲例子。在這句代碼裏,=是作爲初始化還是作爲賦值呢?同樣在關閉編譯器的返回值優化功能的情況下,我們看一下輸出結果:

0x7ffeedea1b00->普通構造函數
0x7ffeedea1b10->拷貝構造函數
0x7ffeedea1b00->析構函數
0x7ffeedea1b10->析構函數

    輸出結果已經很說明問題了,這裏的=同樣是作爲初始化參數的,因爲賦值函數沒有被調用。只不過,這裏發生了隱式類型轉換,也就是"123"原本是const char *類型,這裏轉化成了Str類型,換句話說,完整的寫法應該是這樣的:

Str str = Str("123");

    首先我們將"123"作爲參數創建了一個Str對象,調用了普通的構造函數,然後,將這個對象再作爲str的參數,所以調用了拷貝構造函數。所以Str("123")這種形式創造出的對象就是個將亡值,當然,我們可以改用右值引用來接收,防止這種拷貝:

Str &&str = "123"; // 用右值引用來綁定將亡對象

    但是這樣代碼可讀性就降低了,總感覺是用了Str的引用綁定了const char *類型,所以這種只含一個參數的構造函數所導致的隱式類型轉換,仔細想想也是非常可怕的,如果我們沒有注意,沒有用右值引用去綁定,而是用了普通的對象定義,那麼,就會發生一次無意義的拷貝構造。C++11提供了一個關鍵字explicit,用該關鍵字聲明的構造函數將不能進行隱式類型轉換,只能顯式調用,比如這樣:

class Str {
private:
    bool newMark; // 標記是否有new空間
    char *buffer;
public:
    explicit Str(const char *str); // 聲明必須顯式調用
    Str(const Str &);
    Str &operator =(const Str &);
    ~Str();
};

int main(int argc, const char * argv[]) {
    Str &&str = "123"; // ERROR 不能隱式轉換,所以Str &&不能綁定const char *
    Str str = "123"; // ERROR 不能隱式轉換,所以const char *不能轉換爲Str
    Str str("123"); // OK,作爲參數顯式調用構造函數
    
    return 0;
}

    接下來我們需要關注一下這句:

Str str = Str("123");

    如果不用右值引用,這時調用的是拷貝構造函數,也就是相當於這樣:

Str str(Str("123"));

    但是有一個問題就是,拷貝構造函數裏通常是要發生深複製的,也就是我們這裏會吧這個Str("123")的buffer內容複製到str的buffer中。但是,Str("123")又會馬上被釋放掉(因爲是將亡對象),所以,更好的方法並不是做深複製,而是淺複製。因爲這樣的話,我們可以直接利用將亡對象的buffer,而不用申請新的空間(因爲反正將亡對象馬上就析構了,不用擔心互相干擾)。

    在C++11中提供了一個“移動構造函數”就是這個目的,用將亡對象作爲參數構造一個新的對象時將會調用移動構造函數。在移動構造函數裏一般用淺複製。我們完善一下Str的代碼,給出完整版本:

class Str {
private:
    bool newMark; // 標記是否有new空間
    char *buffer;
public:
    explicit Str(const char *str);
    Str(const Str &);
    Str(Str &&);
    Str &operator =(const Str &);
    ~Str();
};

Str::Str(const char *str): buffer(new char[strlen(str) + 1]), newMark(true) {
    strcpy(buffer, str);
    std::cout << this << "->普通構造函數" << std::endl;
}

Str::Str(const Str &other): buffer(new char[strlen(other.buffer) + 1]), newMark(true) {
    strcpy(buffer, other.buffer);
    std::cout << this << "->拷貝構造函數" << std::endl;
}

Str::Str(Str &&other): buffer(other.buffer), newMark(false) {
    std::cout << this << "->移動構造函數" << std::endl;
}

Str::~Str() {
    if (newMark) {
        delete [] buffer;
    }
    std::cout << this << "->析構函數" << std::endl;
}

Str func1(const Str &str) {
    return str;
}

int main(int argc, const char * argv[]) {
    auto str = Str("123"); // 調用移動構造函數
    auto str2 = func1(str); // 調用移動構造函數
    return 0;
}

    這裏用右值引用作爲參數的構造函數就是移動構造函數,只有當構造參數是將亡對象的時候纔會調用移動構造函數,否則將會調用拷貝構造函數。什麼?有人問我如果傳常量呢?拜託!非POD類型的對象哪來的常量啊?哈哈哈!POD類型沒有移動構造函數,它只有默認的位拷貝。所以,不存在這種情況。

    以上是關於右值引用的相關說明。

    不過既然提到了左右值,我們就再看一個例子,假如我們給Str新實現兩個函數,一個用作拼接,一個用作清空,代碼如下:

class Str {
private:
    bool newMark; // 標記是否有new空間
    char *buffer;
public:
    explicit Str(const char *str);
    Str(const Str &);
    Str(Str &&);
    Str &operator =(const Str &);
    ~Str();
    
    Str operator+(const Str &); // 字符串連接
    void reset(); // 字符串清空
};

Str Str::operator+(const Str &right) {
    strcat(buffer, right.buffer); // 爲了簡化代碼,緩衝區大小的問題暫時不考慮了
    return *this;
}

void Str::reset() {
    memset(buffer, 0, strlen(buffer) + 1);
}
// 其他函數的實現同上,省略

    看起來好像沒有問題,但是,如果這樣來調用的話,會怎麼樣呢?

Str str1("123");
Str str2("456");

(str1 + str2).reset();

    大家注意一下調用這個reset()方法的對象,str1和str2拼接後返回的Str對象,其實是一個將亡對象,我們對這個將亡對象進行reset()處理其實是沒有意義的。其實,我們對將亡對象做任何非讀取的操作都是沒有意義的。爲了 避免這樣的情況發生,C++11提供了成員函數的引用標識符,用來聲明某一方法不能用於將亡對象,比如這樣:

class Str {
private:
    bool newMark; // 標記是否有new空間
    char *buffer;
public:
    explicit Str(const char *str);
    Str(const Str &);
    Str(Str &&);
    Str &operator =(const Str &);
    ~Str();
    
    Str operator+(const Str &); // 字符串連接
    void reset() &; // 字符串清空,聲明爲引用標識,不允許將亡對象調用
};

void Str::reset() & {
    memset(buffer, 0, strlen(buffer) + 1);
}
// 其他函數實現省略

int main(int argc, const char * argv[]) {
    Str str1("123");
    Str str2("456");
    (str1 + str2).reset(); // ERROR 將亡對象不能調用reset()方法
    Str str3 = str1 + str2; // 將亡值構造新對象,調用移動構造函數
    str3.reset(); // OK 普通的對象可以調用reset()
    return 0;
}

    那麼既然存在標識爲&的函數,自然也就存在標識爲&&的函數,也就是隻有將亡對象可以調用的函數。不過這種函數貌似很少有使用的場景(至少本逗比想了很久很久都沒有想到一個合適的應用場景,只想到一種勉強算數的,就是寫兩個同名的重載函數,一個用&標識,一個用&&標識,根據調用對象的不同進行不同的處理),用法和標識&的一樣,不再贅述。

    由此,C++的普通的成員函數(不考慮構造、析構、賦值、靜態等)就分成了5類,我給大家列了個表格供參考:

  普通對象(Obj) 只讀對象(const Obj) 將亡對象(Obj &&)
用&修飾的 可以調用 不可以調用 不可以調用
用&&修飾的 不可以調用 不可以調用 可以調用
用const &修飾的 可以調用 可以調用 可以調用
用const &&修飾的 不可以調用 不可以調用 可以調用
用const修飾的 可以調用 可以調用 可以調用
沒有修飾 可以調用 不可以調用 可以調用

    這裏面需要注意的是,用const修飾的函數,內部不可以更改變量的值(只能取值)。用const &修飾和用const修飾基本是等價的(至少逗比目前沒發現區別,如果有小夥伴發現了請一定要留言告訴我。)

    用const &&修飾的同樣不能在內部更改值。

    總結一下就是,有const就不能更改值,有&的將亡對象不能用,有&&的只有將亡對象可以用。

    好啦!蠻累的,算是把這個C++11裏各種引用給解釋完了。如果大家還有什麼問題,歡迎留言,我們一起學習,共同進步。

【本文由逗比老師全權擁有,允許轉載,但轉載時務必註明轉載處並附上原文鏈接。對任何惡意更改和賦值的,逗比老師保留追究的權利。】

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章