揭開C++移動與複製的神祕面紗

斑馬不睡覺發表於2018-04-18
摘要:本次分享主要圍繞C++中的移動與複製問題,講解了移動與複製過程中涉及的一系列概念,具體場景中存在的問題以及解決方案。幫助大家深入學習C++中移動與複製,並解決實際問題。
演講嘉賓簡介:付哲(花名:行簡),阿里雲高階開發工程師,哈爾濱工業大學微電子學碩士,主攻方向為分散式儲存與高效能伺服器程式設計,目前就職於阿里雲表格儲存團隊,負責後端開發。

左值與右值。
左值與右值的概念來自賦值表示式。以x=a+b+c;為例,其中“x”就是左值,而“a+b+c”的結果就是右值。左值就是可以放到等號左邊的值,或者稱“變數”。右值是隻能放在右邊,不能放在左邊的值。那麼,什麼值可以放在左邊呢?C++標準規定,一個可取地址的變數就可以放在左邊。右值是表示式的值,是臨時變數,無法對它取地址,因為當表示式計算結束之後,它的地址就析構了。因此,上面的表示式就不能寫成a+b+c=x;
右值引用
· C++98只有左值引用,C++11增加了有值引用。
· 非const左值引用只能繫結左值,不能繫結右值。

·
const左值引用是既可以繫結左值又可以繫結右值的。

·
右值引用只能繫結右值,不能繫結左值。

·
右值引用允許移動。
在C++98中,雖然編譯器本身是有左值和右值的劃分的,但它沒有將右值本身暴露給使用者使用,因此用到的引用都是左值。而C++11中增加了右值引用,如下面這段程式碼,一個右值引用只能繫結到右值上。如果嘗試將其繫結到左值上,那麼編譯就會報錯。注意,右值引用和右值本身也是不一樣的,右值本身是沒有名字的,也是無法取地址的。而右值引用本身有名字有地址,因此右值引用本身是左值,只不過它繫結到了右值上。
 
int x;
int&& rref = x; // error!
int&& rref = GetTemp(); // ok

右值引用有什麼用呢?大家知道右值代表一個臨時變數,在C++98中,我們只能對臨時變數值進行復制,完成後臨時變數會被析構。大家可能會思考一個問題,我們為什麼不能在臨時變數析構之前把變數的值取出來呢?如何判斷一個變數是臨時變數,可以把它的值取走,而不是複製呢?那就要用到右值引用。因為用傳統的左值引用會將值繫結到非臨時變數上,那麼就只能對變數進行復制,而右值引用會繫結到一個臨時變數上,那麼就可以安全地移走它的值。C++11中就將這種操作稱為移動。相應的也增加了移動建構函式和移動賦值函式。
特殊函式
現在C++11中大致包含以下這些特殊函式,編譯器會幫助我們生成。預設一個型別至少會有這些函式。後面會講這些函式的特殊之處。
· 預設建構函式
· 解構函式
· 複製建構函式
· 複製賦值函式
· 移動建構函式
· 移動複製函式
 
class Widget {
public:
    Widget(); // 預設建構函式
    ~Widget(); // 解構函式
    Widget(const Widget& rhs); // 複製建構函式
    Widget& operator=(const Widget& rhs); // 複製賦值函式
    Widget(Widget&& rhs); // 移動建構函式
    Widget& operator=(Widget&& rhs); // 移動賦值函式
private:
    std::string mName;
    int32_t mCount;
};

發生構造與賦值的場景
· 發生複製或移動構造的場景:
   ·使用括號或花括號初始化
   ·使用等號初始化
   ·函式的實參到形參
· 發生賦值的場景:
   ·使用等號賦值
// 場景0
Widget w1(w0);
// 場景1
Widget w2 = w0;
// 場景2
void Func(Widget w);
Func(w0);

在這個例子中,case0,1,2都會呼叫建構函式。如果建構函式的引數是Widget,且為左值就會呼叫複製建構函式,如果引數是Widget的右值,就會呼叫移動建構函式,移動後右值對應的物件就成為空物件,不持有任何資源。
賦值函式的規則也一樣,如果引數是Widget左值就呼叫複製賦值函式,如果引數是Widget右值就呼叫移動賦值函式。在case3中,在宣告函式時,傳入的引數w稱為形參,而實際呼叫時傳入的w0稱為實參。在實參到形參的過程中存在構造行為,同樣遵循上述原則。
如果一個類沒有移動建構函式和移動賦值函式,並且它在進行構造和賦值時,引數是右值,會發生什麼呢。在C++11以前規定,要麼編譯器為類生成這兩個移動函式,要麼編譯器呼叫複製建構函式或複製賦值函式,來代替移動。這也是之前提過的,兩個賦值函式的引數必須是const引用的原因,只有const引用才能繫結右值,編譯器才能通過兩個複製函式來代替兩個移動函式。

編譯器生成特殊函式的規則
剛才介紹的六個特殊函式,編譯器會按照某種規則生成這些函式,生成的函式都是public,內聯,非虛的。一個例外是如果一個類有虛擬函式那麼編譯器生成的解構函式也是虛擬函式。其中,預設建構函式與解構函式的預設構造規則已經介紹過了。下面介紹生成複製函式和移動函式的生成規則。
· 生成複製函式的規則:沒有宣告覆制函式,且程式碼中呼叫了複製函式。
· 生成移動函式的規則:沒有宣告覆制函式,且沒有宣告解構函式,且沒有宣告移動函式,且程式碼中呼叫了移動函式。
當一個類沒有自定義的複製建構函式或複製賦值函式,且沒有禁止生成它,且程式碼中呼叫了它,注意,一定要是某行程式碼呼叫了複製建構函式或複製賦值函式,編譯器才會為它生成這兩個函式之一。另外,兩個函式是獨立生成的,互不影響。不會因為自定義了複製建構函式,就不生成對應的複製賦值函式。但這樣其實是存在問題的。複製建構函式、複製賦值函式、解構函式三者應該是有關聯的,如果定義了其中一個,那麼另外兩個也應該自定義。因為這三個函式都與資源管理有關,比如自定義了複製建構函式,如下面這段程式碼,預設的賦值函式會把pb指標賦值過去,即淺複製。但這個例子中,需要進行深複製,因此需要定義一個複製建構函式。但只定義這個複製建構函式還不夠,編譯器還為它生成了對應的賦值函式和解構函式。而這兩個函式的行為顯然是錯的。因為賦值函式進行了淺複製,解構函式也沒有將對應的記憶體釋放。這就是為什麼三個函式要麼都不定義,要麼一起定義。
 
class Widget {
    ...
private:
    Bitmap* pb;
};

Widget::Widget(const Widget& rhs) {
    pb = new Bitmap(*rhs.pb);
}

在C++98中,沒有將這三個函式實現關聯,大家需要牢記這些規則。而在C++11中明確的提出前面提到的這五個函式都是關聯的(複製建構函式、複製賦值函式、移動建構函式、移動賦值函式、解構函式)。只要使用者定義了一個,編譯器就不會生成其他任意的一個函式。
以Widget類為例,編譯器自動生成的建構函式會依次構造每個成員,解構函式會逆向依次析構成員,複製函式就是按順序依次進行復制,移動函式會依次進行移動。
要求編譯器生成特殊函式
當已經宣告瞭某個特殊函式,比如解構函式,這時會導致編譯器不在生成兩個移動函式,但如果我們還是想要編譯器生成它們,在C++11中,可以將特殊函式宣告為default,以下面這段程式碼為例,自定義了建構函式和解構函式。之所以要自定義建構函式是因為,mCount這個變數是int32_t型的,對於這樣的型別,預設的建構函式是不會對它進行初始化的,是不符合需求的。但對於其他幾個函式而言,只需要定義成default,就可以讓編譯器自動為我們生成。
 
class Widget {
public:
    Widget(); // 預設建構函式
    ~Widget(); // 解構函式
    Widget(const Widget& rhs) = default;
    Widget& operator=(const Widget& rhs) = default;
    Widget(Widget&& rhs) = default;
    Widget& operator=(Widget&& rhs) = default;
private:
    std::string mName;
    int32_t mCount;
};

移動與複製函式的寫法
下面展示了自定義移動與複製函式的例子。
 
Widget::Widget(const Widget& rhs)
    : mName(rhs.mName)
    , mCount(rhs.mCount) {}

Widget& Widget::operator=(const Widget& rhs) {
    mName = rhs.mName;
    mCount = rhs.mCount;
    return *this;
}

Widget::Widget(Widget&& rhs)
    : mName(std::move(rhs.mName))
    , mCount(rhs.mCount) {}

Widget& Widget::operator=(Widget&& rhs) {
    mName = std::move(rhs.mName);
    mCount = rhs.mCount;
    return *this
}

這裡需要注意幾點。第一點,為什麼兩個賦值函式都要返回指向自身的引用。這是為了能像內建型別一樣做連續賦值,如x=y=z。第二點,兩個移動函式中呼叫了C++11中新增的函式std::move,這個函式的功能是將一個左值轉化成右值,這樣才能進行移動。其中rhs是一個右值引用,但為什麼它的成員是左值?因為它有名字,可以取到地址。前面提到過,右值引用本身是左值,它的成員也是左值。通過std::move就可以將它轉化成右值,此時進行移動才是安全的。
下面這個例子類似智慧指標。
 
避免無意的移動變複製
· 通常移動要比複製開銷更低。
· 如果沒有移動函式,會呼叫複製函式。
· 造成效能損失,且難以發現。
· 建議:將預設生成的特殊函式宣告為default。
實際上我們推薦大家對每個需要編譯器生成的複製函式和移動函式進行顯式定義並宣告為default。這樣可以避免無意的將移動操作變為複製操作。比如下面展示的程式碼中,StringTable本身沒有宣告析構、複製和移動函式,因此編譯器為自動為它生成這些函式。
 
class StringTable {
public:
    StringTable() {}
    ...               // 編譯器生成析構、複製、移動函式
private:
    std::map<int, std::string> values;
};

一旦使用者為它定義了解構函式,希望在析構時寫入日誌。那麼根據規則,編譯器就不再會為它生成移動函式了。但在實際用到移動函式的時候編譯也不會失敗,編譯器會去呼叫相應的複製函式,複製函式是不會受到解構函式的影響的。這時,我們以為發生的是移動,即移動指標,開銷很低。但實際上發生了複製,開銷變得很高。
 
class StringTable {
public:
    StringTable() {
        makeLogEntry("Creating StringTable object");
    }
    ~StringTable() {
        makeLogEntry("Destroying StringTable object");
    }
private:
    std::map<int, std::string> values;
};

禁止移動或複製
· 有些型別不希望被移動或複製
· C++98中通過只宣告private的複製函式來實現。
· C++11中通過宣告特殊函式為delete來實現。
但有些型別是不希望被移動或複製的,這時該如何做呢?C++98中,通過下面這段來實現,宣告覆制函式並標記為private。類外無法呼叫,類內無法連結。但這種方式比較隱晦,不直接。
 
class Widget {
public:
    ...
private:
    Widget(const Widget&);
    Widget& operator=(const Widget&);
};

C++11中給出了一種更為安全的方法。如下面這段程式碼所示,將這些函式宣告稱delete,那麼編譯器就不會生成這些函式了,並且也無法被自定義,徹底禁用了移動和複製。Delete還有一些其他用途,但在這裡就不展開介紹了。
 
class Widget {
public:
    Widget(const Widget&) = delete;
    Widget& operator=(const Widget&) = delete;
    Widget(Widget&&) = delete;
    Widget& operator=(Widget&&) = delete;
};


移動和複製函式可以是虛擬函式嗎?
下面對這些特殊函式進行一些探索,大家知道建構函式不可以是虛擬函式,而解構函式有時必須為虛擬函式,那麼兩個賦值函式可以是虛擬函式嗎?以下面這段程式碼為例,將Base類中的賦值函式宣告為虛的,並在Derived子類中改寫它。
 
struct Base {
    virtual ~Base() {}
    virtual Base& operator=(const Base& b);
};

struct Derived: public Base {
    virtual Derived& operator=(const Derived& d);
};

從編譯器的角度看,賦值函式也是一種普通函式,當然可以是虛的。但從使用者的角度,虛擬函式用在多型場景下,也就是用基類的指標或引用,呼叫賦值操作,實際呼叫的卻是派生類的賦值函式。那麼可能是這個場景:
int main() {
    Base* p0 = new Derived();
    Base* p1 = new Derived();
    *p0 = *p1;
}
在這一例子中通過基類指標完成派生類的賦值。但這裡派生類根本沒有改寫基類的虛擬函式,因為虛擬函式的改寫規則是,函式名、引數等要與基類完全相同。因此,編譯器不會認為它們是改寫的關係,而會認為Derived又宣告瞭一個自己的虛擬函式。
struct Derived: public Base {
    virtual Derived& operator=(const Base&);
    virtual Derived& operator=(const Derived&);
};
這才是編譯器看到的Derived類。那麼應該如何實現呢?可以在Base基類中實現Clone介面,來實現多型複製。
struct Base {
    ...
    Base* Clone() const = 0;
};
小結:
· 虛擬函式要求引數型別完全相同。
· 無法正確改寫基類的虛的移動和複製函式。
· 通過虛的Clone函式來實現多型複製。
· 移動函式不適合多型行為。
正確的複製與移動基類
在實現派生類的複製和移動時,通常會比較關注,是否有成員忘記處理。除了派生類本身的成員物件外,還需要處理基類的物件。以下面這段程式碼為例,基類Base中有成員x,派生類Derived類中有成員y。在複製的時候只複製了y,因此x的值並沒有發生改變。這就是由於在複製時,沒有複製基類的物件,導致基類的物件被預設構造了,丟失了原物件的x。這種錯誤與之前介紹過的移動與複製問題一樣,很難被發現。
 
struct Base {
    Base(): x(0) {}
    Base(const Base& b): x(b.x) {}
    Base& operator=(const Base& b) {
        x = b.x;
    }
    int x;
};

struct Derived: public Base {
    Derived(): y(1) {}
    Derived(const Derived& d): y(d.y) {}
    Derived& operator=(const Derived& d) {
        y = d.y;
    }
    int y;
};

int main() {
    Derived d0;
    d0.x = 2;
    d0.y = 2;
    Derived d1 = d0;
    printf("%d %d
", d1.x, d1.y); //0 2
    d1.x = 3;
    d1 = d0;
    printf("%d %d
", d1.x, d1.y); //3 2
}

為了避免這樣的錯誤,鼓勵大家做到以下兩點:
· 移動/複製建構函式初始化列表首先處理基類。
· 移動/複製賦值函式首先呼叫基類賦值函式。
正確的寫法如下。
 
Derived::Derived(const Derived& d): Base(d), y(d.y) {}

Derived& Derived::operator=(const Derived& d) {
    Base::operator=(d);
    y = d.y;
}


移動與複製前先判斷是否為自身
在前面介紹的例子中,在移動與複製前都先判斷了引數和this是否指向了同一個物件。因為,如果不判斷是否為自身,可能會導致資源洩露、程式崩潰。
以下面這段程式碼為例。在做複製時先將自身的pb刪除,然後根據目標pb進行深複製,再返回自身引用。在這種情況下,如果自身的pb和目標pb指向同一個值,那麼在刪除自身的同時,目標pb也被刪除了。在移動時同理。
 
class Widget {
    ...
private:
    Bitmap* pb;
};

Widget& Widget::operator=(const Widget& rhs) {
    delete pb;
    pb = new Bitmap(*rhs.pb);
    return *this;
}

正確的做法如下面這段程式碼所示。先判斷再進行移動或複製。
Widget& Widget::operator=(const Widget& rhs) {
    if (this != &rhs) {
        delete pb;
        pb = new Bitmap(*rhs.pb);
    }
    return *this;
}
但這樣做還是存在問題。假設在進行Bitmap的深複製的時候丟擲了異常。被複制的pb物件已經被刪除了,那麼說明這兩個函式不是異常安全的。這種情況該如何處理呢?
 
實現異常安全的複製賦值函式。
異常安全是指當異常丟擲後:
· 不洩露資源
· 不破壞已有資料
對於前面介紹的場景中,如果呼叫複製的過程中拋了異常,對建構函式來說,需要把已經構造的成員析構掉。對於賦值函式來說,由於拋異常是在複製操作未完成的時候出現的,要使得已經被賦值的物件不能被修改。如何實現呢?有一種做法是結合移動函式和複製建構函式。對移動函式來說,沒有產生新資源,一般不會出現異常。而前面介紹過,複製建構函式比較容易實現異常安全。具體做法如下面這段程式碼所示。
 
class Widget {
    ...
private:
    Bitmap* pb;
};

Widget::Widget(Widget&& rhs) {
    pb = rhs.pb;
    rhs.pb = nullptr;
}

Widget& Widget::operator=(Widget&& rhs) {
    pb = rhs.pb;
    rhs.pb = nullptr;
}

Widget::Widget(const Widget& rhs) {
    pb = new Bitmap(*rhs.pb);
}

Widget& Widget::operator=(const Widget& rhs) {
    Widget tmp(rhs);
    *this = std::move(tmp);
    return *this;
}

先通過複製建構函式構造tmp,然後將tmp移動賦值給this。假如在呼叫複製構造時丟擲異常,由於還未呼叫賦值,物件就不會被修改,而在移動過程中也不會出現異常。那麼這個複製賦值函式就是異常安全的。而在C++98中,沒有move。但可以藉助swap函式實現。
總結,實現異常安全的複製賦值函式的方法:
· C++98中使用複製構造+swap
· C++11中使用複製構造+移動賦值函式
移動函式不能拋異常
需要強調的是,在剛才的例子中,對移動函式存在如下假設:
· 廉價
· 不分配資源
· 不拋異常
在具體實現中,我們應儘量保證這種假設,但如果出現例外情況,呼叫了可能出現異常的函式。建議不要進行處理,而是將移動函式宣告為noexcept,讓程式奔潰,如下面這段程式碼。
Widget::Widget(Widget&& rhs) noexcept {
    pb = rhs.pb;
    rhs.pb = nullptr;
}

Widget& Widget::operator=(Widget&& rhs) noexcept {
    pb = rhs.pb;
    rhs.pb = nullptr;
}
理由是絕大多數場景,移動函式拋異常都是遇到很嚴重的問題了,此時再讓程式繼續跑下去也沒什麼意義了,不如早點crash,還能早點恢復。這也是分散式服務的一個理念,任其崩潰。
本文由雲棲志願小組馬JY整理,編輯百見


相關文章