背景
C++ 11 新特性對象移動,可以移動對象而非拷貝。在某些情況下,對象拷貝后就立刻被銷毀了,比如值傳遞參數,對象以值傳遞方式返回,臨時對象構造另一個對象。在這些情況下,如果使用移動對象而非拷貝對象能大幅提升性能。
string s1(string("hello")); // 無名對象string("hello") 就是一個會在拷貝構造s1后,立即銷毀臨時對象
[======]
右值引用
提到對象移動,就不得不提到2個元素:右值引用和std::move庫函數。
右值引用(rvalue reference)就是必須綁定到右值的引用,主要包括無名對象、表達式、字面量。通過 && 來獲得右值的引用。
簡單理解,右值引用就是臨時對象的引用,但臨時對象並不一定是右值,而是要立刻銷毀的臨時對象才是右值對象。比如函數內定義的有名對象是臨時對象,並不是立刻銷毀。
右值引用特性
1)右值引用只能綁定到一個將要銷毀的對象。可以自由地將一個右值引用的資源“移動”到另一個對象上;
2)類似於左值引用,右值引用也是一個對象的別名;
右值引用和左值引用的區別
左值引用(lvalue reference)是我們熟悉的常規引用,為了區分右值引用而提出。特點是不能將左值引用綁定到1)要求轉換的表達式;2)字面常量;3)返回右值的表達式;
例如,
int a = 2;
int &i = a * 2; // 錯誤:臨時計算結果a * 2 是右值,不能綁定到左值引用
const int& ii = a * 2; // 正確:可以將一個const引用綁定到一個右值上
int &&r = a * 2; // 正確:std::move將左值a轉換成了右值,能綁定到右值引用
int &i1 = 42; // 錯誤:42是字面常量,不能綁定到左值引用
int &&r1 = 42; // 正確:42是字面常量,能綁定到右值引用
int &i2 = std::move(a); // 錯誤:std::move將左值a轉換成了右值,不能綁定到左值引用
int &&r2 = std::move(a); // 正確:std::move將左值a轉換成了右值,能綁定到右值引用
注意:可以將一個const引用(不論const &,還是const &&)綁定到一個右值上
左值持久,右值短暫
左值和右值最明顯的區別是:左值有持久的狀態,不會立即銷毀;右值要么是字面常量,要么是表達式求值過程中創建的臨時對象。
因此,可以知道右值引用:
1) 所引用的對象將要被銷毀;
2)該對象沒有其他用戶;
詳見之前寫的這篇文章C++ > 右值引用和左值引用的區別
變量是左值
變量是左值,不能將一個右值引用直接綁定到一個變量上,即使變量是右值引用類型。
int a = 42;
int &&rr1 = 42; // 正確:字面常量是右值
int &&rr2 = a; // 錯誤:變量a是左值
int &&rr3 = rr1; // 錯誤:右值引用rr1是左值
std::move函數
頭文件
不能將一個右值引用綁定到一個左值上,但可以通過調用std::move函數,將左值轉換為對應的右值引用類型。
int &&rr1 = 42;
int &&rr4 = rr1; // 錯誤:不能將右值引用綁定到另一個右值引用
int &&rr5 = std::move(rr1); // OK
move函數告訴編譯器:我們有一個左值,但希望像一個右值一樣處理它。調用move意味着承諾:除對rr1賦值或銷毀它之外,不能再使用它。在調用move之后,就不能對移動后源對象的值做任何假設。
int *p = new int(42);
int &&r = std::move(*p);
cout << r << endl;
r = 1;
*p = 3; // 編譯器不報錯,也不會阻止修改源對象值,但不建議這么做
cout << r << endl;
cout << *p << endl;
注意:與大多數標准庫名字的使用不同,對move不提供using上面,建議是直接調用std::move而非move。由於move名字常見,應用程序經常定義該函數,為了避免與應用程序定義的move函數沖突,請使用std::move。
[======]
移動構造函數和移動賦值運算符
移動構造函數(又稱move constructor)和移動賦值運算符(又稱move assignment運算符),類似於copy函數(copy構造函數,copy assignment運算符),不過前2個函數是從給定對象“竊取”資源,而非拷貝資源。
除了完成資源移動,move constructor還必須確保移動后源對象處於這樣的狀態:銷毀源對象是無害的。
一旦資源移動完成后,資源不再屬於源對象而是屬於新創建的對象,源對象必須不再指向被移動的資源。
例,為StrVec類定義move constructor,實現從一個StrVec到另一個StrVec的元素move而非copy:
class StrVec
{
public:
StrVec(const StrVec &s); // copy constructor
StrVec(StrVec &&s) noexcept; // move constructor
...
private:
string *elements;
string *first_free;
string *cap;
};
StrVec::StrVec(StrVec &&s) noexcept // move操作不應拋出任何異常
// 成員初始化器接管s中的資源
: elements(s.elements), first_free(s.first_free), cap(s.cap)
{
// 令s進入這一的狀態 -- 對齊運行析構函數是安全的
s.elements = s.first_free = s.cap = nullptr;
}
move構造函數中,新創建對象成員初始化器接管了源對象中的資源,並將源對象指向資源的指針都置空,就完成了資源的移動操作。源對象析構時,資源並不會被釋放,因此新對象使用資源是安全的。
noexcept 表明該函數不拋出任何異常。
移動操作、標准庫容器和異常
因為移動操作“竊取”資源,通常不分配任何資源。因此移動操作通常不會拋出異常。既然如此,為什么需要指明noexcept呢?
這是因為,除非編譯器知道我們的move構造函數不會拋出異常,否則會認為移動我們的類對象可能拋出異常,並且為了處理這種可能而做一些額外工作。因此,如果確認不會拋出異常,就用noexcept顯式指出。
TIPS:
不拋出異常的move構造函數和move assignment運算符必須標記為noexcept。
移動操作通常不拋出異常,但不代表不能拋出異常,而且標准庫容器能對異常發生時自身的行為提供保障。比如,vector保證,調入push_back發生異常(如內存不夠),vector自身不會改變。
為了避免這種潛在問題,除非vector知道元素類型的move構造函數不會拋出異常,否則,在重新分配內存的過程中,必須用copy構造函數而非move構造函數。
如果希望在vector重新分配內存這類情況下,對我們自定義類型的對象進行move而非copy,就必須顯式告訴標准庫我們的移動構造函數可以安全使用。
簡而言之:move構造函數如果可能拋出異常,就使用copy構造函數構造對象。如果move構造函數不拋出異常,就用noexcept顯式聲明。
移動賦值運算符(move assignment)
move assignment執行與析構函數和move構造函數相同的工作。如果我們的move assignment運算符不拋出任何異常,就應該標記為noexcept。
定義move assignment三步:
- 釋放當前對象已有資源;
- 接管源對象的資源;
- 置源對象為可析構狀態;
class StrVec
{
public:
...
StrVec& operator=(StrVec &&rhs) noexcept; // move assignment
...
private:
string *elements;
string *first_free;
string *cap;
};
StrVec& StrVec::operator=(StrVec &&rhs) noexcept
{
if (this != &rhs) {// 避免自移動,因為move返回結果可能是對象自身
// 釋放this對象已有元素, 相當於調用this->~StrVec
free();
// 從rhs接管資源
elements = rhs.elements;
first_free = rhs.first_free;
cap = rhs.cap;
// 將rhs置於可析構狀態
elements = first_free = cap = nullptr;
}
return *this;
}
移動后源對象必須可析構
資源從一個對象移動到另一個對象並不會銷毀源對象,但有時在移動操作完成后,源對象會被銷毀。因此,在編寫移動操作時,必須確保移動后源對象進入可析構狀態。否則,析構源對象可能導致資源釋放,或者修改資源狀態,導致接管對象出現異常。
移動資源完成后,程序不應該在依賴於源對象中的數據。雖然可能還能訪問源對象中的數據,但結果是不確定的。
TIPS:移動之后,移后源對象必須保持有效的、可析構的狀態,但是用戶不能對其進行任何假設。什么時候可析構?取決於用戶,通常可立即析構。
合成的move操作
如果我們不聲明自己的copy函數,當需要的時候,編譯器會為我們合成默認的版本。類似地,編譯器也會為我們合成move函數(move constructor和move assignment運算符,即move操作)。
copy操作可以被定義成三種情況:
1)bit-wise copy成員;
2)為對象賦值;
3)刪除的函數;
什么時候編譯器不合成move操作?
不同於copy函數,編譯器不會為某些類合成move操作,特別一個類如果定義了3種成員函數:
1)copy構造函數;
2)copy assignment運算符;
3)析構函數;
什么時候編譯器會合成move操作?
只有當一個類沒有定義任何自己版本的copy控制成員(即copy函數),且類的每個非static數據成員都可以移動時(通常是內置類型、支持move操作的對象),編譯器才會為它合成move操作。
當類沒有move操作時,會發生什么?
當一個類沒有move操作時,正常的函數匹配,類會使用copy操作來替代。
合成move操作示例:
struct X
{
int i; // 內置類型
string s; // string定義了自己的move操作
};
struct hasX
{
X mem; // X有合成的move操作
};
int main()
{
X x, x2 = std::move(x); // 使用合成的move構造函數
hasX hx, hx2 = std::move(hx); // 使用合成的move構造函數
return 0;
}
思考:當我們既沒有定義copy函數,也沒有定義move函數時,編譯器何時使用合成的copy操作,何時使用合成的move操作?
我的理解:看函數匹配,如果用於構造或賦值的實參是左值,就用合成的copy操作;如果是右值,就用合成的move操作。
定義default、delete move操作
與copy操作不同,move操作不會隱式定義為delete。相反,如果我們用=default顯式要求編譯出合成move操作,但編譯器不能移動所有成員,則編譯器會將move操作定義為delete。
那么,什么時候編譯器會將move操作定義為delete呢?
其原則是:
-
與copy構造函數不同,move構造函數被定義為delete的條件是:存在數據成員有copy constructor沒有move constructor,或者沒有copy constructor但無法合成move constructor。move assignment情況類似。
-
如果有數據成員的move操作被定義為delete或者不可訪問的,那么類的move操作被定義為delete。
-
類似copy構造函數,如果類的析構函數被定義為delete或不可訪問的,則類的move構造函數被定義為delete。
-
類似copy assignment運算符,如果有類數據成員是const的或引用,則類的move assignment被定義為刪除的。
例如,假設Y是一個class,定義了copy構造函數,但沒有定義move構造函數:
class Y {
public:
Y() = default;
Y(const Y&) { cout << "Y copy constructor invoked" << endl; }
Y& operator=(const Y&) { cout << "Y copy assignment invoked" << endl; return *this; }
};
// 由於數據成員Y沒有move函數(move構造函數和move assignment運算符),編譯器不會為hasY合成move函數,相反合成copy函數
struct hasY {
hasY() = default;
hasY(hasY &&) = default; // 編譯器並不會合成move構造函數
hasY& operator=(hasY&&) = default; // 編譯器不會合成move assignment運算符
Y mem; // 將有一個delete的move constructor,move assignment運算符
};
int main()
{
hasY hy, hy2 = std::move(hy); // 實際上並不會調用hasY的move constructor, 而是調用copy constructor
hasY h3, h4;
h4 = std::move(h3); // 實際上調用copy assignment運算符
return 0;
}
運行結果:
可以看到,即使指示move函數為default,但實際上並沒有調用move函數,而是調用的copy函數。
Y copy constructor invoked
Y copy assignment invoked
移動操作和合成的copy控制成員間還有相互作用:如果一個類定義了move函數,則該類合成對應的copy函數會被定義為delete。
move右值,copy左值
如果一個類既有move函數,也有copy函數,編譯器使用普通的函數匹配規則確定使用哪個函數:左值使用copy函數,右值使用move函數。
StrVec v1, v2; // v1, v2是左值
v1 = v2; // v2是左值,使用copy assignment運算符
StrVec getVec(istream &); // getVec返回右值(即將銷毀的臨時對象)
v2 = getVec(cin); // getVec返回右值,使用move assignment運算符
如果沒有move函數,就使用相應copy函數,即使是右值
當只有copy函數(包括合成的),沒有move函數(包括編譯器沒有合成,用戶設置函數為delete)時,即使是右值,也使用copy函數,而不是使用move函數(因為沒有)。
class Foo {
public:
Foo() = default; // default constructor
Foo(const Foo&); // copy constructor
... // 其他函數,但沒有move constructor
};
Foo x; // 使用default constructor構建對象x
Foo y(x); // 使用copy constructor構建對象y
Foo z(std::move(x)); // 使用copy constructor,不使用move constructor,因為沒有move constructor
std::move(x)返回的是一個綁定到x的Foo&&(右值),由於沒有move構造函數,只有copy構造函數,因此即使構建對象z的時候,使用了右值,但實際上會把Foo&&轉換為const Foo&,從而調用copy構造函數構造z。
拷貝並交換賦值運算符和move操作
std::swap可以實現資源的移動。
比如,我們定義class HashPtr:
// 定義default構造函數,move構造函數,不定義copy構造函數和move assignment運算符
// 可以推斷編譯器不會合成copy構造函數和copy assignment運算符( 隱式delete)
class HashPtr {
public:
HashPtr() : ps(nullptr), i(0) { } // default構造函數
HashPtr(HashPtr &&p) noexcept : ps(p.ps), i(p.i) { p.ps = 0; } // move構造函數,由於合成了move構造函數,所以不會合成copy操作
// assignment(operator=) 既是move assignment運算符,也是copy assignment運算符
// 注意與HashPtr& operator=(const HashPtr &rhs) {}和HashPtr& operator=(const HashPtr &&rhs) {}的區別
HashPtr& operator=(HashPtr rhs) { swap(*this, rhs); return *this; } // assignment運算符
// ...
private:
string *ps;
int i;
};
operator=的參數是HashPtr rhs,意味着傳參時,要進行一次copy構造,然而我們已經定義了move構造,編譯器不會為我們合成copy構造,也就是說copy構造是隱式delete。
假定hp,hp2都是HashPtr對象:
HashPtr hp;
HashPtr hp2 = hp; // 錯誤:因為已經定義了move構造函數,編譯器不會合成copy構造函數(delete),而hp是一個右值,無法調用move構造函數來構造hp2
HashPtr hp3 = std::move(hp); // OK:std::move將hp轉換為右值,調用move構造函數構造hp3
HashPtr hp4, hp5;
hp4 = hp; // 錯誤:因為hp是左值,copy運算符會用到copy構造函數構造形參rhs,然而copy構造函數是隱式delete
hp5 = std::move(hp); // OK::std::move將hp轉換為右值,調用move構造函數構造operator=形參rhs
建議:更新三/五法則
5個拷貝控制成員:
1)1個析構函數;
2)2個copy函數:copy構造函數,copy assignment運算符;
3)2個move函數:move構造函數,moveassignment運算符;
應該看作一個整體,一個類如果定義了任何一個拷貝操作,就應該定義所有5個操作。
- 如果一個class自定義copy構造函數,那么它很可能需要定義copy assignment (同樣適用於move函數);
- 如果一個class自定義析構函數,那么它很可能需要定義copy函數,因為有自定義對象成員需要自定義copy函數來實現拷貝;
- 如果一個class自定義析構函數,那么它很可能需要定義move函數,來減少copy資源帶來的不必要開銷;
- 如果一個class定義了指針類型數據成員,那么它很可能需要定義析構函數,來釋放動態申請的資源;
移動迭代器 move iterator
一般地,一個迭代器解引用(如,*it,it是某個迭代器)返回一個指向元素的左值,然而,move迭代器返回一個指向元素的右值引用。
標准庫函數make_move_iterator可以將一個普通迭代器轉換為一個move迭代器。
例,不使用move迭代器時,如果要擴張StrVec(自定義動態string數組)的尺寸,可以這樣做:
void StrVec::reallocate()
{
// 分配當前規模大小的2倍空間
auto newcapacity = size() ? 2 * size() : 1;
// 分配raw memory
auto newdata = alloc.allocate(newcapacity);
// 將數據從舊內存移動到新內存
auto dest = newdata; // 指向新數組空閑位置
auto elem = elements; // 指向舊數組下一個元素
// 在allocator分配的內存上,逐次調用class string的move構造函數
for (size_t i = 0; i != size(); i++) {
alloc.construct(dest++, std::move(*elem++));
}
free(); // 移動完成,釋放舊內存
// 更新指針
elements = newdata;
first_free = dest;
cap = elements + newcapacity;
}
使用move迭代器:
void StrVec::reallocate()
{
// 分配當前大小2倍內存空間
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;
}
class StrVec完整源代碼
點擊查看代碼
class StrVec {
public:
StrVec() : // allocator成員進行默認初始化
elements(nullptr), first_free(nullptr), cap(nullptr) { }
StrVec(const StrVec&);
StrVec& operator=(const StrVec&);
~StrVec();
void push_back(const string&);
size_t size() const { return first_free - elements; }
size_t capacity() const { return cap - elements; }
string *begin() const { return elements; }
string *end() const { return first_free; }
private:
static allocator<string> alloc;
void chk_n_alloc() { if (size() == capacity()) reallocate(); }
pair<string*, string*> alloc_n_copy(const string*, const string*);
void free();
void reallocate();
string *elements; // 指向數組首元素的指針
string *first_free; // 指向數組第一個空閑元素的指針
string *cap; // 指向數組尾后位置的指針
};
allocator<string> StrVec::alloc;
StrVec::StrVec(const StrVec& s)
{
auto newdata = alloc_n_copy(s.begin(), s.end());
elements = newdata.first;
first_free = cap = newdata.second;
}
StrVec& StrVec::operator=(const StrVec& rhs)
{
auto data = alloc_n_copy(rhs.begin(), rhs.end());
free();
elements = data.first;
first_free = cap = data.second;
return *this;
}
StrVec::~StrVec()
{
free();
}
void StrVec::push_back(const string& s)
{
chk_n_alloc();
alloc.construct(first_free++, s);
}
pair<string*, string*> StrVec::alloc_n_copy(const string* b, const string* e)
{
auto data = alloc.allocate(e - b);
return { data, uninitialized_copy(b, e, data) };
}
void StrVec::free()
{
if (elements)
{
for (auto p = first_free; p != elements; )
{
alloc.destroy(--p);
}
alloc.deallocate(elements, cap - elements);
}
}
void StrVec::reallocate()
{
// 分配當前大小2倍內存空間
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;
}
// 客戶端測試代碼
int main()
{
StrVec s;
stringstream stream;
string str;
for (int i = 0; i < 50; i++) {
stream << i + 1;
stream >> str;
s.push_back(str);
}
cout << s.size() << endl;
StrVec s2;
s2 = s;
cout << s2.size() << endl;
return 0;
}
注意:建議不要隨意使用move操作。
1)標准庫不保證哪些算法適用move迭代器,哪些不適用。
2)移后源對象具有不確定的狀態,可能銷毀源對象,也可能不銷毀,對其調用std::move很危險的。
因此,如果要使用move操作,必須確保移后源對象沒有其他用戶,必須確信需要進行move操作是安全的。這並非C++語法要求,而是使用move操作應該遵循的規范。
右值引用和成員函數
除了構造函數和assignment運算符,右值引用也能應用於成員函數,提供成員函數的move版本。
成員函數允許同時提供兩個版本重載函數:copy版本,move版本。copy版本接受一個指向const的左值引用,move版本接受一個指向非const的右值引用。
例如,定義了push_back的標准庫容器vector,提供了這樣2個版本。
void push_back(const X&); // 拷貝:綁定到任意類型的X
void push_back(X&&); // 移動:只能綁定到類型X的可修改的右值
我們可以在上一節StrVec基礎上,為其添加2個版本push_back:
class StrVec
{
public:
void push_back(const string& s); // copy元素
void push_back(string &&s); // move元素
...
};
// copy版本
void StrVec::push_back(const string& s)
{
chk_n_alloc(); // 如果需要的話為StrVec重新分配內存
alloc.construct(first_free++, s);
}
// move版本
void StrVec::push_back(string&& s)
{
chk_n_alloc(); // 如果需要的話為StrVec重新分配內存
alloc.construct(first_free++, std::move(s));
}
// 客戶端
// 實參類型決定了新元素是copy還是move
string vec;
string s = "hello";
vec.push_back(s); // 調用push_back(const string&)
vec.push_back("test"); // 調用push_back(string&&)
右值和左值引用成員函數
通常在一個對象上調用成員函數,並不關心該對象是左值還是右值。
例如,我們在一個string右值(s1+s2)上調用find成員
string s1 = "this is a value", s2 = "another";
auto n = (s1 + s2).find('a');
cout << n << endl; // 打印8
舊標准無法阻止這種使用方式,為了維持向后兼容性,新標准庫類仍然允許這種向右值賦值。但如果我們想希望在自己的class中,強制左側運算對象(即this指向的對象)是一個左值,阻止向右值賦值,該怎么辦?
答:可以在參數列表后放置一個引用限定符(reference qualifier),指出this的左值/右值屬性。
規則:
加了引用限定符 & 的成員函數,只能被左值對象調用;
加了引用限定符 && 的成員函數,只能被右值對象調用;
//------ 限定向可修改的左值賦值 --------------
class Foo
{
public:
Foo& operator=(const Foo&) &; // 指出this可以指向一個左值
};
Foo& operator=(const Foo& rhs) &
{
// 將rhs賦予本對象
...
return *this;
}
//------ 限定向可修改的右值賦值 --------------
class Foo
{
public:
Foo& operator=(Foo&) &&; // 指出this可以指向一個右值
};
Foo& operator=(const Foo& rhs) &&
{
// 將rhs移動給本對象
...
return *this;
}
注意:
1)類似於const限定符,引用限定符只能用於非static成員函數;
2)位置類同const限定符,但如果也有const限定符時,引用限定符只能放在const限定符之后;