C++ Primer學習筆記 - 對象移動move


背景

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三步:

  1. 釋放當前對象已有資源;
  2. 接管源對象的資源;
  3. 置源對象為可析構狀態;
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限定符之后;


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM