13.6 對象移動
新標準的一個最主要的特性是可以移動而非拷貝對象的能力。如我們在13.1.1節(第440頁)中所見,很多情況下都會發生對象拷貝。在其中某些情況下,對象拷貝后就立即被銷毀了。在這些情況下,移動而非拷貝對象會大幅度提升性能。
如我們已經看到的,我們的strvec類是這種不必要的拷貝的一個很好的例子。在重新分配內存的過程中,從舊內存將元素拷貝到新內存是不必要的,更好的方式是移動元素。使用移動而不是拷貝的另一個原因源于I0類或unique_ptr這樣的類。這些類都包含不能被共享的資源(如指針或IO緩沖)。因此,這些類型的對象不能拷貝但可以移動。
在舊C++標準中,沒有直接的方法移動對象。因此,即使不必拷貝對象的情況下,我們也不得不拷貝。如果對象較大,或者是對象本身要求分配內存空間(如string),進行不必要的拷貝代價非常高。類似的,在舊版本的標準庫中,容器中所保存的類必須是可拷貝的。但在新標準中,我們可以用容器保存不可拷貝的類型,只要它們能被移動即可。
Note:標準庫容器、string和shared_ptr類既支持移動也支持拷貝。I0類和unique_ptr類可以移動但不能拷貝。
13.6.1 右值引用
為了支持移動操作,新標準引入了一種新的引用類型–右值引用(rvalue reference)。所謂右值引用就是必須綁定到右值的引用。我們通過&&而不是&來獲得右值引用。如我們將要看到的,右值引用有一個重要的性質–只能綁定到一個將要銷毀的對象。因此,我們可以自由地將一個右值引用的資源“移動”到另一個對象中。
回憶一下,左值和右值是表達式的屬性。一些表達式生成或要求左值,而另外一些則生成或要求右值。一般而言,一個左值表達式表示的是一個對象的身份,而一個右值表達式表示的是對象的值。
類似任何引用,一個右值引用也不過是某個對象的另一個名字而已。如我們所知,對于常規引用(為了與右值引用區分開來,我們可以稱之為左值引用(lvalue reference)),我們不能將其綁定到要求轉換的表達式、字面常量或是返回右值的表達式。右值引用有著完全相反的綁定特性:我們可以將一個右值引用綁定到這類表達式上,但不能將一個右值引用直接綁定到一個左值上:
int i = 42;
int &r =i;//正確:r引用i
int &&rr =i;//錯誤:不能將一個右值引用綁定到一個左值上
int &r2=i* 42;//錯誤:i*42是一個右值
const int &r3=i*42;//正確:我們可以將一個const的引用綁定到一個右值上
int &&rr2=i* 42;//正確:將rr2綁定到乘法結果上
返回左值引用的函數,連同賦值、下標、解引用和前置遞增/遞減運算符,都是返回左值的表達式的例子。我們可以將一個左值引用綁定到這類表達式的結果上。
返回非引用類型的函數,連同算術、關系、位以及后置遞增/遞減運算符,都生成右值。我們不能將一個左值引用綁定到這類表達式上,但我們可以將一個const的左值引用或者一個右值引用綁定到這類表達式上。
左值持久;右值短暫
考察左值和右值表達式的列表,兩者相互區別之處就很明顯了:左值有持久的狀態而右值要么是字面常量,要么是在表達式求值過程中創建的臨時對象。
由于右值引用只能綁定到臨時對象,我們得知
(1)所引用的對象將要被銷毀
(2)該對象沒有其他用戶
這兩個特性意味著:使用右值引用的代碼可以自由地接管所引用的對象的資源。
Note:右值引用指向將要被銷毀的對象。因此,我們可以從綁定到右值引用的對象“竊取”狀態。
變量是左值
變量可以看作只有一個運算對象而沒有運算符的表達式,雖然我們很少這樣看待變量。類似其他任何表達式,變量表達式也有左值/右值屬性。變量表達式都是左值。帶來的結果就是,我們不能將一個右值引用綁定到一個右值引用類型的變量上,這有些令人驚訝:
int &&rrl=42;//正確:字面常量是右值
int &&rr2=rr1;//錯誤:表達式rr1是左值!
其實有了右值表示臨時對象這一觀察結果,變量是左值這一特性并不令人驚訝。畢竟,變量是持久的,直至離開作用域時才被銷毀。
變量是左值,因此我們不能將一個右值引用直接綁定到一個變量上,即使這個變量是右值引用類型也不行。
標準庫 move 函數
雖然不能將一個右值引用直接綁定到一個左值上,但我們可以顯式地將一個左值轉換為對應的右值引用類型。我們還可以通過調用一個名為move的新標準庫函數來獲得綁定到左值上的右值引用,此函數定義在頭文件utility中。move函數使用了我們將在 16.2.6節(第610頁)中描述的機制來返回給定對象的右值引用。
int &&rr3= std::move(rrl);//ok
move 調用告訴編譯器:我們有一個左值,但我們希望像一個右值一樣處理它。我們必須認識到,調用move 就意味著承諾:除了對rr1賦值或銷毀它外,我們將不再使用它。在調用 move 之后,我們不能對移后源對象的值做任何假設。
我們可以銷毀一個移后源對象,也可以賦予它新值,但不能使用一個移后源對象的值。
如前所述,與大多數標準庫名字的使用不同,對move(參見13.5節,第469頁)我們不提供using聲明(參見3.1節,第74頁)。我們直接調用std::move 而不是move,其原因將在18.2.3節(第707頁)中解釋。
WARNING:使用move的代碼應該使用std::move而不是move。這樣做可以避免潛在的名字沖突。
13.6.2移動構造函數和移動賦值運算符
類似string類(及其他標準庫類),如果我們自己的類也同時支持移動和拷貝,那么也能從中受益。為了讓我們自己的類型支持移動操作,需要為其定義移動構造函數和移動賦值運算符。這兩個成員類似對應的拷貝操作,但它們從給定對象“竊取”資源而不是拷貝資源。
類似拷貝構造函數,移動構造函數的第一個參數是該類類型的一個引用。不同于拷貝構造函數的是,這個引用參數在移動構造函數中是一個右值引用。與拷貝構造函數一樣,任何額外的參數都必須有默認實參。
除了完成資源移動,移動構造函數還必須確保移后源對象處于這樣一個狀態–銷毀它是無害的。特別是,一旦資源完成移動,源對象必須不再指向被移動的資源–這些資源的所有權已經歸屬新創建的對象。
作為一個例子,我們為strVec類定義移動構造函數,實現從一個strVec 到另一個strVec的元素移動而非拷貝:
StrVec::StrVec(StrVec &&s)noexcept//移動操作不應拋出任何異常
//成員初始化器接管s中的資源
:elements(s.elements),first_free(s.first_free),cap(s.cap)
{//令s進入這樣的狀態--對其運行析構函數是安全的s.elements=s.first_free =s.cap =nullptr;
}
我們將簡短解釋 noexcept(它通知標準庫我們的構造函數不拋出任何異常),但讓我們先分析一下此構造函數完成什么工作。
與拷貝構造函數不同,移動構造函數不分配任何新內存;它接管給定的strVec中的內存。在接管內存之后,它將給定對象中的指針都置為nullptr。這樣就完成了從給定對象的移動操作,此對象將繼續存在。最終,移后源對象會被銷毀,意味著將在其上運行析構函數。strVec的析構函數在first_free 上調用 deallocate。如果我們忘記了改變s.first_free,則銷毀移后源對象就會釋放掉我們剛剛移動的內存。
移動操作、標準庫容器和異常
由于移動操作“竊取”資源,它通常不分配任何資源。因此,移動操作通常不會拋出任何異常。當編寫一個不拋出異常的移動操作時,我們應該將此事通知標準庫。我們將看到,除非標準庫知道我們的移動構造函數不會拋出異常,否則它會認為移動我們的類對象時可能會拋出異常,并且為了處理這種可能性而做一些額外的工作。
一種通知標準庫的方法是在我們的構造函數中指明noexcept。noexcept是新標準引入的,我們將在18.1.4節(第690頁)中討論更多細節。目前重要的是要知道,noexcept是我們承諾一個函數不拋出異常的一種方法。我們在一個函數的參數列表后指定noexcept。在一個構造函數中,noexcept出現在參數列表和初始化列表開始的冒號之間:
class StrVec
{public:StrVec(StrVec&&)noexcept;//移動構造函數//其他成員的定義,如前
};
StrVec::StrVec(StrVec&&s)noexcept:/*成員初始化器*/{/*構造函數體 */}
我們必須在類頭文件的聲明中和定義中(如果定義在類外的話)都指定noexcept。
不拋出異常的移動構造函數和移動賦值運算符必須標記為noexcept。
搞清楚為什么需要noexcept能幫助我們深入理解標準庫是如何與我們自定義的類型交互的。我們需要指出一個移動操作不拋出異常,這是因為兩個相互關聯的事實:首先,雖然移動操作通常不拋出異常,但拋出異常也是允許的;其次,標準庫容器能對異常發生時其自身的行為提供保障。例如,vector保證,如果我們調用push_back時發生異常vector自身不會發生改變。
現在讓我們思考push_back內部發生了什么。類似對應的strVec操作(參見13.5節,第466頁),對一個vector調用push_back可能要求為vector 重新分配內存空間。當重新分配vector的內存時,vector將元素從舊空間移動到新內存中,就像我們在reallocate中所做的那樣。如我們剛剛看到的那樣,移動一個對象通常會改變它的值。如果重新分配過程使用了移動構造函數,且在移動了部分而不是全部元素后拋出了一個異常,就會產生問題。舊空間中的移動源元素已經被改變了,而新空間中未構造的元素可能尚不存在。在此情況下vector將不能滿足自身保持不變的要求。
另一方面,如果vector使用了拷貝構造函數且發生了異常,它可以很容易地滿足要求。在此情況下,當在新內存中構造元素時,舊元素保持不變。如果此時發生了異常,vector可以釋放新分配的(但還未成功構造的)內存并返回。vector原有的元素仍然存在。
為了避免這種潛在問題,除非vector知道元素類型的移動構造函數不會拋出異常否則在重新分配內存的過程中,它就必須使用拷貝構造函數而不是移動構造函數。如果希望在 vector 重新分配內存這類情況下對我們自定義類型的對象進行移動而不是拷貝,就必須顯式地告訴標準庫我們的移動構造函數可以安全使用。我們通過將移動構造函數(及移動賦值運算符)標記為noexcept來做到這一點。
移動賦值運算符
移動賦值運算符執行與析構函數和移動構造函數相同的工作。與移動構造函數一樣,如果我們的移動賦值運算符不拋出任何異常,我們就應該將它標記為noexcept。類似拷貝賦值運算符,移動賦值運算符必須正確處理自賦值:
StrVec &StrVec::operator=(StrVec &&rhs)noexcept
{//直接檢測自賦值if (this != &rhs){free();//釋放已有元素elements=rhs.elements;//從rhs接管資源first free =rhs.first_free;cap =rhs.cap;//將rhs置于可析構狀態rhs.elements=rhs.first free =rhs.cap =nullptr;}return *this;
}
在此例中,我們直接檢查this指針與rhs的地址是否相同。如果相同,右側和左側運算對象指向相同的對象,我們不需要做任何事情。否則,我們釋放左側運算對象所使用的內存,并接管給定對象的內存。與移動構造函數一樣,我們將rhs中的指針置為nullptr.我們費心地去檢查自賦值情況看起來有些奇怪。畢竟,移動賦值運算符需要右側運算對象的一個右值。我們進行檢查的原因是此右值可能是move調用的返回結果。與其他任何賦值運算符一樣,關鍵點是我們不能在使用右側運算對象的資源之前就釋放左側運算對象的資源(可能是相同的資源)。
移后源對象必須可析構
從一個對象移動數據并不會銷毀此對象,但有時在移動操作完成后,源對象會被銷毀。因此,當我們編寫一個移動操作時,必須確保移后源對象進入一個可析構的狀態。我們的StrVec的移動操作滿足這一要求,這是通過將移后源對象的指針成員置為nullptr來實現的。
除了將移后源對象置為析構安全的狀態之外,移動操作還必須保證對象仍然是有效的。一般來說,對象有效就是指可以安全地為其賦予新值或者可以安全地使用而不依賴其當前值。另一方面,移動操作對移后源對象中留下的值沒有任何要求。因此,我們的程序不應該依賴于移后源對象中的數據。
例如,當我們從一個標準庫string或容器對象移動數據時,我們知道移后源對象仍然保持有效。因此,我們可以對它執行諸如empty或size這些操作。但是,我們不知道將會得到什么結果。我們可能期望一個移后源對象是空的,但這并沒有保證。
我們的 strVec類的移動操作將移后源對象置于與默認初始化的對象相同的狀態。因此,我們可以繼續對移后源對象執行所有的strVec操作,與任何其他默認初始化的對象一樣。而其他內部結構更為復雜的類,可能表現出完全不同的行為。
WARNING:在移動操作之后,移后源對象必須保持有效的、可析構的狀態,但是用戶不能對其值進行任何假設。
合成的移動操作
與處理拷貝構造函數和拷貝賦值運算符一樣,編譯器也會合成移動構造函數和移動賦值運算符。但是,合成移動操作的條件與合成拷貝操作的條件大不相同。回憶一下,如果我們不聲明自己的拷貝構造函數或拷貝賦值運算符,編譯器總會為我們合成這些操作。拷貝操作要么被定義為逐成員拷貝,要么被定義為對象賦值,要么被定義為刪除的函數。
與拷貝操作不同,編譯器根本不會為某些類合成移動操作。特別是,如果一個類定義了自己的拷貝構造函數、拷貝賦值運算符或者析構函數,編譯器就不會為它合成移動構造函數和移動賦值運算符了。因此,某些類就沒有移動構造函數或移動賦值運算符。如我們將在第477頁所見,如果一個類沒有移動操作,通過正常的函數匹配,類會使用對應的拷貝操作來代替移動操作。
只有當一個類沒有定義任何自己版本的拷貝控制成員,且類的每個非static數據成員都可以移動時,編譯器才會為它合成移動構造函數或移動賦值運算符。編譯器可以移動內置類型的成員。如果一個成員是類類型,且該類有對應的移動操作,編譯器也能移動這個成員:
//編譯器會為X和hasx合成移動操作
struct X
{int i;//內置類型可以移動std::string s;// string定義了自己的移動操作
};
struct hasX
{Xmem;//X有合成的移動操作
};
X x,x2 = std::move(x);//使用合成的移動構造函數
hasX hx,hx2= std::move(hx);//使用合成的移動構造函數
Note:只有當一個類沒有定義任何自己版本的拷貝控制成員,且它的所有數據成員都能移動構造或移動賦值時,編譯器才會為它合成移動構造函數或移動賦值運算符。
與拷貝操作不同,移動操作永遠不會隱式定義為刪除的函數。但是,如果我們顯式地要求編譯器生成=default的移動操作,且編譯器不能移動所有成員,則編譯器會將移動操作定義為刪除的函數。除了一個重要例外,什么時候將合成的移動操作定義為刪除的函數遵循與定義刪除的合成拷貝操作類似的原則:
(1)與拷貝構造函數不同,移動構造函數被定義為刪除的函數的條件是:有類成員定義了自己的拷貝構造函數且未定義移動構造函數,或者是有類成員未定義自己的拷貝構造函數且編譯器不能為其合成移動構造函數。移動賦值運算符的情況類似。
(2)如果有類成員的移動構造函數或移動賦值運算符被定義為刪除的或是不可訪問的則類的移動構造函數或移動賦值運算符被定義為刪除的。
(3)類似拷貝構造函數,如果類的析構函數被定義為刪除的或不可訪問的,則類的移動構造函數被定義為刪除的。
(4)類似拷貝賦值運算符,如果有類成員是const的或是引用,則類的移動賦值運算符被定義為刪除的。
例如,假定Y是一個類,它定義了自己的拷貝構造函數但未定義自己的移動構造函數:
//假定丫是一個類,它定義了自己的拷貝構造函數但未定義自己的移動構造函數
struct hasY
{hasY()= default;hasY(hasY&&)=default;Y mem;//hasY將有一個刪除的移動構造函數
};
hasY hy,hy2=std::move(hy);//錯誤:移動構造函數是刪除的
編譯器可以拷貝類型為Y的對象,但不能移動它們。類hasy顯式地要求一個移動構造函數,但編譯器無法為其生成。因此,hasY會有一個刪除的移動構造函數。如果hasy忽略了移動構造函數的聲明,則編譯器根本不能為它合成一個。如果移動操作可能被定義為刪除的函數,編譯器就不會合成它們。
移動操作和合成的拷貝控制成員間還有最后一個相互作用關系:一個類是否定義了自己的移動操作對拷貝操作如何合成有影響。如果類定義了一個移動構造函數和/或一個移動賦值運算符,則該類的合成拷貝構造函數和拷貝賦值運算符會被定義為刪除的。
定義了一個移動構造函數或移動賦值運算符的類必須也定義自己的拷貝操作。否則,這些成員默認地被定義為刪除的。
移動右值,拷貝左值……
如果一個類既有移動構造函數,也有拷貝構造函數,編譯器使用普通的函數匹配規則來確定使用哪個構造函數。賦值操作的情況類似。例如,在我們的strVec類中,拷貝構造函數接受一個const strVec的引用。因此,它可以用于任何可以轉換為strVec的類型。而移動構造函數接受一個strVec&&,因此只能用于實參是(非static)右值的情形:
StrVec vl,v2;
v1= v2;//v2是左值;使用拷貝賦值
StrVec getVec(istream &);// getVec返回一個右值
v2 = getVec(cin);//getVec(cin)是一個右值;使用移動賦值
在第一個賦值中,我們將v2傳遞給賦值運算符。v2的類型是strVec,表達式v2是一個左值。因此移動版本的賦值運算符是不可行的,因為我們不能隱式地將一個右值引用綁定到一個左值。因此,這個賦值語句使用拷貝賦值運算符。
在第二個賦值中,我們賦予v2的是getVec 調用的結果。此表達式是一個右值。在此情況下,兩個賦值運算符都是可行的–將getVec的結果綁定到兩個運算符的參數都是允許的。調用拷貝賦值運算符需要進行一次到const的轉換,而strVec&&則是精確匹配。因此,第二個賦值會使用移動賦值運算符。
但如果沒有移動構造函數,右值也被拷貝
如果一個類有一個拷貝構造函數但未定義移動構造函數,會發生什么呢?在此情況編譯器不會合成移動構造函數,這意味著此類將有拷貝構造函數但不會有移動構造函數。如果一個類沒有移動構造函數,函數匹配規則保證該類型的對象會被拷貝,即使我們試圖通過調用move 來移動它們時也是如此:
class Foo
{public:Foo()= default;Foo(const Foo&);//拷貝構造函數//其他成員定義,但Foo未定義移動構造函數
};
Foo x;
Foo y(x);//拷貝構造函數;x是一個左值
Foo z(std::move(x));//拷貝構造函數,因為未定義移動構造函數
在對z進行初始化時,我們調用了move(x),它返回一個綁定到x的Foo&&。Foo的拷貝構造函數是可行的,因為我們可以將一個Foo&&轉換為一個const Foo&。因此,z的初始化將使用Foo的拷貝構造函數。
值得注意的是,用拷貝構造函數代替移動構造函數幾乎肯定是安全的(賦值運算符的情況類似)。一般情況下,拷貝構造函數滿足對應的移動構造函數的要求:它會拷貝給定對象,并將原對象置于有效狀態。實際上,拷貝構造函數甚至都不會改變原對象的值。
如果一個類有一個可用的拷貝構造函數而沒有移動構造函數,則其對象是通過拷貝構造函數來“移動”的。拷貝賦值運算符和移動賦值運算符的情況類似。
拷貝并交換賦值運算符和移動操作
我們的 HasPtr版本定義了一個拷貝并交換賦值運算符,它是函數匹配和移動操作間相互關系的一個很好的示例。如果我們為此類添加一個移動構造函數,它實際上也會獲得一個移動賦值運算符:
Class HasPtr
{
public://添加的移動構造函數HasPtr(HasPtr &&p)noexcept:ps(p.ps),i(p.i){p.ps=0;}//賦值運算符既是移動賦值運算符,也是拷貝賦值運算符HasPtr& operator=(HasPtrrhs){swap(*this,rhs);return *this;}//其他成員的定義,同13.2.1節(第453頁)
}
在這個版本中,我們為類添加了一個移動構造函數,它接管了給定實參的值。構造函數體將給定的HasPtr的指針置為0,從而確保銷毀移后源對象是安全的。此函數不會拋出異常,因此我們將其標記為noexcept。
現在讓我們觀察賦值運算符。此運算符有一個非引用參數,這意味著此參數要進行拷貝初始化。依賴于實參的類型,拷貝初始化要么使用拷貝構造函數,要么使用移動構造函數–左值被拷貝,右值被移動。因此,單一的賦值運算符就實現了拷貝賦值運算符和移動賦值運算符兩種功能。
例如,假定hp和hp2都是HasPtr對象:
hp=hp2;//hp2是一個左值;hp2通過拷貝構造函數來拷貝
hp=std::move(hp2);//移動構造函數移動hp2
在第一個賦值中,右側運算對象是一個左值,因此移動構造函數是不可行的。rhs將使用拷貝構造函數來初始化。拷貝構造函數將分配一個新string,并拷貝hp2指向的string。
在第二個賦值中,我們調用std::move 將一個右值引用綁定到hp2上。在此情況下,拷貝構造函數和移動構造函數都是可行的。但是,由于實參是一個右值引用,移動構造函數是精確匹配的。移動構造函數從hp2拷貝指針,而不會分配任何內存。
不管使用的是拷貝構造函數還是移動構造函數,賦值運算符的函數體都swap兩個運算對象的狀態。交換 HasPtr會交換兩個對象的指針(及int)成員。在swap之后,rhs中的指針將指向原來左側運算對象所擁有的string。當rhs離開其作用域時,這個string 將被銷毀。
建議:更新三/五法則
所有五個拷貝控制成員應該看作一個整體:一般來說,如果一個類定義了任何一個拷貝操作,它就應該定義所有五個操作。如前所述,某些類必須定義拷貝構造函數、拷貝賦值運算符和析構函數才能正確工作。這些類通常擁有一個資源,而拷貝成員必須拷貝此資源。一般來說,拷貝一個資源會導致一些額外開銷。在這種拷貝并非必要的情況下,定義了移動構造函數和移動賦值運算符的類就可以避免此問題。
Message類的移動操作
定義了自己的拷貝構造函數和拷貝賦值運算符的類通常也會從移動操作受益。例如,我們的Message和Folder類就應該定義移動操作。通過定義移動操作,Message類可以使用string和set的移動操作來避免拷貝contents和folders 成員的額外開銷。
但是,除了移動 folders 成員,我們還必須更新每個指向原Message的 Folder。
我們必須刪除指向舊Message的指針,并添加一個指向新Message的指針。
移動構造函數和移動賦值運算符都需要更新Folder指針,因此我們首先定義一個操作來完成這一共同的工作:
//從本Message移動Folder指針
void Message::move Folders(Message *m)
{folders=std::move(m->folders);//使用set的移動賦值運算符for(autof:folders){//對每個Folderf->remMsg(m);//從Folder中刪除舊Messagef->addMsg(this);//將本Message添加到Folder中}m->folders.clear();//確保銷毀m是無害的
}
此函數首先移動 folders集合。通過調用move,我們使用了set的移動賦值運算符而不是它的拷貝賦值運算符。如果我們忽略了move調用,代碼仍能正常工作,但帶來了不必要的拷貝。函數然后遍歷所有Folder,從其中刪除指向原Message的指針并添加指向新 Message 的指針。
值得注意的是,向set插入一個元素可能會拋出一個異常–向容器添加元素的操作要求分配內存,意味著可能會拋出一個bad_alloc異常。因此,與我們的HasPtr和strVec類的移動操作不同,Message的移動構造函數和移動賦值運算符可能會拋出異常。因此我們未將它們標記為noexcep。
函數最后對m.folders調用clear。在執行了move 之后,我們知道m.folders是有效的,但不知道它包含什么內容。由于Message的析構函數遍歷folders,我們希望能確定set是空的。
Message的移動構造函數調用move來移動contents,并默認初始化自己的folders 成員:
Message::Message(Message &m): contents(std::move(m.contents))
{move_Folders(&m);//移動folders并更新Folder指針
}
在構造函數體中,我們調用了move_Folders來刪除指向m的指針并插入指向本Message 的指針。
移動賦值運算符直接檢查自賦值情況:
Message& Message::operator=(Message &&rhs)
{if (this != &rhs){remove_from_Folders();//直接檢查自賦值情況contents=std::move(rhs.contents);//移動賦值運算符move_Folders(&rhs);//重置Folders指向本Message}
return *this;
}
與任何賦值運算符一樣,移動賦值運算符必須銷毀左側運算對象的舊狀態。在本例中,銷毀左側運算對象要求我們從現有folders中刪除指向本essage 的指針,我們調用remove_from_Folders 來完成這一工作。完成刪除工作后,我們調用move從rhs將contents移動到this 對象。剩下的就是調用move_Messages 來更新 Folder 指針了。
移動迭代器
StrVec的reallocate成員使用了一個for 循環來調用construct從舊內存將元素拷貝到新內存中。作為一種替換方法,如果我們能調用uninitialized_copy來構造新分配的內存,將比循環更為簡單。但是,uninitialized _copy恰如其名:它對元素進行拷貝操作。標準庫中并沒有類似的函數將對象“移動”到未構造的內存中。
新標準庫中定義了一種移動迭代器(move iterator)適配器。一個移動迭代器通過改變給定迭代器的解引用運算符的行為來適配此迭代器。一般來說,一個迭代器的解引用運算符返回一個指向元素的左值。與其他迭代器不同,移動迭代器的解引用運算符生成一個右值引用。
我們通過調用標準庫的make_move_iterator 數將一個普通迭代器轉換為一個移動迭代器。此函數接受一個迭代器參數,返回一個移動迭代器。
原迭代器的所有其他操作在移動迭代器中都照常工作。由于移動迭代器支持正常的迭代器操作,我們可以將一對移動迭代器傳遞給算法。特別是,可以將移動迭代器傳遞給uninitialized_copy:
void StrVec::reallocate()
{//分配大小兩倍于當前規模的內存空間auto newcapacity=size()?2*size():1;auto first =alloc.allocate(newcapacity);//移動元素auto last = uninitialized_copy(make_move_iterator (begin()),make_move_iterator(end()),first);free();//釋放舊空間elements =first;//更新指針first_free =last;cap =elements+newcapacity;
}
uninitialized_copy對輸入序列中的每個元素調用construct 來將元素“拷貝”到目的位置。此算法使用迭代器的解引用運算符從輸入序列中提取元素。由于我們傳遞給它的是移動迭代器,因此解引用運算符生成的是一個右值引用,這意味著construct將使用移動構造函數來構造元素。
值得注意的是,標準庫不保證哪些算法適用移動迭代器,哪些不適用。由于移動一個對象可能銷毀掉原對象,因此你只有在確信算法在為一個元素賦值或將其傳遞給一個用戶定義的函數后不再訪問它時,才能將移動迭代器傳遞給算法。
建議:不要隨意使用移動操作
由于一個移后源對象具有不確定的狀態,對其調用std::move是危險的。當我們調用move時,必須絕對確認移后源對象沒有其他用戶。
通過在類代碼中小心地使用move,可以大幅度提升性能。而如果隨意在普通用戶代碼(與類實現代碼相對)中使用移動操作,很可能導致莫名其妙的、難以查找的錯誤而難以提升應用程序性能。
Best在移動構造函數和移動賦值運算符這些類實現代碼之外的地方,只有當你確信Practices需要進行移動操作且移動操作是安全的,才可以使用std::move。
13.6.3 右值引用和成員函數
除了構造函數和賦值運算符之外,如果一個成員函數同時提供拷貝和移動版本,它也能從中受益。這種允許移動的成員函數通常使用與拷貝/移動構造函數和賦值運算符相同的參數模式–一個版本接受一個指向const的左值引用,第二個版本接受一個指向非const的右值引用。
例如,定義了push_back的標準庫容器提供兩個版本:一個版本有一個右值引用參數,而另一個版本有一個const左值引用。假定x是元素類型,那么這些容器就會定義以下兩個push_back 版本:
Xvoid push_back(const X&);//拷貝:綁定到任意類型的
void push_back(X&&);//移動:只能綁定到類型X的可修改的右值
我們可以將能轉換為類型x的任何對象傳遞給第一個版本的push_back。此版本從其參數拷貝數據。對于第二個版本,我們只可以傳遞給它非const的右值。此版本對于非const的右值是精確匹配(也是更好的匹配)的,因此當我們傳遞一個可修改的右值時,編譯器會選擇運行這個版本。此版本會從其參數竊取數據。
一般來說,我們不需要為函數操作定義接受一個constx&&或是一個(普通的)X&參數的版本。當我們希望從實參“竊取”數據時,通常傳遞一個右值引用。為了達到這目的,實參不能是const的。類似的,從一個對象進行拷貝的操作不應該改變該對象因此,通常不需要定義一個接受一個(普通的)x&參數的版本。
區分移動和拷貝的重載函數通常有一個版本接受一個const T&,而另一個版本接受一個 T&&。
作為一個更具體的例子,我們將為strvec類定義另一個版本的push_back:
class StrVec
{
public:void push_back(const std::string&);//拷貝元素void push_back(std::string&&);// 移動元素//其他成員的定義,如前
};
//與13.5節(第466頁)中的原版本相同
void StrVec::push_back(const string& s)
{chk_n_alloc();//確保有空間容納新元素//在first_free指向的元素中構造s的一個副本alloc.construct(first_free++,s);
}
void StrVec::push_back(string &&s)
{chk_n_alloc();//如果需要的話為StrVec 重新分配內存alloc.construct(first_free++,std::move(s));
}
這兩個成員幾乎是相同的。差別在于右值引用版本調用move來將其參數傳遞給construct。如前所述,construct函數使用其第二個和隨后的實參的類型來確定使用哪個構造函數。由于move返回一個右值引用,傳遞給construct的實參類型是string&&。因此,會使用string的移動構造函數來構造新元素。
當我們調用push_back時,實參類型決定了新元素是拷貝還是移動到容器中:
StrVec vec;//空StrVec
string s="some string or another";
vec.push_back(s);//調用push_back(const string&)
vec.push_back("done");//調用push_back(string&&)
這些調用的差別在于實參是一個左值還是一個右值(從"done"創建的臨時 string),具體調用哪個版本據此來決定
右值和左值引用成員函數
通常,我們在一個對象上調用成員函數,而不管該對象是一個左值還是一個右值。例如:
string s1="avalue",s2 ="another";
auto n=(s1+s2).find('a');
此例中,我們在一個string右值上調用find成員,該string右值是通過連接兩個string而得到的。有時,右值的使用方式可能令人驚訝:
sl+s2 =“wow!”;
此處我們對兩個string的連接結果–一個右值,進行了賦值。
在舊標準中,我們沒有辦法阻止這種使用方式。為了維持向后兼容性,新標準庫類仍然允許向右值賦值。但是,我們可能希望在自己的類中阻止這種用法。在此情況下,我們希望強制左側運算對象(即,this指向的對象)是一個左值。
我們指出this的左值/右值屬性的方式與定義const成員函數相同,即,在參數列表后放置一個引用限定符(reference qualifier):
class Foo
{
public :Foo &operator=(const Foo&)&;//只能向可修改的左值賦值// Foo 的其他參數
};
Foo &Foo::operator=(const Foo &rhs)&
{//執行將 rhs 賦予本對象所需的工作return *this;
}
引用限定符可以是&或&&,分別指出this可以指向一個左值或右值。類似const 限定符引用限定符只能用于(非static)成員函數,且必須同時出現在函數的聲明和定義中。對于&限定的函數,我們只能將它用于左值;對于&&限定的函數,只能用于右值:
Foo &retFoo();//返回一個引用;retFoo 調用是一個左值
Foo retVal();//返回一個值;retVal調用是一個右值
Foo i,j;//i和j是左值
i =j;//正確:i是左值
retFoo()=j;//正確:retFoo()返回一個左值
retVal()=j;//錯誤:retVal()返回一個右值
i = retVal();//正確:我們可以將一個右值作為賦值操作的右側運算對象
一個函數可以同時用const和引用限定。在此情況下,引用限定符必須跟隨在 const限定符之后:
class Foo
{
public:Foo someMem() & const;//錯誤:const限定符必須在前//正確:const限定符在前Foo anotherMem() const&:
};
重載和引用函數
就像一個成員函數可以根據是否有const來區分其重載版本一樣,引用限定符也可以區分重載版本。而且,我們可以綜合引用限定符和const來區分一個成員函數的重載版本。例如,我們將為Foo定義一個名為data的vector 成員和一個名為sorted 的成員函數,sorted返回一個Foo對象的副本,其中 vector已被排序:
class Foo
{
public:Foo sorted()&&;//可用于可改變的右值Foo sorted()const&;//可用于任何類型的Foo// Foo 的其他成員的定義
private :vector<int> data;
};
//本對象為右值,因此可以原址排序
Foo Foo::sorted()&&
{sort(data.begin(),data.end());return *this;
}
//本對象是 const或是一個左值,哪種情況我們都不能對其進行原址排序
Foo Foo::sorted()const&
{Foo ret(*this);//拷貝一個副本sort(ret.data.begin(),ret.data.end());//排序副本return ret;//返回副本
}
當我們對一個右值執行sorted時,它可以安全地直接對data成員進行排序。對象是一個右值,意味著沒有其他用戶,因此我們可以改變對象。當對一個const右值或一個左值執行 sorted 時,我們不能改變對象,因此就需要在排序前拷貝data。
編譯器會根據調用sorted的對象的左值/右值屬性來確定使用哪個sorted 版本:
retVal().sorted();//retVal()是一個右值,調用Foo::sorted()&&
retFoo().sorted();//retFoo()是一個左值,調用Foo::sorted()const &
當我們定義const成員函數時,可以定義兩個版本,唯一的差別是一個版本有const限定而另一個沒有。引用限定的函數則不一樣。如果我們定義兩個或兩個以上具有相同名字和相同參數列表的成員函數,就必須對所有函數都加上引用限定符,或者所有都不加:
Class Foo
{
public:Foo sorted()&&;Foo sorted()const;//錯誤:必須加上引用限定符//Comp是函數類型的類型別名(參見6.7節,第222頁)//此函數類型可以用來比較int值using Comp=bool(const int&,const int&);Foo sorted(Comp*);//正確:不同的參數列表Foo sorted(Comp*)const;//正確:兩個版本都沒有引用限定符
}
本例中聲明了一個沒有參數的const版本的sorted,此聲明是錯誤的。因為Foo類中還有一個無參的sorted版本,它有一個引用限定符,因此const版本也必須有引用限定符。另一方面,接受一個比較操作指針的sorted版本是沒問題的,因為兩個函數都沒
有引用限定符。
Note如果一個成員函數有引用限定符,則具有相同參數列表的所有版本都必須有引用限定符。