C++右值引用淺析
直想試著把自己理解和學習到的右值引用相關的技術細節整理並分享出來,希望能夠對感興趣的朋友提供幫助。
右值引用是C++11標准中新增的一個特性。右值引用允許程序員可以忽略邏輯上不需要的拷貝;而且還可以用來支持實現完美轉發的函數。它們都是實現更高效、更健壯的庫。
move語義
先不展開具體右值引用定義。先說說move語義。右值引用是用來支持move語義的。move語義是指將一個同類型的對象A中的資源(可能是在堆上分配,也可能是一個文件句柄或者其他系統資源)搬移到另一個同類型的對象B中,解除對象A對該資源的所有權。這樣可以減少不必要的臨時對象的構造、拷貝以及析構等動作。比如我們經常使用的std::vector<T>,當兩個相同的std::vector類型賦值時,一般的步驟如下:
內部的賦值構造函數一般是先分配指定大小的內存,
從源std::vector中拷貝到新申請的內存,
之後再把原有的對象實例析構掉,
最後接管新申請的數據。
這就是我們C++11之前使用的拷貝語義,也就是常說的深拷貝。move語義與拷貝語義相對,類似於淺拷貝,但是資源的所有權發生了轉移。move語義的實現可以減少拷貝動作,大幅提高程序的性能。
而為了實現move語義的構造,就需要對應的語法來支持。原有的拷貝構造函數等不能夠滿足該需求。最典型的例子就是C++11廢棄的std::auto_ptr,其構造函數會產生不明確的擁有權關系,很容易滋生BUG。這也是很多人不喜歡std::auto_ptr的原因。C++11為此增加了相應的構造函數。
復制代碼
class Foo {
public:
Foo(Foo&& f) {}
Foo& operator=(Foo&& f) {
return *this;
}
};
復制代碼
這裡可以明顯看到兩個函數中的參數類型是Foo&&。這就是右值引用的基本語法。這樣做的目的是通過函數重載實現不同的功能處理。
強制move語義
C++11規定即可以在右值上使用move語義,也可以在左值上使用move語義。也就是說,可以把一個左值轉為右值引用,然後使用move語義。比如在C++的經典函數swap中:
復制代碼
template<class T>
void swap(T& a, T& b)
{
T tmp(a);
a = b;
b = tmp;
}
X a, b;
swap(a, b);
復制代碼
上面代碼中沒有右值,但是tmp變量只作用在本函數作用域中,只是用來承擔數據的轉移動作。C++11制定的上述規則在這裡反而可以得到非常好的適用。C++11為了達到這個規則,實現了std::move函數,這個函數的就是把傳入的參數轉換為一個右值引用並返回。也就是說在C++11下,swap的實現如下:
復制代碼
template<class T>
void swap(T& a, T& b)
{
T tmp(std::move(a));
a = std::move(b);
b = std::move(tmp);
}
X a, b;
swap(a, b);
復制代碼
我們在實際使用中,也可以盡量的多使用std::move。只要求我們自定義的類型實現轉移構造函數。
右值引用
為了說清楚右值引用什麼,就不得不說左值和右值。簡單的說左值是一個指向某內存空間的表達式,並且我們可以用&操作符獲得該內存空間的地址。右值就是非左值的表達式。可以閱讀這篇《Lvalues and Rvalues》進行深入理解。
右值引用非常類似於C++的普通引用,也是一個復合類型。為了方便區分,普通引用就是左值引用。一個左值引用就是在類型後面加&操作符。而右值引用就是在類型後加&&操作符,就像上面的轉移構造函數的參數一樣。
右值引用的行為類似於左值引用,但是右值引用只能綁定臨時對象,不能綁定一個左值引用。右值引用的出現還影響了函數重載決議。左值會優先適配左值引用參數的函數,右值會優先適配右值引用參數的函數:
復制代碼
void foo(X& x); // lvalue reference overload
void foo(X&& x); // rvalue reference overload
X x;
X foobar();
foo(x); // argument is lvalue: calls foo(X&)
foo(foobar()); // argument is rvalue: calls foo(X&&)
復制代碼
理論上,你可以用這種方式重載任何函數,但是絕大多數情況下這樣的重載只出現在拷貝構造函數和賦值運算符中,也就是實現move語義。
如果你實現了void foo(X&);,但是沒有實現void foo(X&&);,那麼和以前一樣foo的參數只能是左值。如果實現了void foo(X const &);,但是沒有實現void foo(X&&);,仍和以前一樣,foo的參數既可以是左值也可以是右值。唯一能夠區分左值和右值的辦法就是實現void foo(X&&);。最後,如果只實現了實現void foo(X&&);,但卻沒有實現void foo(X&);和void foo(X const &);,那麼foo的參數將只能是右值。
右值引用是右值嗎?
void foo(X&& x)
{
X anotherX = x;
// ...
}
在上面這個函數foo內,X的哪個構造函數會被調用?是拷貝構造還是轉移構造?按照我們之前說的,這是個右值引用,應該是調用的X(X&&);函數。但是實際上,這裡調用的是X(const X&);這裡就是讓人迷惑的地方:右值引用類型既可以被當做左值也可以被當做右值,判斷的標准是該右值引用是否有名字。有名字就是左值,否則就是右值。如果要做到把帶有名字的右值引用變為右值,就需要借助std::move函數。
void foo(X&& x)
{
X anotherX = std::move(x);
// ...
}
在實現自己的轉移構造函數時,一些人沒有理解這一點,導致在自己的轉移構造函數內部的實現中實際是執行了拷貝構造函數。
move語義與返回值優化
了解了move語義和強制move以及右值引用的一些概念後,有些朋友在實現一些函數時,會在返回的地方進行強制move。認為這樣可以減少一次拷貝。比如:
復制代碼
X foo()
{
X x;
// perhaps do something to x
return std::move(x); // making it worse!
}
復制代碼
實際上這種是不需要的。因為編譯器會做返回值優化(Return Value Optimization)。也就是說編譯器能夠感知到x變量不再需要了,它需要轉移到函數外部使用。
完美轉發
右值引用除了用來實現move語義之外,還就是為了解決完美轉發的問題。我們有的時候會寫工廠函數,比如如下代碼:
template<typename T, typename Arg>
shared_ptr<T> factory(Arg arg)
{
return shared_ptr<T>(new T(arg));
}
這個實現非常簡單,就是把參數arg傳給類T進行構造。但是這裡引入了額外的通過值的函數調用,不使用於那些以引用為參數的構造函數。
那麼為了解決這個問題,就有人想到用引用,比如:
template<typename T, typename Arg>
shared_ptr<T> factory(Arg& arg)
{
return shared_ptr<T>(new T(arg));
}
但是這裡又有問題,不能接收右值作為參數。
factory<X>(hoo()); // error if hoo returns by value
factory<X>(41); // error
對應的解決辦法是繼續引入const引用。如果有多個參數的情況下,這個函數的參數列表就變的比較惡心了。同時還有個問題就是不能實現move語義。
而右值引用可以解決這個問題,可以不用通過重載函數來實現真正的完美轉發。但是它需要配合兩個右值引用的規則:
引用疊加規則
A& & => A&
A& && => A&
A&& & => A&
A&& && => A&&
模板參數推導規則
template<typename T>
void foo(T&&);
當函數foo的實參是一個A類型的左值時,T的類型是A&。再根據引用疊加規則判斷,最後參數的實際類型是A&。
當foo的實參是一個A類型的右值時,T的類型是A。根據引用疊加規則可以判斷,最後的類型是A&&。
有了上面這些規則,我們可以用右值引用來解決前面的完美轉發問題。下面是解決的辦法:
template<typename T, typename Arg>
shared_ptr<T> factory(Arg&& arg)
{
return shared_ptr<T>(new T(std::forward<Arg>(arg)));
}
而std::forward的實現如下:
template<class S>
S&& forward(typename remove_reference<S>::type& a) noexcept
{
return static_cast<S&&>(a);
}