[C++] 右值引用:移動語義與完美轉發

C++11 引入的新特性中,除了併發內存模型和相關設施,這些高帥富之外,最引人入勝且接地氣的特性就要屬『右值引用』了(rvalue reference)。加入右值引用的動機在於效率:減少不必要的資源拷貝。考慮下面的程序:

std::vector<string> v;
v.push_back("string");

  向 vector 中添加一個元素,這個動作需要先後調用 string::string(const char*), string::string(const string&), string::~string() 三個函數,涉及兩次內存拷貝:第一次使用字面常量 “string” 構造出一個臨時對象,第二次使用該臨時對象構造出 vector 中的一個新元素,『最後臨時對象會發生析構』。

移動語義

  上面程序操作的問題癥結在於,臨時對象的構造和析構帶來了不必要的資源拷貝。如果有一種機制,可以在語法層面識別出臨時對象,在使用臨時對象構造新對象(拷貝構造)的時候,將臨時對象所持有的資源『轉移』到新的對象中,就能消除這種不必要的拷貝。這種語法機制就是『右值引用』,相對地,傳統的引用被稱爲『左值引用』。左值引用使用 ‘&’ 標識(比如 string&),右值引用使用 ‘&&’ 標識(比如 string&&)。順帶提一下什麼是左值(lvalue)什麼是(rvalue):可以取地址的具名對象是左值;無法取值的對象是右值,包括匿名的臨時對象和所有字面值(literal value)。
  有了右值的語法支持,爲了實現移動語義,需要相應類以右值爲參數重載傳統的拷貝構造函數和賦值操作符,畢竟哪些資源可以移動、哪些只能拷貝只有類的實現者才知道。對於移動語義的拷貝『構造』,一般流程是將源對象的資源綁定到目的對象,然後解除源對象對資源的綁定;對於賦值操作,一般流程是,首先銷燬目的對象所持有的資源,然後改變資源的綁定。另外,當然,與傳統的構造和賦值相似,還要考慮到構造的異常安全和自賦值情況。作爲演示:

class String {
public:
    String(const String &rhs) { ... }
    String(String &&rhs) {
        s_ = rhs.s_;
        rhs.s_ = NULL;
    }
    String& operator=(const String &rhs) { ... }
    String& operator=(String &&rhs) {
        if (this != &rhs) {
            delete [] s_;
            s_ = rhs.s_;
            rhs.s_ = NULL;
        }
        return *this;
    }
private:
    char *s_;
};

  值得注意的是,一個綁定到右值的右值引用是『左值』,因爲它是有名字的。考慮:

class B {
public:
    B(const B&) {}
    B(B&&) {}
};
class D : public B {
    D(const D &rhs) : B(rhs) {}
    D(D &&rhs) : B(rhs) {}
};
D getD();
D d(getD());

  上面程序中,B::B(B&&) 不會被調用。爲此,C++11 中引入 std::move(T&& t) 模板函數,它 t 轉換爲右值:

class D : public B {
    D(D &&rhs) : B(std::move(rhs)) {}
};

  std::move 的一種可能的實現:

template <typename T>
typename remove_reference<T>::type&&
move(T &&t) {
    return static_cast<remove_reference<T>::type&&>(t);
}
綁定規則

  引入右值引用後,『引用』到『值』的綁定規則也得到擴充:

  1. 左值引用可以綁定到左值: int x; int &xr = x;
  2. 非常量左值引用不可以綁定到右值: int &r = 0;
  3. 常量左值引用可以綁定到左值和右值:int x; const int &cxr = x; const int &cr = 0;
  4. 右值引用可以綁定到右值:int &&r = 0;
  5. 右值引用不可以綁定到左值:int x; int &&xr = x;
  6. 常量右值引用沒有現實意義(畢竟右值引用的初衷在於移動語義,而移動就意味着『修改』)。

  其中,第五條規則『不適用於』函數模板的形參,例如下面的函數可以接受任意類型的參數,既可以是右值也可以是左值,還可以是常量或者非常量:

template <typename T>
void foo(T &&t);
int x;
const int xx;
foo(x); //~ OK
foo(xx); //~ OK
foo(10); //~ OK

  T&& 形參可以接受左值,是 C++11 針對這種特殊情況做的規則修訂,目的是爲了實現『完美轉發』(perfect forwarding)。

完美轉發

  C++11 之前,一直存在着參數『轉發』的問題,即不能方便地實現完美轉發。轉發的目的在於傳遞『引用參數』的附加屬性,比如 cv 屬性(const/volatile)和左右值屬性。爲了刻畫這個問題,我們以左右值屬性的傳遞爲例(cv 屬性也存在相似的問題),參考下面的類定義:

class X
{
public:
    X(const std::string &s, const std::vector<int> &v) : s_(s), v_(v) {}
private:
    std::string s_;
    std::vector<int> v_;
};

  爲了支持移動語義,就需要重載構造函數,由於構造函數有兩個參數,還需要考慮到右值引用和左值引用的組合形式:

class X
{
public:
    X(const std::string &s, const std::vector<int> &v) : s_(s), v_(v) {}
    X(std::string &&s, const std::vector<int> &v) : s_(std::move(s)), v_(v) {}
    X(const std::string &s, std::vector<int> &&v) : s_(s), v_(std::move(v)) {}
    X(std::string &&s, std::vector<int> &&v) : s_(std::move(s)), v_(std::move(v)) {}
private:
    std::string s_;
    std::vector<int> v_;
};

  如果構造函數有 n 個參數,就需要 2^n 個重載!
  C++11 中,通過基於右值引用的函數模板解決了這個問題,本質上是通過對實參類型的推演,按照實際情況,由編譯器完成自動的『重載』。

class X
{
public:
    template <typename T1, typename T2>
    X(T1 &&s, T2 &&v) : s_(std::forward<T1>(s)), v_(std::forward<T2>(v)) {}
private:
    std::string s_;
    std::vector<int> v_;
};

  在介紹這種轉發之前,先需要知道右值引用形參的函數模板的實參推演規則,即引用摺疊(reference collapsing)。BTW. C++11 之前,不允許綁定到引用的引用類型(reference to reference)。
  設 T 爲模板的類型參數,A 爲實參的基本類型,則有:

T 形參 摺疊後的T 摺疊後實參類型
A& T& A A&
A& T&& A& A&
A&& T& A& A&
A&& T&& A A&&

  可以看到,當函數的形參聲明爲 T&& 時,當且僅當實參爲右值或者右值引用,摺疊後的的實參類型纔是右值引用,否則爲左值引用。通過這個摺疊規則,就可以實現左右值引用屬性的轉發。std::forward 就可以簡單地實現爲:

template <typename T>
T&& forward(T &&t)
{
    return static_cast<T&&>(t);
}

總結

  C++11 中引入很多特性,大多讓人眼前一亮:靠,這就是我一直想要的啊!很多特性瀏覽一遍就清晰了,但右值引用相關的,尤其是完美轉發相對來說比較繞,難以理順。右值引用有兩個應用,最基本的動機是移動語義,同時又給完美轉發的支持帶來契機。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章