tft每日頭條

 > 生活

 > c指針詳解

c指針詳解

生活 更新时间:2024-07-19 05:24:12

c指針詳解(不再困惑一文教你讀懂C)1

作者:rickonji 冀銘哲

C 11引入了右值引用,有一定的理解成本,工作中發現不少同事對右值引用理解不深,認為右值引用性能更高等等。本文從實用角度出發,用盡量通俗易懂的語言講清左右值引用的原理,性能分析及其應用場景,幫助大家在日常編程中用好右值引用和std::move。

1. 什麼是左值、右值

首先不考慮引用以減少幹擾,可以從2個角度判斷:左值可以取地址、位于等号左邊;而右值沒法取地址,位于等号右邊

int a = 5;

  • a可以通過 & 取地址,位于等号左邊,所以a是左值。
  • 5位于等号右邊,5沒法通過 & 取地址,所以5是個右值。

再舉個例子:

struct A { A(int a = 0) { a_ = a; } int a_; }; A a = A();

  • 同樣的,a可以通過 & 取地址,位于等号左邊,所以a是左值。
  • A()是個臨時值,沒法通過 & 取地址,位于等号右邊,所以A()是個右值。

可見左右值的概念很清晰,有地址的變量就是左值,沒有地址的字面值、臨時值就是右值。

2. 什麼是左值引用、右值引用

引用本質是别名,可以通過引用修改變量的值,傳參時傳引用可以避免拷貝,其實現原理和指針類似。 個人認為,引用出現的本意是為了降低C語言指針的使用難度,但現在指針 左右值引用共同存在,反而大大增加了學習和理解成本。

2.1 左值引用

左值引用大家都很熟悉,能指向左值,不能指向右值的就是左值引用

int a = 5; int &ref_a = a; // 左值引用指向左值,編譯通過 int &ref_a = 5; // 左值引用指向了右值,會編譯失敗

引用是變量的别名,由于右值沒有地址,沒法被修改,所以左值引用無法指向右值。

但是,const左值引用是可以指向右值的:

const int &ref_a = 5; // 編譯通過

const左值引用不會修改指向值,因此可以指向右值,這也是為什麼要使用const &作為函數參數的原因之一,如std::vector的push_back:

void push_back (const value_type& val);

如果沒有const,vec.push_back(5)這樣的代碼就無法編譯通過了。

2.2 右值引用

再看下右值引用,右值引用的标志是&&,顧名思義,右值引用專門為右值而生,可以指向右值,不能指向左值

int &&ref_a_right = 5; // ok int a = 5; int &&ref_a_left = a; // 編譯不過,右值引用不可以指向左值 ref_a_right = 6; // 右值引用的用途:可以修改右值

2.3 對左右值引用本質的讨論

下邊的論述比較複雜,也是本文的核心,對理解這些概念非常重要。

2.3.1 右值引用有辦法指向左值嗎?

有辦法,std::move:

int a = 5; // a是個左值 int &ref_a_left = a; // 左值引用指向左值 int &&ref_a_right = std::move(a); // 通過std::move将左值轉化為右值,可以被右值引用指向 cout << a; // 打印結果:5

在上邊的代碼裡,看上去是左值a通過std::move移動到了右值ref_a_right中,那是不是a裡邊就沒有值了?并不是,打印出a的值仍然是5。

std::move是一個非常有迷惑性的函數,不理解左右值概念的人們往往以為它能把一個變量裡的内容移動到另一個變量,但事實上std::move移動不了什麼,唯一的功能是把左值強制轉化為右值,讓右值引用可以指向左值。其實現等同于一個類型轉換:static_cast<T&&>(lvalue)。 所以,單純的std::move(xxx)不會有性能提升,std::move的使用場景在第三章會講。

同樣的,右值引用能指向右值,本質上也是把右值提升為一個左值,并定義一個右值引用通過std::move指向該左值:

int &&ref_a = 5; ref_a = 6; 等同于以下代碼: int temp = 5; int &&ref_a = std::move(temp); ref_a = 6;

2.3.2 左值引用、右值引用本身是左值還是右值?

被聲明出來的左、右值引用都是左值。 因為被聲明出的左右值引用是有地址的,也位于等号左邊。仔細看下邊代碼:

// 形參是個右值引用 void change(int&& right_value) { right_value = 8; } int main() { int a = 5; // a是個左值 int &ref_a_left = a; // ref_a_left是個左值引用 int &&ref_a_right = std::move(a); // ref_a_right是個右值引用 change(a); // 編譯不過,a是左值,change參數要求右值 change(ref_a_left); // 編譯不過,左值引用ref_a_left本身也是個左值 change(ref_a_right); // 編譯不過,右值引用ref_a_right本身也是個左值 change(std::move(a)); // 編譯通過 change(std::move(ref_a_right)); // 編譯通過 change(std::move(ref_a_left)); // 編譯通過 change(5); // 當然可以直接接右值,編譯通過 cout << &a << ' '; cout << &ref_a_left << ' '; cout << &ref_a_right; // 打印這三個左值的地址,都是一樣的 }

看完後你可能有個問題,std::move會返回一個右值引用int &&,它是左值還是右值呢? 從表達式int &&ref = std::move(a)來看,右值引用ref指向的必須是右值,所以move返回的int &&是個右值。所以右值引用既可能是左值,又可能是右值嗎? 确實如此:右值引用既可以是左值也可以是右值,如果有名稱則為左值,否則是右值

或者說:作為函數返回值的 && 是右值,直接聲明出來的 && 是左值。 這同樣也符合第一章對左值,右值的判定方式:其實引用和普通變量是一樣的,int &&ref = std::move(a)和 int a = 5沒有什麼區别,等号左邊就是左值,右邊就是右值。

最後,從上述分析中我們得到如下結論:

  1. 從性能上講,左右值引用沒有區别,傳參使用左右值引用都可以避免拷貝。
  2. 右值引用可以直接指向右值,也可以通過std::move指向左值;而左值引用隻能指向左值(const左值引用也能指向右值)。
  3. 作為函數形參時,右值引用更靈活。雖然const左值引用也可以做到左右值都接受,但它無法修改,有一定局限性。

void f(const int& n) { n = 1; // 編譯失敗,const左值引用不能修改指向變量 } void f2(int && n) { n = 1; // ok } int main() { f(5); f2(5); }

3. 右值引用和std::move的應用場景

按上文分析,std::move隻是類型轉換工具,不會對性能有好處;右值引用在作為函數形參時更具靈活性,看上去還是挺雞肋的。他們有什麼實際應用場景嗎?

3.1 實現移動語義

在實際場景中,右值引用和std::move被廣泛用于在STL和自定義類中實現移動語義,避免拷貝,從而提升程序性能。 在沒有右值引用之前,一個簡單的數組類通常實現如下,有構造函數、拷貝構造函數、賦值運算符重載、析構函數等。深拷貝/淺拷貝在此不做講解。

class Array { public: Array(int size) : size_(size) { data = new int[size_]; } // 深拷貝構造 Array(const Array& temp_array) { size_ = temp_array.size_; data_ = new int[size_]; for (int i = 0; i < size_; i ) { data_[i] = temp_array.data_[i]; } } // 深拷貝賦值 Array& operator=(const Array& temp_array) { delete[] data_; size_ = temp_array.size_; data_ = new int[size_]; for (int i = 0; i < size_; i ) { data_[i] = temp_array.data_[i]; } } ~Array() { delete[] data_; } public: int *data_; int size_; };

該類的拷貝構造函數、賦值運算符重載函數已經通過使用左值引用傳參來避免一次多餘拷貝了,但是内部實現要深拷貝,無法避免。 這時,有人提出一個想法:是不是可以提供一個移動構造函數,把被拷貝者的數據移動過來,被拷貝者後邊就不要了,這樣就可以避免深拷貝了,如:

class Array { public: Array(int size) : size_(size) { data = new int[size_]; } // 深拷貝構造 Array(const Array& temp_array) { ... } // 深拷貝賦值 Array& operator=(const Array& temp_array) { ... } // 移動構造函數,可以淺拷貝 Array(const Array& temp_array, bool move) { data_ = temp_array.data_; size_ = temp_array.size_; // 為防止temp_array析構時delete data,提前置空其data_ temp_array.data_ = nullptr; } ~Array() { delete [] data_; } public: int *data_; int size_; };

這麼做有2個問題:

  • 不優雅,表示移動語義還需要一個額外的參數(或者其他方式)。
  • 無法實現!temp_array是個const左值引用,無法被修改,所以temp_array.data_ = nullptr;這行會編譯不過。當然函數參數可以改成非const:Array(Array& temp_array, bool move){...},這樣也有問題,由于左值引用不能接右值,Array a = Array(Array(), true);這種調用方式就沒法用了。

可以發現左值引用真是用的很不爽,右值引用的出現解決了這個問題,在STL的很多容器中,都實現了以右值引用為參數的移動構造函數和移動賦值重載函數,或者其他函數,最常見的如std::vector的push_back和emplace_back。參數為左值引用意味着拷貝,為右值引用意味着移動。

class Array { public: ...... // 優雅 Array(Array&& temp_array) { data_ = temp_array.data_; size_ = temp_array.size_; // 為防止temp_array析構時delete data,提前置空其data_ temp_array.data_ = nullptr; } public: int *data_; int size_; };

如何使用:

// 例1:Array用法 int main(){ Array a; // 做一些操作 ..... // 左值a,用std::move轉化為右值 Array b(std::move(a)); }

3.2 實例:vector::push_back使用std::move提高性能

// 例2:std::vector和std::string的實際例子 int main() { std::string str1 = "aacasxs"; std::vector<std::string> vec; vec.push_back(str1); // 傳統方法,copy vec.push_back(std::move(str1)); // 調用移動語義的push_back方法,避免拷貝,str1會失去原有值,變成空字符串 vec.emplace_back(std::move(str1)); // emplace_back效果相同,str1會失去原有值 vec.emplace_back("axcsddcas"); // 當然可以直接接右值 } // std::vector方法定義 void push_back (const value_type& val); void push_back (value_type&& val); void emplace_back (Args&&... args);

在vector和string這個場景,加個std::move會調用到移動語義函數,避免了深拷貝。

除非設計不允許移動,STL類大都支持移動語義函數,即可移動的。 另外,編譯器會默認在用戶自定義的class和struct中生成移動語義函數,但前提是用戶沒有主動定義該類的拷貝構造等函數(具體規則自行百度哈)。 因此,可移動對象在<需要拷貝且被拷貝者之後不再被需要>的場景,建議使用std::move觸發移動語義,提升性能。

moveable_objecta = moveable_objectb; 改為: moveable_objecta = std::move(moveable_objectb);

還有些STL類是move-only的,比如unique_ptr,這種類隻有移動構造函數,因此隻能移動(轉移内部對象所有權,或者叫淺拷貝),不能拷貝(深拷貝):

std::unique_ptr<A> ptr_a = std::make_unique<A>(); std::unique_ptr<A> ptr_b = std::move(ptr_a); // unique_ptr隻有‘移動賦值重載函數‘,參數是&& ,隻能接右值,因此必須用std::move轉換類型 std::unique_ptr<A> ptr_b = ptr_a; // 編譯不通過

std::move本身隻做類型轉換,對性能無影響。 我們可以在自己的類中實現移動語義,避免深拷貝,充分利用右值引用和std::move的語言特性。

4. 完美轉發 std::forward

和std::move一樣,它的兄弟std::forward也充滿了迷惑性,雖然名字含義是轉發,但他并不會做轉發,同樣也是做類型轉換.

與move相比,forward更強大,move隻能轉出來右值,forward都可以。

std::forward<T>(u)有兩個參數:T與 u。 a. 當T為左值引用類型時,u将被轉換為T類型的左值; b. 否則u将被轉換為T類型右值。

舉個例子,有main,A,B三個函數,調用關系為:main->A->B,建議先看懂2.3節對左右值引用本身是左值還是右值的讨論再看這裡:

void B(int&& ref_r) { ref_r = 1; } // A、B的入參是右值引用 // 有名字的右值引用是左值,因此ref_r是左值 void A(int&& ref_r) { B(ref_r); // 錯誤,B的入參是右值引用,需要接右值,ref_r是左值,編譯失敗 B(std::move(ref_r)); // ok,std::move把左值轉為右值,編譯通過 B(std::forward<int>(ref_r)); // ok,std::forward的T是int類型,屬于條件b,因此會把ref_r轉為右值 } int main() { int a = 5; A(std::move(a)); }

例2:

void change2(int&& ref_r) { ref_r = 1; } void change3(int& ref_l) { ref_l = 1; } // change的入參是右值引用 // 有名字的右值引用是 左值,因此ref_r是左值 void change(int&& ref_r) { change2(ref_r); // 錯誤,change2的入參是右值引用,需要接右值,ref_r是左值,編譯失敗 change2(std::move(ref_r)); // ok,std::move把左值轉為右值,編譯通過 change2(std::forward<int &&>(ref_r)); // ok,std::forward的T是右值引用類型(int &&),符合條件b,因此u(ref_r)會被轉換為右值,編譯通過 change3(ref_r); // ok,change3的入參是左值引用,需要接左值,ref_r是左值,編譯通過 change3(std::forward<int &>(ref_r)); // ok,std::forward的T是左值引用類型(int &),符合條件a,因此u(ref_r)會被轉換為左值,編譯通過 // 可見,forward可以把值轉換為左值或者右值 } int main() { int a = 5; change(std::move(a)); }

上邊的示例在日常編程中基本不會用到,std::forward最主要運于模版編程的參數轉發中,想深入了解需要學習萬能引用(T &&)和引用折疊(eg:& && → ?)等知識,本文就不詳細介紹這些了。

如有錯誤,請指正!

,

更多精彩资讯请关注tft每日頭條,我们将持续为您更新最新资讯!

查看全部

相关生活资讯推荐

热门生活资讯推荐

网友关注

Copyright 2023-2024 - www.tftnews.com All Rights Reserved