移動語義
本文是對《最好的C++教程》的整理,主要是移動語義部分,包含視頻85p左值和右值、89p移動語義與90p stdmove和移動賦值操作符。
移動語義是C++11的新feature,可能許多人學習的時候尚未使用到C++11的特性,但是現在C++11已經過去了10年了,早已成為廣泛使用的基礎特性。所以絕對值得一學。在我的上一篇博客自己動手寫Vector中就用到了相關的內容對Vector的性能做了一定的提升,學習完本文后可以到其中看看實際中的使用。
文章主題內容來自Cherno的視頻教程,但是同時也加入了一些個人的理解和思考在其中,並未一一指出,如有錯誤或疑惑之處,歡迎留言批評指正。
本文包含的知識點:左值和右值、移動語義、stdmove、移動賦值操作符
原作者Cherno視頻鏈接:Cherno C++視頻教程
文中代碼GitHub鏈接:https://github.com/zhangyi1357/Little-stuff/tree/main/Move-Semantics
左值與右值
相信你已經在很多地方聽過左值右值了,比如編譯器的報錯等處。想要理解移動語義,左值和右值是繞不過去的概念。
如果你對左值和右值已經十分熟悉了,可以直接跳過此章節,直接閱讀移動語義部分。
如果你去看左值右值的定義或者到CSDN上去找什么是左值右值,你可能會看得暈頭轉向。不過我們不需要對背誦左值和右值的定義,只需要用一個基本的原則指導我們去應用左值和右值就可以了。畢竟我們只是需要學習其用法而不是做語言律師。
這個基本原則就是:
- 左值對應於一個實實在在的內存位置,右值只是臨時的對象,它的內存對程序來說只能讀不能寫。
以上原則或許不能精確描述左值和右值的定義,但是足夠我們理解左值和右值的應用。
我們結合一些具體的例子來應用上面的原則。
基本概念
int i = 10;
i = 5;
int a = i;
a = i;
這里a, i就是左值,10, 5為右值,我們可以用右值來初始化左值或賦值,也可以用左值來初始化左值或賦值給左值。
10 = i; // error
而左值顯然不能賦給右值。
應用基本原則上述都是很自然的事情,右值沒有存儲其的位置,自然不能給它賦值,左值就當成一個變量,想怎么賦值就怎么賦值。
引用
// int& b = 5; // can't reference rvalue
int& c = i; // allowed
可以對一個有地址的變量創建引用(引用本質上就是指針的語法糖),右值沒有地址自然不能引用。
函數返回值
關於函數返回值和參數完全可以把傳參和返回過程看成是賦值來理解。
int GetValue() {
return 5;
}
i = GetValue();
GetValue() = i; // error
這里GetValue函數的返回值為右值,可以當成和前面一樣的情況。
int& GetLValue() {
static int value = 10;
return value;
}
i = GetLValue(); // true
GetLValue() = i; // true
函數的返回值一樣可以是左值,不過要注意的是函數不能返回其臨時變量,因為臨時變量雖然有其內存位置,但是函數調用結束后棧幀就銷毀了,臨時變量一並銷毀了,所以就不能作為左值了。
函數參數
void SetValue(int value) {}
void SetLValue(int& value) {}
SetValue(i);
SetValue(5);
SetLValue(i);
SetLValue(5); // error
這幾個可以用作練習。
Const
上面的函數參數問題似乎有些讓人惱火,因為有時候你確實就是想傳入一個值而不是創建一個變量再傳入,實際上C++為此提供了解決方案。
const int & d = 5;
void SetConstValue(const int& value) {}
SetConstValue(i);
SetConstValue(5);
你可能會想說,這樣就沒法在函數里改變value的值了。但是如果你需要改變value的值,你就不能傳入一個右值。二者不可兼得。
右值引用
現在我們介紹一個對於移動語義實現的關鍵。
前面我們說到int&只接受左值,const int&左右值都接受,那么有沒有一種方式只接受右值呢?
void PrintName(const std::string& name) {
std::cout << "[lvalue] " << name << std::endl;
}
void PrintName(const std::string&& name) {
std::cout << "[rvalue] " << name << std::endl;
}
std::string firstName = "Yan";
std::string lastName = "Chernikov";
std::string fullName = firstName + lastName;
PrintName(fullName);
PrintName(firstName + lastName);
注意第二個函數的參數類型,相較於前一個多了一個&符號,代表其僅接受右值引用。
以上程序的輸出為:
[lvalue] YanChernikov
[rvalue] YanChernikov
移動語義
為什么需要移動語義?
首先來講講我們為什么需要移動語義,很多時候我們只是單純創建一些右值,然后賦給某個對象用作構造函數。
這時候會出現的情況是,我們首先需要在main函數里創建這個右值對象,然后復制給這個對象相應的成員變量。
如果我們可以直接把這個右值變量移動到這個成員變量而不需要做一個額外的復制行為,程序性能就這樣提高了。
例子
讓我們看下面這樣一個例子
#include <iostream>
#include <cstring>
class String {
public:
String() = default;
String(const char* string) {
printf("Created!\n");
m_Size = strlen(string);
m_Data = new char[m_Size];
memcpy(m_Data, string, m_Size);
}
String(const String& other) {
printf("Copied!\n");
m_Size = other.m_Size;
m_Data = new char[m_Size];
memcpy(m_Data, other.m_Data, m_Size);
}
~String() {
delete[] m_Data;
}
void Print() {
for (uint32_t i = 0; i < m_Size; ++i)
printf("%c", m_Data[i]);
printf("\n");
}
private:
char* m_Data;
uint32_t m_Size;
};
class Entity {
public:
Entity(const String& name)
: m_Name(name) {}
void PrintName() {
m_Name.Print();
}
private:
String m_Name;
};
int main(int argc, const char* argv[]) {
Entity entity(String("Cherno"));
entity.PrintName();
return 0;
}
程序的輸出結果是
Created!
Copied!
Cherno
可以看到中間發生了一次copy,實際上這次copy發生在Entity的初始化列表里。
從String的復制構造函數可以看到,復制過程中還申請了新的內存空間!這會帶來很大的消耗。
移動構造函數
現在讓我們為String寫一個移動構造函數並為Entity重載一個接受右值引用參數的構造函數,另外我們還將原來的構造函數注釋掉了。
String(String&& other) {
printf("Moved!\n");
m_Size = other.m_Size;
m_Data = other.m_Data;
other.m_Data = nullptr;
other.m_Size = 0;
}
~String() {
printf("Destroyed!\n");
delete[] m_Data;
}
Entity(String&& name)
: m_Name(name) {}
// Entity(const String& name)
// : m_Name(name) {}
輸出為
Created!
Copied!
Destroyed!
Cherno
Destroyed!
幸運的是可以看到沒有報錯,確實調用了新寫的Entity的構造函數並輸出了結果。
但是不幸的是還是調用了String的賦值構造函數,問題出在哪呢?
實際上接受右值的函數在參數傳進來后其右值屬性就退化了,所以給m_Name的參數仍然是左值,還是會調用復制構造函數。
解決的辦法是將name轉型,
Entity(String&& name)
:m_Name((String&&)name) {}
但是這樣的作法並不優雅,C++為了提供了更為優雅的做法
Entity(String&& name)
:m_Name(std::move(name)) {}
修改之后的輸出結果為
Created!
Moved!
Destroyed!
Cherno
Destroyed
完美!
移動賦值運算符
上面的例子講了關於移動構造函數的例子,然而有時候我們想要將一個已經存在的對象移動給另一個已經存在的對象,就像下面這樣。
int main(int argc, const char* argv[]) {
String apple = "apple";
String orange = "orange";
printf("apple: ");
apple.Print();
printf("orange: ");
orange.Print();
apple = std::move(orange);
printf("apple: ");
apple.Print();
printf("orange: ");
orange.Print();
return 0;
}
我們需要的是一個移動賦值運算符重載
String& operator=(String&& other) {
printf("Moved\n");
if (this != &other) {
delete[] m_Data;
m_Size = other.m_Size;
m_Data = other.m_Data;
other.m_Data = nullptr;
other.m_Size = 0;
}
return *this;
}
注意這里的實現還是有點講究的,因為移動賦值相當於把別的對象的資源都偷走,那如果移動到自己頭上了就沒必要自己偷自己 。
更重要的是原來自己的資源一定要釋放掉,否則指向自己原來內容內存的指針就沒了,這一片內存就泄露了!
上述輸出結果是
Created!
Created!
apple: apple
orange: orange
Moved
apple: orange
orange:
Destroyed!
Destroyed!
很漂亮,orange的內容被apple偷走了。
C++ 三/五法則
瀏覽知乎時看到了如下的回答
其實這說的就是如果有必要實現析構函數,那么就有必要一並正確實現復制構造函數和賦值運算符,這被稱為三法則。
如果加上這一節所講的移動構造函數和移動賦值運算符,則被稱為五法則。
上述法則可以用來識別C++項目的代碼質量,既然在用C++寫代碼,希望就能寫出符合規范的優雅的代碼,做一個更優秀的C++er。
更多詳細資料可以參考C++ 三/五法則 - 阿瑪尼迪迪 - 博客園 (cnblogs.com)
參考資料
C++ 三/五法則 - 阿瑪尼迪迪 - 博客園 (cnblogs.com)
原文鏈接:https://www.cnblogs.com/zhangyi1357/p/16018810.html
轉載請注明出處!