日本免费高清视频-国产福利视频导航-黄色在线播放国产-天天操天天操天天操天天操|www.shdianci.com

學無先后,達者為師

網站首頁 編程語言 正文

C++精要分析右值引用與完美轉發的應用_C 語言

作者:程序猿阿諾 ? 更新時間: 2022-07-02 編程語言

區分左值與右值

在C++面試的時候,有一個看起來似乎挺簡單的問題,卻總可以挖出坑來,就是問:“如何區分左值與右值?”

如果面試者自信地回答:“簡單來說,等號左邊的就是左值,等號右邊的就是右值。” 那么好了,手寫一道面試題繼續提問。

int a=1;
int b=a;

問:a和b各是左值還是右值?

b是左值沒有疑問,但如果說a在上面是左值,在下面是右值的,那就要面壁思過了。C++從來就不是一門可以淺嘗輒止的編程語言,要學好它真的需要不斷地去探問。公布答案:上面代碼中的a和b都是左值。所以在很多地方都能看到的區分左右值說法是并不準確的。

如果是給出描述性的說明,那么左值就是指向特定內存具有名稱的值(具名對象),它有一個相對穩定的內存地址,并且有一段較長的生命周期。右值是不指向穩定內存地址的匿名值(不具名對象),它的生命周期很短,通常是暫時性的。

要是看著上面這段說明有些抽象,那還有一個好辦法來幫助區分,那就是是否可以用取地址符“&”來獲得地址。如果能取到地址的則為左值,否則編譯期都報錯的,那就是右值。

還是以上面的代碼為例,&a; &b;這個一眼能看出來可以取地址成功,這是左值。而&1這樣的寫法編譯器肯定會報錯,所以1是右值。用這樣的方法,目測也可以判斷出來了。

右值引用

說到C++中的引用,相信大家都很熟悉其用法了。在函數調用時需要對變量進行修改,或者避免內存復制,就會使用引用的方式。當然,使用指針也能達到一樣的效果,但引用相對來說更為安全可靠。這種使用方式就是左值引用。

那么好了,我們先從語法上來認識一下右值引用。

int i = 0;
int &j = i; //左值引用
int &&k = 10; //右值引用

我們看到,右值引用的寫法就是在變量名前加上"&&"標識。它的作用是可以延長字面量數字10的生命周期。不過,這看起來似乎并沒什么用,不像左值引用那樣已經深入人心。那么,我們接下來看一段有意義的示例代碼。

#include        <iostream>
using namespace std;
static const int DataSize = 1024;
class ActOne {
    public:
        ActOne() { cout << "ActOne default construct" << endl; }
        ActOne(const ActOne &one) { cout << "ActOne copy construct" << endl; }
        ~ActOne() { cout << "ActOne destructor" << endl;}
        void DoSomething() { cout << "ActOne work" << endl; }
};
ActOne make_one() {
    ActOne one;
    return one;
}
int main() {
    ActOne one = make_one();
    one.DoSomething();
    cout << "++++++++++" << endl;
    ActOne &&one2 = make_one();
    one2.DoSomething();
}

上述源碼就是實現生成一個對象并返回的功能。需要注意的是,如果使用g++編譯器,對這段代碼進行編譯的時候要加上-fno-elide-constructors以屏蔽編譯器對構造函數的優化操作。

再來看下運行結果:

ActOne default construct
ActOne copy construct
ActOne destructor
ActOne copy construct
ActOne destructor
ActOne work
++++++++++
ActOne default construct
ActOne copy construct
ActOne destructor
ActOne work
ActOne destructor
ActOne destructor

經過對比,我們可以發現未使用右值引用的寫法中,拷貝構造函數執行了兩次,因為這是make_one()中的return one;會復制一次構造產生的臨時對象,接著在ActOne one = make_one();語句中將臨時對象復制到one變量,這是第二次拷貝構造的調用。

那么,使用了右值引用的方法中,拷貝構造函數只調用了一次,one2實際上指向的是一個臨時存儲的變量。因為這個臨時變量被one2作為右值所引用,因此其生命期也延長到main函數結束才調用解析構造方法。

大家可以好好體會一下右值引用的作用,對于性能敏感的C++程序員來說,它不僅是降低了程序運行的開銷,而且臨時局部變量的可引用,也意味著可以減少動態分配內存所帶來的管理復雜度。

移動語義

可能有同學出于對技術的追求,會繼續提問:那我還想優化程序性能,再減少一次拷貝構造函數的開銷行不行?應當對這樣的提問給予積極的回應,答案是可以的,這就是C++11標準所引入的移動語義。

讓我們將上一節的代碼稍加改動,然后來體會一下移動語義的使用。main函數和make_one函數沒有變化,所以僅列出ActOne類的源碼。

class ActOne {
    public:
        ActOne():data_ptr(new uint8_t[DataSize]) { cout << "ActOne default construct" << endl; }
        ActOne(const ActOne &one) { cout << "ActOne copy construct" << endl; }
        ActOne(ActOne &&one) { // 移動構造方法
            cout << "ActOne move construct" << endl;
            data_ptr = one.data_ptr;
            one.data_ptr = nullptr;
        }
        ~ActOne() {
            cout << "ActOne destructor" << endl;
            if (data_ptr != nullptr) {
                delete []data_ptr;
            }
        }
        void DoSomething() { cout << "ActOne work" << endl; }
    private:
        uint8_t *data_ptr;
};

我想對于任何一名寫C/C++的代碼的程序員來說,最大的愿望就是動態內存的分配和釋放次數越少越好。源碼中的ActOne(ActOne &&one)就是一個移動構造方法,它接受的是一個右值作為參數,通過轉移實參對象的數據以實現構造目標對象。如果是復制構造要怎么做?那就要先為data_ptr分配好內存,然后再調用內存拷貝函數memcpy進行一次DataSize字節數的復制。

相比于復制構造方法,移動構造只需要進行指針值的替換即可,其時空消耗是不可同日而語的。程序添加了一個移動構造方法運行之后的結果如下:

ActOne default construct
ActOne move construct
ActOne destructor
ActOne move construct
ActOne destructor
ActOne work
++++++++++
ActOne default construct
ActOne move construct
ActOne destructor
ActOne work
ActOne destructor
ActOne destructor

從上面的結果可以觀察到,在右值引用和移動語義的配合下,內存的分配實際只發生了一次,移動構造也只有一次。大家可以往上翻到上一節的程序打印結果,對比一下純拷貝式的構造,進行了三次內存的分配,兩次內存深復制操作。這對于程序性能的影響已經不用多說了,各位可以進行benchmark測試以驗證移動語義帶來的提升了。

從構造函數的優先級來說,編譯器對于右值會優先使用移動構造函數去生成目標對象,如果移動構造函數不存在,則是使用復制構造函數。那么賦值運算符能不能進行移動操作呢?答案是可以的,這個實現就留給各位自己去嘗試吧。

提示一下,賦值運算符函數的聲明:

ActOne & operator=(ActOne &&one) {……}

完美轉發

我們再來學習C++11中的一個新特性,就是萬能引用。何謂萬能,這個名稱很唬人,其實就是一種引用的實現方法,它既可以引用左值,也可以引用右值。不廢話,還是直接上代碼。

int get_param() { return 100;}
int &&a = get_param(); // a為右值引用
auto &&b = get_param(); // b為萬能引用

可以看到,a和b的區別就在于b的類型是由auto推導而來,而a則是確定類型的。這是作為函數返回值的,再看一個模板參數的例子:

template <class T> 
void func1(T &&t){} // t為萬能引用
int a = 100;
const int b = 200;
func1(a);
func1(b);
func1(get_param());

模板方法的參數t可以接受任何類型的數據,并推導出一個引用類型結果,是什么結果我們后面會說。所以我們會發現,萬能引用本質上是發生了類型推導。auto &&T &&在初始化過程中都會發生類型推導。

那么推導結果的規則也很簡單:

  1. 如果源對象是左值,則目標對象會被推導為左值引用;
  2. 如果源對象是右值,則目標對象會被推導為右值引用。

萬能引用的概念大家已經了解,那么它的用途是什么呢?這就是本節標題所要說的完美轉發。實話說,我不太喜歡C++術語中的某些翻譯,在中文語境下很容易讓人費解、誤解或是產生不必要的期待。例如C++的萬能引用可以實現完美轉發,如果你向一名初學者來上這么一句,他是不是會覺得“這門語言也太牛X了吧,竟然有萬能和完美的特性?” 竊以為換成“全值引用”和“任意轉發”會不會低調和貼切一些呢。

讓我們先從轉發的一個局限性示例說起:

template<class T>
void show_info(T t) {
    cout << "type is: " << typeid(t).name() << endl;
}
template<class T>
void transform(T t) {
    show_info(t);
}
int main() {
    string tmp("test for forward");
    transform(tmp);
}

上述代碼可以工作,但從性能上說string類對象作為參數傳遞時會發生一次臨時對象復制。在實際工作中,它可能就是一個包含有大塊內存變量的對象,顯然不能這么干。那就給參數加上一個&符使之成為左值引用吧。下一個問題又來了,如果傳的參數是個右值怎么?看到這里,大家就明白了,要想結束抬杠在這兒用上萬能引用就好了。

最終版完美引用實現,僅列出有變動的代碼:

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

std::forward()是標準庫中的模板方法,它的功能就是可以根據值的類型將其按左值引用或右值引用進行轉發。這樣,既避免了臨時對象復制的開銷,又可以支持任意類型的對象轉發。某種意義上,將其稱為“完美”似乎也并不為過。畢竟要讓挑剔的C++程序員感到滿意并不容易啊。

需要注意的是,標準庫中的std::move()方法是將任意實參轉換為右值引用,使用這個方法不需要指定模板實參。而std::forward()方法在使用的時候必須指定模板實參,也只有它才能按實際類型進行轉發。

結語

右值引用說到這里,相信大家已經從一知半解的狀態到可以理解并運用了。它對于苛求性能以及強調效率的場景有著非凡的意義,例如在基礎庫組件的實現中。雖然大多數程序員都不一定會參與到基礎庫的開發中,但這就看個人對于技術之道的追求了。即使是調用別人做好的庫來組裝一個應用,也會遇到性能調優的問題,那個時候你對老板有多大的價值就體現在這里了。

如果大家在工作中發現以前的代碼在用支持C++11的編譯器重新編譯之后,運行效率居然有了提升,不用奇怪,這就是基于C++11的新特性做的編譯期優化。例如今天學習的右值引用、移動語義、萬能引用、完美轉發等就在語法層面提供了良好的支持。

希望我們接下來在實踐中不斷練習,能夠發揮出C++的最大威力來!

原文鏈接:https://blog.csdn.net/michaeluo/article/details/124298507

欄目分類
最近更新