架構師大神帶你讀懂C++

架構師大神帶你讀懂C++

背景和問題


RAII


RAII是 Resource acquisition is initialization 的簡稱,是面向對象編程中常用的一種模式。總結起來,RAII包括:

1把資源的使用和維護封裝在類( class )中

  • 在構造函數中獲得資源並且初始化維護資源需要用到的輔助結構。如果獲得資源失敗,則拋出異常( exception )。
  • 通過析構函數來釋放資源。

2使用資源時,通過類的接口來獲得資源


可以看出RAII的主要思想就是把程序中用到的資源的生命週期跟對象的生命週期綁定起來,利用編程語言的特性來防止資源洩漏。因此,RAII也稱為 Scope-Bound Resource Management 。


一個C++的例子

#include <iostream>
using namespace std;

class MyString
{
public:
\tMyString(const char* string = nullptr)
\t{
\t\tif (string != nullptr)
\t\t{
\t\t\tsize_t length = strlen(string);
\t\t\tm_data = new char[length + 1];
\t\t\tstrcpy(m_data, string);
\t\t\tcout << "memory allocated" << endl;
\t\t}
\t}
\t~MyString()
\t{
\t\tdelete[] m_data;
\t\tcout << "memory released" << endl;
\t}
\tconst char* c_str()
\t{
\t\treturn m_data;
\t}
private:
\tchar* m_data = nullptr;
};

int main()
{
\tMyString string = "Hello, RAII!";
\tcout << string.c_str() << endl;
\treturn 0;
}/<iostream>


上面是一個非常簡單的一個RAII的例子。我們把字符串的內存申請和釋放封裝在了MyString類的構造函數和析構函數中。在main函數中,我們創建了一個MyString的實例並將它打印出來。當我們運行這個程序時,我們會得到如下輸出:

memory allocated

Hello, RAII!
memory released


可以看到,MyString類的使用者不需要去擔心其背後內存的申請和釋放。


淺拷貝和深拷貝

現在讓我們在main函數里面加一些字符串拷貝的操作——構建兩個字符串stringA和stringB,stringB是stringA的拷貝:

int main()
{
\tMyString stringA = "Hello, RAII!";
\tMyString stringB = stringA;
\tcout << stringB.c_str() << endl;
\treturn 0;
}

這段程序在我的MacBook上用Xcode編譯運行時的結果如下:

memory allocated
Hello, RAII!
memory released
Test(27525,0x1000ae5c0) malloc: *** error for object 0x10070c690: pointer being freed was not allocated
Test(27525,0x1000ae5c0) malloc: *** set a breakpoint in malloc_error_break to debug

可以看到,我們遇到了一個運行時的錯誤。為了理解這裡發生了什麼,不得不提一下 拷貝構造函數 這個概念。對於任何類來說,如果我們不定義拷貝構造函數,編譯器會自動幫我們生成一個默認的拷貝構造函數。在這裡,這個拷貝構造函數,只是簡單的值拷貝。因此,拷貝的結果是stringA和stringB有一樣的m_data,它們指向同一個字符串。對於這類拷貝——只拷貝了指針的值,而並沒有拷貝指針指向的內容,我們稱之為

淺拷貝 。顯然,淺拷貝並不是我們想要的結果,而且它導致了stringA和srtingB在析構的時候去釋放同一片內存,這在程序運行時是可能導致程序崩潰的。

為了解決這個問題,我們需要定義自己的拷貝構造函數來實現我們需要的 深拷貝 。一般來說,我們也需要同時定義對應的 拷貝賦值函數 (拷貝構造函數和拷貝賦值函數並稱C++的 拷貝語義):

class MyString
{
public:
\tMyString(const char* string = nullptr)
\t{
\t\tinit(string, "constructor");
\t}
\tMyString(const MyString& myString)
\t{
\t\tinit(myString.m_data, "copy constructor");
\t}
\tMyString& operator=(const MyString& myString)
\t{
\t\tif (this == &myString)
\t\t\treturn *this;

\t\tcleanUp("assignment operator");
\t\tinit(myString.m_data, "assignment operator");
\t\treturn *this;
\t}
\t~MyString()
\t{
\t\tcleanUp("destructor");
\t}
\tconst char* c_str()

\t{
\t\treturn m_data;
\t}
private:
\tvoid init(const char* string, const char* where)
\t{
\t\tif (string != nullptr)
\t\t{
\t\t\tsize_t length = strlen(string);
\t\t\tm_data = new char[++length];
\t\t\tstrcpy(m_data, string);
\t\t\tcout << "memory allocated in " << where << endl;
\t\t}
\t}
\tvoid cleanUp(const char* where)
\t{
\t\tif (m_data)
\t\t{
\t\t\tdelete[] m_data;
\t\t\tm_data == nullptr;
\t\t\tcout << "memory released in " << where << endl;
\t\t}
\t}
\tchar* m_data = nullptr;
};

拷貝帶來的性能問題

當我們定義完拷貝構造函數和拷貝賦值函數,我們的類就有了完整的拷貝語義。這個時候我們可能會碰到如下問題:

MyString getTempString()
{
\tMyString temp = "This is a temp string";
\treturn temp;
}

int main()
{
\tMyString temp;
\ttemp = getTempString();
\treturn 0;
}

在這段程序中可能發生拷貝的地方有兩處:

  1. 在getTempString返回的時候,從temp拷貝構造返回值。
  2. 把getTempString的返回值賦值給temp。

程序在Xcode中的運行結果如下:

memory allocated in constructor
memory allocated in assignment operator
memory released in destructor
memory released in destructor

可以看到,這裡只發生了拷貝#2,拷貝#1應該是被編譯器優化掉了。大家有興趣的話可以在Visual Studio中試一下,有可能會看到拷貝#1(寫到這裡的時候,我把代碼貼到VS2019裡,在debug模式下,可以同時看到拷貝#1和#2)。

總結起來,問題就是從函數返回大型的對象時,程序會因為不必要的拷貝而變得低效。

在傳統C++倆面解決這個問題,主要有兩個思路:

  1. 返回指向對象的指針。這種方法需要調用者注意對象內存的管理——用完要記得銷燬對象。
  2. 返回對象的引用。這種方法不是通過返回值而是通過參數來返回對象。因此需要調用者事先創建好對象,然後通過引用參數將對象傳給函數。

接下來讓我們看看C++11是怎麼解決這個問題的。


C++11中的移動語義

進一步分析問題

如果我們再稍微仔細分析一下上一章最後的問題,不難發現這兩處不必要的拷貝都是從臨時對象做的拷貝。假如有一種新的參數類型能夠區別臨時對象和非臨時對象,那麼我們就有可能用這種新的類型來重載拷貝構造函數和拷貝賦值函數來達到我們的目的——將資源的所有權從臨時變量移動到拷貝的目標身上。

左值,右值和右值引用

為了理解C++中的移動語義,我先介紹一下左值和右值的概念。

左值 ,就是指可以被取地址的表達式。簡單的說,可以出現在等號左邊的就是左值。比如:


 int a;
a = 1;\t// 這裡的a是左值

另外也可以有不是變量的左值:

 int x;
int& getRef()
{
\treturn x;
}
getRef() = 4;

這裡,getRef()返回的是一個全局變量的引用,它的值存在固定的位置,因此是一個左值。

右值 ,則指的是沒有名字的值,它們只出現表達式的計算過程中,也就是等號的右邊。例如:

 string getName()
{
\treturn "Baosong";
}
string name = getName();

getName()返回一個在函數中構造的字符串。你可以把它的值賦給一個變量,但是它是一個臨時對象,我們並不知道它的值放在哪裡。所以,getName()是一個右值。

說清楚了什麼是左值和右值,那麼什麼是右值引用呢?右值引用 是C++11中新引入,是一種只綁定與右值的引用。區別與左值引用(&),它用&&來表示。與左值引用一樣,它也可以是const或者是非const的,但是我們基本不會在實際應用中用到const的右值引用(這個大家可以思考一下為什麼)。讓我們來看一些例子:

const string& name = getName();\t// OK
string& name = getName();\t\t// NOT OK
string&& name = getName();\t\t// OK - YEAH!

從例子中,我們可以看到const的左值引用可以綁定到右值,非const的左值引用不能綁定到右值,右值引用可以綁定到右值。那麼右值引用怎麼幫助我們解決問題呢?讓我們接著看右值引用在作為函數參數時的行為。假如我有下面兩個函數:

void printReference(const MyString& myString)
{
\tcout << "print by const lvaue reference: " << myString.c_str() << endl;
}

void printReference(MyString&& myString)
{
\tcout << "print by rvalue reference: " << myString.c_str() << endl;
}

第一個printReference函數是用const左值引用作為參數,從前面的例子中我們知道它既可以接受左值也可以接受右值。但是當有了第二個printReference的用右值引用的重載之後,右值將優先綁定到第二個printReference。這點我們可以通過如下代碼來驗證:

int main()
{
\tMyString me("Baosong");
\tprintReference(me);
\tprintReference(getTempString());
}

輸出為:

memory allocated in constructor
print by const lvaue reference: Baosong
memory allocated in constructor


print by rvalue reference: This is a temp string
memory released in destructor
memory released in destructor

終於,我們可以寫出專門處理臨時變量的函數了!那麼這個問題的最終解決方案也是呼之欲出了。


移動構造函數和移動賦值函數

在右值引用的幫助下,我們可以通過重載拷貝構造函數和拷貝賦值函數來定義我們想要的從臨時變量拷貝和賦值時的行為。在C+11裡,這兩個重載函數有它們專門的名字——移動構造函數和移動賦值函數。

移動構造函數 :和拷貝構造函數類似,接受一個對象的實例,基於這個實例創建一個新的對象實例。但是在移動構造函數里,我們知道傳入的參數是一個臨時變量,所以沒有必要去做拷貝。高效的做法是把資源從臨時變量那裡“偷”過來。以MyString為例,它的移動構造函數可以這樣實現:

 MyString(MyString&& myString)
{
\tstd::swap(m_data, myString.m_data);
\tcout << "memory moved in move constructor" << endl;
}

移動賦值函數

,它對應與拷貝賦值函數。用移動構造的思路,我們應該很容易寫出移動賦值函數的實現。以下是我的Mystring的移動賦值函數的實現:

 MyString& operator=(MyString&& myString)
{
\tstd::swap(m_data, myString.m_data);
\tcout << "memory moved in move assignment operator" << endl;
\treturn *this;
}

驗證問題已解決

我們已經為Mystring實現了移動構造函數和移動賦值函數,讓我們運行之前的程序:

int main()
{
\tMyString temp;
\ttemp = getTempString();
\treturn 0;
}

結果為:

memory allocated in constructor
memory moved in move assignment operator
memory released in destructor

從輸出結果,可以看到之前的內存拷貝已經被替換成從臨時變量轉移內存。所以,問題圓滿解決!


總結

C++11引入右值引用和移動語義的目的在於從語言層面上提供對深拷貝以及淺拷貝的支持。那麼我們在日常編程中關於這塊,需要注意什麼呢?

Keep it simple

當讀到這裡的時候,大家有沒有覺得移動語義好像很複雜?如何才能用最小的代價來獲得正確移動語義呢?其實很簡單,只要記住以下兩點:

  1. 當類裡面需要定義指針類型的成員變量的時候,不要使用裸指針,而使用智能指針——unique_ptr, shared_ptr等。
  2. 優先使用C++STL裡面的容器,因為這些容器已經定義了正確的移動語義。

如果你的類成員是基本類型和其他已經定義了正確的移動語義的類組合而成的,那麼你完全不需要擔心任何移動語義,並且你會發現你不需要手動去實現析構函數來釋放資源。

還是以MyString為例子,如果它的成員變量m_data被定義為unique_ptr<char>,那麼我們就不用去手動實現移動構造函數以及移動賦值函數了。把這些工作交給編譯器就可以了。/<char>

必要的時候為類添加正確的移動語義

在某些特定情況下,比如用RAII模式來實現的類用來封裝對FILE*的操作,我們就不得不去考慮類的移動語義了。還記得嗎,要為的類添加移動語義,我們需要在類裡面:

  • 實現移動構造函數——C::C(C&&)
  • 實現移動賦值函數——C& C::operator=(C&&)

我們會每週推送商業智能、數據分析資訊、技術乾貨和程序員日常生活,歡迎關注我們的頭條&知乎公眾號“微策略中國”或微信公眾號“微策略 商業智能"。


分享到:


相關文章: