2018年5月7日

[C++] move semantic 的誤解

今天部門的讀書會上討論到 Effective Modern C++ 中介紹關於 C++11 開始引入的各項新特色與其相關問題,其中有個例子介紹到了 std::move 跟 std::forward 的使用時機。主講者也很有心的把其中某個例子自己嘗試寫了出來並加以變化來驗證,結果沒想到剛好可以變成一個說明何時不該使用 move semantic 的絕佳範例 XD 而且這也剛好說明了 C++11 引入了各項特色其實還...滿難懂的,也難怪會後會有人說其實乾脆不要用就沒這些煩惱。

何謂 rvalue reference 跟 move semantic 就不多談了,google 一下就會找到許多講解與範例,這邊直接來談今天遇到的狀況,先來看看 Effective Modern C++ Item 25 中的一個範例:
class Widget {
public:
    template<typename T>
    void setName(T&& newName) // universal reference
    { name = std::move(newName); }    // compiles, but is
    …                                // bad, bad, bad!
private:
    std::string name;
    std::shared_ptr<SomeDataStructure> p;
};

std::string getWidgetName(); // factory function
Widget w;
auto n = getWidgetName(); // n is local variable
w.setName(n); // moves n into w!
… // n's value now unknown

簡單來說這個 Item 講的是如果 parameter 是用 universal reference (正式的稱呼好像是 forwarding reference,Effective Modern C++ 撰寫時貌似還沒有統一的名稱) 那就要使用 std::forward 而不是 std::move,這樣才能正確反應 argument 是 lvalue 還是 rvalue。

介紹這個 item 的同事呢實作了這個範例,不過他做了一點修改,根據他的說法 (他只有說他做了甚麼修改,沒展示真正的程式碼),他把 std::string 改成 int,所以個人猜測應該是長得像這樣:
class Widget {
public:
    template<typename T>
    void setName(T&& newName) // universal reference
    { name = std::move(newName); } // compiles, but is
    … // bad, bad, bad!
private:
    int name;
    std::shared_ptr<SomeDataStructure> p;
};

int getWidgetName(); // factory function
Widget w;
auto n = getWidgetName(); // n is local variable
w.setName(n); // moves n into w!
… // n's value now unknown

原本的範例要說明的是錯誤的使用 move semantic 會讓程式出現非預期行為,在原本的範例的寫法會讓 n 的從有值變成未知,違反我們看到程式時的認知:n 的值應該不變。結果同事把型態改成 int 後發現 n 還是有效的,就誤以為在原本範例中的 n 也會是有效的 XD

其實兩個例子的結果都沒問題,有這種誤解的癥結點很簡單:因為 move semantic 對基本型別沒有意義。至於為什麼沒有意義應該要回到為什麼我們會需要 move semantic。

move semantic 的用意在於使用自定義型態 (ex: struct 或 class) 的 object 時,在操作過程中可能會產生一些暫時物件。舉個例子:假設 a, b, c 是某個 class Matrix 的物件,今天我們要計算 a + b + c 時,計算方法可以看成以下這樣:
Matrix tmp1 = a + b;   
Matrix tmp2 = tmp1 + c;
沒錯﹐這個時候我們會產生 2 個暫時物件 tmp1 & tmp2 (其實 rvalue 是沒有名字的,只是為了方便理解才寫出來),在 C++98 前我們沒有任何方法可以存取到 tmp1 & tmp2 這 2 個暫時物件,因此我們沒辦法對他們做優化處理。但是,假如我們有辦法存取到又會有甚麼改變呢?這時候我們可以變成下面這樣:
Matrix tmp1 = a + b;
tmp1 += c;          
沒錯,我們完全不必產生 tmp2 這個暫時物件,只要重複利用 tmp1 這樣就省下了一次不必要的複製,理所當然地也能節省執行時間。這是引入 move semantic 的一項重大誘因,特別是 Matrix 如果是個非常龐大的物件 (比方說是個元素數量超級大的矩陣)。

講到這邊應該就可以隱約發現為什麼基礎型別不需要 move semantic 了,原因很簡單:因為 compiler 就會幫你做了啊!! 還會幫你最佳化 CPU register 的使用情形,比你自己手動最佳化還更好。

另一個基礎型別不需要 move semantic 的原因在於你根本沒辦法從中獲得任何好處,以下面的例子來看:

class MyString{
public:
    MyString(MyString&& rhs) : buf(nullptr)
    { swap(buf, rhs.buf); }
private:
    char* buf;
};

MyString 內部是個 char* 指向動態產生的 char array,想當然爾,MyString 的 move constructor 只要交換 this 跟 rhs 的 buf 位址就好,根本不用複製實際的 char array。但是如果今天 buf 的型態是 char[] 呢?對,你沒辦法像上面這個例子藉由交換指標節省複製內容的時間。同樣的理由也能套用到基礎型別。

最後題外話,後來發現要判斷某個自訂義行別能不能從 move semantic 獲得執行效率上的改善能從一個簡單的方法判斷:
實作出該型別的 swap function
如果在實作 swap function 時發現有辦法極有效率的交換兩個物件的值,那就有使用 move semantic 的意義。比方說上面的 MyString 這個 class 的 2 個物件 a 跟 b 要交換所儲存的字串,最簡單的方法就是直接交換各自 buf 這個指標所指向的位置,而不是把字串內容互換。其他的使用情境嘛...遵照類似的想法去找找看有沒有辦法避免不必要的複製就對了,找不到的話就表示沒必要,可以不用浪費時間去使用 move semantic。

沒有留言:

張貼留言