移動語義
有一些類的資源是__不可共享__的,這種類型的對象可以被移動但不能被拷貝,如:IO
或 unique_ptr
庫容器、string
和 shared_ptr
支持拷貝和移動,IO
和 unique_ptr
則只能移動不能拷貝。。
右值引用
右值引用是必須綁定到右值的引用,右值引用使用 &&
符號,相較于左值引用的&
。右值引用有一個特性就是其只能綁定到即將銷毀的對象上,因而,可以自由的移動右值引用對象中的資源。
左值表示對象的身份,而右值表示對象的值。不能將左值引用(lvalue reference)
綁定到需要轉型的值、字面量或者返回右值的表達式上。右值引用則剛好相反:可以將右值引用綁定到以上的值,但不能直接將右值引用綁定到左值。如:
int i = 42;
int &r = i;
int &&rr = i; //錯誤:不能將右值引用綁定到左值上
int &r2 = i * 42; //錯誤:不能將左值引用綁定到右值上
const int &r3 = i * 42; //可以將 const 左值引用綁定到任何類型的值上(const/非 const 的左/右值)
int &&rr2 = i * 42; //將右值引用綁定到右值上
返回左值引用的函數和賦值、下標操作、解引用和前綴自增/自減操作符都是返回左值的表達式,可將左值引用綁定到這些表達式的結果中。
返回非引用類型的函數與算術、關系、位操作和后綴自增/自減的操作符都是返回右值的表達式,可將右值引用和 const
左值引用綁定到這種表達式上。
變量是左值
一個變量就是一個表達式,其只有一個操作數而沒有操作符。變量表達式是左值。因而,不能將右值引用綁定到一個定義為右值引用的變量上。如:
int &&rr1 = 42;
int &&rr2 = rr1; //錯誤:rr1 是左值,因而不能這樣定義
一個變量就是一個左值;不能直接將右值引用綁定到一個變量上,即使這個變量被定義為右值引用類型也不可以。
但是如果臨時對象通過一個接受右值的函數傳遞給另一個函數時,就會變成左值,因為這個臨時對象在傳遞過程中,變成了命名對象。
move庫函數
template< class T > (C++11 起)
typename std::remove_reference<T>::type&& move( T&& t ) noexcept; (C++14 前)
template< class T > (C++14 起)
constexpr typename std::remove_reference<T>::type&& move( T&& t ) noexcept;
可以顯式將左值強轉為對應的右值引用類型,也可以通過調用 move
庫函數來獲取綁定到左值的右值引用,其被定義在 utility
頭文件中。如:
int &&rr3 = std::move(rr1);
調用 move
告知編譯器,以右值方式對象一個左值。特別需要了解的是調用 move
將承諾:不會再次使用 rr1
,除非是賦值或者析構。當調用了 move
之后,不能對這個對象做任何值上的假設。可以析構或賦值給移動后的對象,但在此之前不能使用其值。
使用 move
的代碼應該使用 std::move
,而不是 move
,這樣做可以避免潛在的名字沖突。
移動構造函數和移動賦值
為了讓我們自己的類可以執行移動操作,需要定義移動構造函數和移動賦值操作符。這些成員類似于對應的拷貝賦值操作,但是他們將從給定對象中偷取資源而不是復制。
- 參數(右值)不可以是常量,因為我們需要修改右值。
- 參數(右值)的資源鏈接和標記必須修改。否則,右值的析構函數就會釋放資源。轉移到新對象的資源也就無效了。
除了移動資源,移動構造函數需要保證移動后的對象的狀態是析構無害的。特別是,一旦資源被移動后,原始對象就不再指向移動了的資源,這些所有權被轉移給了新創建的對象。如:
StrVec::StrVec(StrVec &&s) noexcept :elements(s.elements), first_free(s.first_free), cap(s.cap)
{s.elements = s.first_free = s.cap = nullptr;
}
與拷貝構造函數不同,移動構造函數并不會分配新資源;其將攫取參數中的內存,在此之后,構造函數體將參數中的指針都設置為 nullptr
,當一個對象被移動后,這個對象依然存在。最后移動后的對象將被析構,意味著析構函數將在此對象上運行。析構函數將釋放其所擁有的資源,如果沒有將指針設置為 nullptr
的,就會將移動了的資源給釋放掉。
移動操作,庫容器和異常
移動操作通常不必自己分配資源,所以移動操作通常不拋出任何異常。當我們寫移動操作時,由于其不會拋出異常,我們應當告知編譯器這個事實。除非編譯器知道這個事實,它將必須做額外的工作來滿足移動構造操作將拋出異常。
通過在函數參數列表后加上 noexcept
,在構造函數時則,noexcept
出現在參數列表后到冒號之間,來告知編譯器一個函數不會拋出異常。如:
class StrVec {
public:StrVec(StrVec &&) noexcept;
};
StrVec::StrVec(StrVec &&s) noexcept : { ... }
必須同時在類體內的聲明處和定義處同時指定 noexcept
。
移動構造函數和移動賦值操作符,如果都不允許拋出異常,那么就應該被指定為 noexcept
。
告知移動操作不拋出異常是由于兩個不相關的事實:第一,盡管移動操作通常不拋出異常,它們可以這樣做。第二,有些庫容器在元素是否會在構建時拋出異常有不同的表現,如:vector
只有在知道元素類型的移動構造函數不會拋出異常才使用移動構造函數,否則將必須使用拷貝構造函數;
移動賦值操作符
StrVec& StrVec::operator=(StrVec &&rhs) noexcept
{if (this == &rhs)return *this;free();elements = rhs.elements;first_free = rhs.first_free;cap = rhs.cap;rhs.elements = rhs.first_free = rhs.cap = nullptr;return *this;
}
移動賦值操作符不拋出異常應當用 noexcept
修飾,與拷貝賦值操作符一樣需要警惕自賦值的可能性。移動賦值操作符同時聚合了析構函數和移動構造函數的工作:其將釋放左操作數的內存,并且占有右操作數的內存,并將右操作數的指針設為 nullptr
。
移動后的對象必須是可以析構的
移動對象并不會析構那個對象,有時在移動操作完成后,被移動的對象將被銷毀。因而,當我們寫移動操作時,必須保證移動后的對象的狀態是可以析構的。StrVec
通過將其指針設置為 nullptr
來滿足此要求。
除了讓對象處于可析構狀態,移動操作必須保證對象處于有效狀態。通常來說,有效狀態就是可以安全的賦予新值或者使用在不依賴當前值的方式下。另一方面,移動操作對于遺留在移動后的對象中的值沒有什么特別要求,所以,程序不應該依賴于移動后對象的值。
例如,從庫 string
和容器對象中移動資源后,移動后對象的狀態將保持有效。可以在移動后對象上調用 empty
或 size
函數,然而,并不保證得到的結果是空的。可以期望一個移動后對象是空的,但是這并不保證。
以上 StrVec
的移動操作將移動后對象留在一個與默認初始化一樣的狀態。因而,這個 StrVec
的所有操作將與默認初始化的 StrVec
的操作完全一樣。其它類,有著更加復雜的內部結構,也許會表現的不一致。
在移動后操作,移動后對象必須保證在一個有效狀態,并且可以析構,但是用戶不能對其值做任何假設。
*合成移動操作
編譯器會為對象合成移動構造函數和移動賦值操作符。然而,在什么情況下合成移動操作與合成拷貝操作是十分不同的。
與拷貝操作不同的,對于某些類來說,編譯器根本不合成任何移動操作。特別是,如果一個類定義自己的拷貝構造函數、拷貝賦值操作符或析構函數,移動構造函數和移動賦值操作符是不會合成的。作為結果,有些類是沒有移動構造函數或移動賦值操作符。同樣,當一個類沒有移動操作時,對應的拷貝操作將通過函數匹配被用于替代移動操作。
編譯器只會在類沒有定義任何拷貝控制成員并且所有的非 static 數據成員都是可移動的情況下才會合成移動構造函數和移動賦值操作符。編譯器可以移動內置類型的成員,亦可以移動具有對應移動操作的類類型成員。
移動操作不會隱式被定義為刪除的,而是根本不定義,當沒有移動構造函數時,重載將選擇拷貝構造函數。當用 =default 要求編譯器生成時,如果編譯器無法移動所有成員,將會生成一個刪除的移動操作。被刪除的函數不是說不能被用于函數重載,而是說當其是重載解析時最合適的候選函數時,將是編譯錯誤。
- 與拷貝構造函數不同,當類有一個成員定義了自己的拷貝構造函數,但是沒有定義移動構造函數時使用拷貝構造函數。當成員沒有定義自己的拷貝操作但是編譯器無法為其合成移動構造函數時,其移動構造函數被定義為被刪除的。對于移動賦值操作符是一樣的;
- 如果類有一個成員其移動構造函數或移動賦值操作符是被刪除的或不可訪問的,其移動構造函數或移動賦值操作符被定義為被刪除的;
- 與拷貝構造函數一樣,如果其析構函數是被刪除的或不可訪問的,移動構造函數被定義為被刪除的;
- 與拷貝賦值操作符一樣,如果其有一個
const
或引用成員,移動賦值操作被定義為刪除的;
如果一個類定義自己的移動構造函數或移動賦值操作符,那么合成的拷貝構造函數和拷貝賦值操作符都將被定義為被刪除的。
右值移動,左值拷貝
當一個類既有移動構造函數又有拷貝構造函數,編譯器使用常規的函數匹配來決定使用哪個構造函數。拷貝構造函數通常使用 const StrVec
引用類型作為參數,因而,可以匹配可以轉為 StrVec
類型的對象參數。而移動構造函數則使用 StrVec &&
作為參數,因而,只能使用非 const
的右值。如果調用拷貝形式的,需要將參數轉為 const
的,而移動形式的卻是精確匹配,因而,右值將調用移動形式的。
右值在無法被移動時進行拷貝
如果一個類有拷貝構造函數,但是沒有定義移動構造函數,在這種情況下編譯不會合成移動構造函數,意味著類只有拷貝構造函數而沒有移動構造函數。如果一個類沒有移動構造函數,函數匹配保證即便是嘗試使用 move
來移動對象時,它們依然會被拷貝。
class Foo {
public:Foo() = default;Foo(const Foo&); //拷貝構造函數
};
Foo x;
Foo y(x); //拷貝構造函數;x 是左值
Foo z(std::move(x)); //拷貝構造函數;因為沒有移動構造函數
調用 move(x)
時返回 Foo&&
,Foo
的拷貝構造函數是可行的,因為可以將 Foo&&
轉為 const Foo&
,因而,使用拷貝構造函數來初始化 z
。
使用拷貝構造函數來替換移動構造函數通常是安全的,對于賦值操作符來說是一樣的。拷貝構造符合移動構造函數的先決條件:它將拷貝給定的對象,并且不會改變其狀態,這樣原始對象將保持在有效狀態內。
拷貝和交換賦值操作與移動
class HasPtr {
public:HasPtr(HasPtr &&p) noexcept : ps(p.ps), i(p.i) {p.ps = 0;}HasPtr& operator=(HasPtr rhs){swap(*this, rhs);return *this;}
};
賦值操作符的參數是非引用類型的,所以參數是拷貝初始化的。根據參數的類型,拷貝初始化可能使用拷貝構造函數也可能使用移動構造函數。左值將被拷貝,右值將被移動。因而,這個移動操作符既是拷貝賦值操作符又是移動賦值操作符。如:
hp = hp2;
hp = std::move(hp2);
所有五個拷貝控制成員應該被當做一個整體:通常,如果一個類定義了其中任何一個操作,它通常需要定義所有成員。有些類必須定義拷貝構造函數,拷貝賦值操作符和析構函數才能正確工作。這種類通常有一個資源是拷貝成員必須拷貝的,通常拷貝資源需要做很多額外的工作,定義移動構造函數和移動賦值操作符可以避免在不需要拷貝的情況的額外工作。
移動迭代器
在新標準中,定義了移動迭代器(move iterator)
適配器。移動迭代器通過改變迭代器的解引用操作來適配給定的迭代器。通常,迭代器解引用返回元素的左值引用,與其它迭代器不同,解引用移動迭代器返回右值引用。調用函數 make_move_iterator
將常規迭代器變成移動迭代器,移動迭代器的操作與原始迭代器操作基本一樣,因而可以將移動迭代器傳給 uninitialized_copy
函數。如:
uninitialized_copy(make_move_iterator(begin()), make_move_iterator(end()));
值得一提的是標準庫沒有說哪些算法可以使用移動迭代器,哪些不可以。因為移動對象會破壞原始對象,所以將移動迭代器傳給那些不會在移動后訪問其值的算法才合適。
慎用移動操作:由于移動后的對象處于中間狀態,在對象上調用 std::move
是很危險的。當調用 move
后,必須保證沒有別的用戶使用移動后對象。
謹慎克制的在類內使用 move
可以提供重大的性能提升,在用戶代碼中使用 move
則更可能導致難以定位的 bug
,相比較得到的性能提升是不值得的。
在類實現代碼外使用 std::move
,必須是在確實需要移動操作,并且保證移動是安全的。
右值引用和成員函數
除了構造函數和賦值操作符外提供拷貝和移動版本亦會受益。這種可以移動的成員函數中一個使用 const
左值引用,另一個使用非 const
右值引用。如:
void push_back(const X&); //拷貝:綁定到任何類型的 X
void push_back(X&&); //移動:綁定到可修改的右值 X
可以傳遞任何可以轉換為類型 X 的對象給拷貝版本,這個版本從參數中拷貝數據。只能將非 const
右值傳遞給移動版本。此版本比拷貝版本更好的匹配非 const
右值(精確匹配),因而,在函數匹配中將是更優的,并且可以自由的從參數中移動資源。
通常上面這種重載方式不會使用 const X&&
和 X&
類型的參數,原因在于移動數據要求對象是非 const
的,而拷貝數據則應該是 const
的。
以拷貝或移動的方式對函數進行重載,常用的做法是一個版本使用 const T&
為參數,另外一個版本使用 T&&
為參數。
右值與左值引用的成員函數
有些成員函數是只允許左值調用的,右值是不能調用的,如:在新標準前可以給兩個字符串拼接的結果賦值:s1 + s2 = "wow!";
,在新標準中可以強制要求賦值操作符的左操作數是左值,通過在參數列表后放置引用修飾符(reference qualifier)
可以指示 this
的左值/右值特性。如:
class Foo {
public:Foo& operator=(const Foo&) &;
};
Foo& Foo::operator=(const Foo& rhs) &
{ return *this; }
引用修飾符可以是 &
或者 &&
用于表示 this
指向左值或右值。與 const
修飾符一樣,引用修飾符必須出現在非 static
成員函數的聲明和定義處。被 &
修飾的函數只能被左值調用,被 &&
修飾的函數只能被右值調用。
一個函數既可以有 const
也可以有引用修飾符,在這種情況下,引用修飾符在 const
修復符的后面。如:
class Foo {
public:Foo someMem() const &;
};
重載帶引用修飾符的成員函數
可以通過函數的引用修飾符進行重載,這與常規的函數重載是一樣的,&&
可以在可修改的右值上調用,const &
可以在任何類型的對象上調用。如:
class Foo {
public:Foo sorted() &&; //可以在可修改的右值上調用Foo sorted() const &; //可以在任何類型的 Foo 上調用
};
當定義具有相同名字和相同參數列表的成員函數時,必須同時提供引用修飾符或者都不提供引用修飾符,如果只在其中一些提供,而另外一些不提供就是編譯錯誤。如:
class Foo {
public:Foo sorted() &&;Foo sorted() const; //錯誤:必須提供引用修飾符//全不提供引用修飾符是合法的using Comp = bool(const int&, const int&);Foo sorted(Comp*);Foo sorted(Comp*) const;
};
精確傳遞 (Perfect Forwarding)
本文采用精確傳遞表達這個意思。Perfect Forwarding
也被翻譯成完美轉發,精準轉發等,說的都是一個意思。
精確傳遞適用于這樣的場景:需要將一組參數原封不動的傳遞給另一個函數。
“原封不動”不僅僅是參數的值不變,在 C++
中,除了參數值之外,還有一下兩組屬性:
左值/右值和 const/non-const
。 精確傳遞就是在參數傳遞過程中,所有這些屬性和參數值都不能改變。在泛型函數中,這樣的需求非常普遍。
下面舉例說明。函數 forward_value
是一個泛型函數,它將一個參數傳遞給另一個函數 process_value
。
forward_value
的定義為:
template <typename T> void forward_value(const T& val) {process_value(val);
}
template <typename T> void forward_value(T& val) {process_value(val);
}
函數 forward_value
為每一個參數必須重載兩種類型,T&
和 const T&
,否則,下面四種不同類型參數的調用中就不能同時滿足 :
int a = 0;const int &b = 1;forward_value(a); // int&forward_value(b); // const int&
forward_value(2); // int&
對于一個參數就要重載兩次,也就是函數重載的次數和參數的個數是一個正比的關系。這個函數的定義次數對于程序員來說,是非常低效的。我們看看右值引用如何幫助我們解決這個問題 :
template <typename T> void forward_value(T&& val) {process_value(val);
}
只需要定義一次,接受一個右值引用的參數,就能夠將所有的參數類型原封不動的傳遞給目標函數。四種不用類型參數的調用都能滿足,參數的左右值屬性和 const/non-cosnt
屬性完全傳遞給目標函數 process_value
。這個解決方案不是簡潔優雅嗎?
int a = 0;
const int &b = 1;
forward_value(a); // int&
forward_value(b); // const int&
forward_value(2); // int&&
C++11
中定義的 T&&
的推導規則為:
右值實參為右值引用,左值實參仍然為左值引用。
一句話,就是參數的屬性不變。這樣也就完美的實現了參數的完整傳遞。
右值引用,表面上看只是增加了一個引用符號,但它對 C++
軟件設計和類庫的設計有非常大的影響。它既能簡化代碼,又能提高程序運行效率。每一個 C++
軟件設計師和程序員都應該理解并能夠應用它。我們在設計類的時候如果有動態申請的資源,也應該設計轉移構造函數和轉移拷貝函數。在設計類庫時,還應該考慮 std::move
的使用場景并積極使用它。
關鍵術語
-
拷貝-交換
(copy and swap)
:一種書寫賦值操作符的技術,先將右操作數拷貝到參數中,然后調用swap
將其與左操作數進行交換; -
拷貝賦值操作符
(copy-assignment operator)
:拷貝賦值操作符與本類的const
引用對象作為參數,返回對象的引用。如果類不定義拷貝賦值操作符,編譯器將合成一個; -
拷貝構造函數
(copy constructor)
:將新對象初始化為本類的另一個對象的副本的構造函數。拷貝構造函數將在以非引用方式傳遞參數或從函數中返回時默認調用。如果類不定義的話,編譯器將合成一個; -
拷貝控制
(copy control)
:用于控制對象被拷貝、移動、賦值和銷毀時應當做什么的成員函數。如果類不定這些函數,編譯器將在合適的時候合成它們; -
拷貝初始化
(copy initialization)
:使用 = 形式的初始化,或者當傳遞參數、按值形式返回值,或者初始化數組或聚合類時,將進行拷貝初始化。拷貝初始化將根據初始值是左值還是右值,使用拷貝構造函數或者移動構造函數; -
被刪除的函數
(deleted function)
:不被使用的函數,通過=delete
來刪除函數。使用被刪除的函數是告知編譯器在進行函數匹配時,如果匹配到被刪除的函數就報編譯器錯誤; -
析構函數
(destructor)
:當對象離開作用域時調用的特殊成員函數來清理對象。編譯器自動銷毀每個數據成員,類成員通過調用其析構函數進行銷毀,內置類型或符合類型將不做任何析構操作,特別是指向動態對象的指針不會被自動delete
; -
逐個成員拷貝/賦值
(memberwise copy/assign)
:合成的拷貝/移動構造函數和拷貝/移動賦值操作符的運作方式。依次對所有的數據成員,拷貝/移動構造函數通過從參數中拷貝/移動對應的成員進行初始化;拷貝/移動賦值操作符則依次對右操作數的各個成員進行拷貝/移動賦值;內置類型的成員是直接進行初始化或賦值的。類類型成員則調用對應的拷貝/移動構造函數或拷貝/移動賦值操作符; -
move
函數(move function)
:用于將左值綁定到右值引用的庫函數。調用move
將隱式保證不會使用移動后的對象值,唯一的操作是析構或者賦予新值; -
移動賦值操作符
(move-assignment operator)
:參數是右值引用的賦值操作符版本。通常移動賦值操作符將其右操作數的數據移動到左操作數。在賦值后,必須保證可以安全的析構掉右操作數; -
移動構造函數
(move constructor)
:以右值引用為參數的構造函數。移動構造函數將參數中的數據移動到新創建的對象中。在移動后,必須保證可以安全地析構掉右操作數; -
移動迭代器
(move iterator)
:迭代器適配器,包裝一個迭代器,當其解引用時返回右值引用; -
右值引用
(rvalue reference)
:對即將被銷毀的對象的引用;