1. 程式人生 > >C++基礎教程面向物件(學習筆記(84))

C++基礎教程面向物件(學習筆記(84))

移動建構函式並移動賦值

在前面智慧指標和移動語義的介紹中,我們看了一下std :: auto_ptr,討論了移動語義,並看了一下在為複製語義設計的函式時出現的一些缺點(複製建構函式)和複製賦值運算子被重新定義以實現移動語義。

在本課中,我們將深入研究C ++ 11如何通過移動建構函式和移動賦值來解決這些問題。

複製建構函式和複製賦值

首先,讓我們花點時間來回顧一下複製語義。

複製建構函式用於通過複製同一個類的物件來初始化類。複製分配用於將一個類複製到另一個現有類。預設情況下,如果未明確提供,則C ++將提供複製建構函式和複製賦值運算子。這些編譯器提供的函式執行淺拷貝,這可能會導致分配動態記憶體的類出現問題。因此,處理動態記憶體的類應該覆蓋這些函式來執行深層複製。

回到本章第一課中的Auto_ptr智慧指標類示例,讓我們看一個實現複製建構函式和複製賦值運算子的版本,它們執行深層複製,以及一個執行它們的示例程式:

template<class T>
class Auto_ptr3
{
	T* m_ptr;
public:
	Auto_ptr3(T* ptr = nullptr)
		:m_ptr(ptr)
	{
	}
 
	~Auto_ptr3()
	{
		delete m_ptr;
	}
 
	//複製建構函式
	// 將a.m_ptr的深層副本複製到m_ptr
	Auto_ptr3(const Auto_ptr3& a)
	{
		m_ptr = new T;
		*m_ptr = *a.m_ptr;
	}
 
	// 複製作業
	//將a.m_ptr的深層副本複製到m_ptr
	Auto_ptr3& operator=(const Auto_ptr3& a)
	{
		// 自我指派檢測
		if (&a == this)
			return *this;
 
		// 釋放我們持有的任何資源
		delete m_ptr;
 
		// 複製資源
		m_ptr = new T;
		*m_ptr = *a.m_ptr;
 
		return *this;
	}
 
	T& operator*() const { return *m_ptr; }
	T* operator->() const { return m_ptr; }
	bool isNull() const { return m_ptr == nullptr; }
};
 
class Resource
{
public:
	Resource() { std::cout << "Resource acquired\n"; }
	~Resource() { std::cout << "Resource destroyed\n"; }
};
 
Auto_ptr3<Resource> generateResource()
{
	Auto_ptr3<Resource> res(new Resource);
	return res; //此返回值將呼叫複製建構函式
}
 
int main()
{
	Auto_ptr3<Resource> mainres;
	mainres = generateResource(); // 此作業將呼叫複製作業
 
	return 0;
}

在這個程式中,我們使用一個名為generateResource()的函式來建立一個智慧指標封裝資源,然後將其傳遞迴函式main()。函式main()然後將其分配給現有的Auto_ptr3物件。

執行此程式時,它會列印:

Resource acquired
Resource acquired
Resource destroyed
Resource acquired
Resource destroyed
Resource destroyed

(注意:如果編譯器從函式generateResource()中刪除返回值,則可能只獲得4個輸出)

對於這樣一個簡單的程式來說,這需要大量的資源創造和破壞!這裡發生了什麼?

讓我們仔細看看。此程式中有6個關鍵步驟(每個列印訊息一個):

1)在generateResource()內部,建立區域性變數res並使用動態分配的資源進行初始化,從而導致第一個“獲取資​​源”。
2)Res按值返回main()。我們在這裡返回值因為res是一個區域性變數 - 它不能通過地址或引用返回,因為當generateResource()結束時res將被銷燬。因此res是複製構造成臨時物件。由於我們的複製建構函式執行深層複製,因此在此處分配新資源,這會導致第二個“資源獲取”。
3)Res超出範圍,破壞最初建立的資源,導致第一個“資源被破壞”。
4)通過複製分配將臨時物件分配給mainres。由於我們的複製分配也進行了深層複製,因此會分配一個新資源,從而導致另一個“資源獲取”。
5)賦值表示式結束,臨時物件超出表示式範圍並被銷燬,導致“資源被破壞”。
6)在main()結束時,mainres超出範圍,並顯示我們的最終“Resource destroyed”。

因此,簡而言之,因為我們呼叫複製建構函式一次將構造res複製到臨時,並複製賦值一次以將臨時複製到mainres,我們最終總共分配和銷燬3個單獨的物件。

效率不高,但至少不會崩潰!

但是,使用移動語義,我們可以做得更好。

移動建構函式和移動賦值

C ++ 11定義了兩個用於移動語義的新函式:移動建構函式和移動賦值運算子。複製建構函式和複製賦值的目標是將一個物件複製到另一個物件,而移動建構函式和移動賦值的目標是將資源的所有權從一個物件移動到另一個物件(這比重新開闢要便宜得多)。

定義移動建構函式並將賦值工作類似於其副本對應項。但是,雖然這些函式的複製風格採用const l-value引用引數,但這些函式的移動風格使用非const r值引用引數。

這是與上面相同的Auto_ptr3類,添加了移動建構函式和移動賦值運算子。為了進行比較,我們留在深度複製複製建構函式和複製賦值運算子中。

#include <iostream>
 
template<class T>
class Auto_ptr4
{
	T* m_ptr;
public:
	Auto_ptr4(T* ptr = nullptr)
		:m_ptr(ptr)
	{
	}
 
	~Auto_ptr4()
	{
		delete m_ptr;
	}
 
	// 複製建構函式
	// 將a.m_ptr的深層副本複製到m_ptr
	Auto_ptr4(const Auto_ptr4& a)
	{
		m_ptr = new T;
		*m_ptr = *a.m_ptr;
	}
 
	// 移動建構函式
	// 將a.m_ptr的所有權轉讓給m_ptr
	Auto_ptr4(Auto_ptr4&& a)
		: m_ptr(a.m_ptr)
	{
		a.m_ptr = nullptr; // 我們將在下面詳細介紹這一行
	}
 
	// 複製作業
	// 將a.m_ptr的深層副本複製到m_ptr
	Auto_ptr4& operator=(const Auto_ptr4& a)
	{
		//自我指派檢測
		if (&a == this)
			return *this;
 
		// 釋放我們持有的任何資源
		delete m_ptr;
 
		// 複製資源
		m_ptr = new T;
		*m_ptr = *a.m_ptr;
 
		return *this;
	}
 
	// 移動作業
	// 將a.m_ptr的所有權轉讓給m_ptr
	Auto_ptr4& operator=(Auto_ptr4&& a)
	{
		// 自我指派檢測
		if (&a == this)
			return *this;
 
		// 釋放我們持有的任何資源
		delete m_ptr;
 
		//將a.m_ptr的所有權轉讓給m_ptr
		m_ptr = a.m_ptr;
		a.m_ptr = nullptr; // 我們將在下面詳細介紹這一行
 
		return *this;
	}
 
	T& operator*() const { return *m_ptr; }
	T* operator->() const { return m_ptr; }
	bool isNull() const { return m_ptr == nullptr; }
};
 
class Resource
{
public:
	Resource() { std::cout << "Resource acquired\n"; }
	~Resource() { std::cout << "Resource destroyed\n"; }
};
 
Auto_ptr4<Resource> generateResource()
{
	Auto_ptr4<Resource> res(new Resource);
	return res; // 此返回值將呼叫移動建構函式
}
 
int main()
{
	Auto_ptr4<Resource> mainres;
	mainres = generateResource(); // 此賦值將呼叫移動賦值
 
	return 0;
}

移動建構函式和移動賦值運算子很簡單。我們只是移動(竊取)源物件的資源,而不是將源物件(a)深度複製到隱式物件中。這涉及淺淺地將源指標複製到隱式物件中,然後將源指標設定為null。

執行時,該程式列印:

Resource acquired
Resource destroyed
這就好多了!

該程式的流程與以前完全相同。但是,該程式不是呼叫複製建構函式和複製賦值運算子,而是呼叫移動建構函式並移動賦值運算子。看得更深一點:

1)在generateResource()內部,建立區域性變數res並使用動態分配的資源進行初始化,從而導致第一個“獲取資​​源”。
2)Res按值返回main()。Res被移動構造成臨時物件,將儲存在res中的動態建立的物件傳遞給臨時物件。我們將在下面討論為什麼會發生這種情況。
3)Res超出範圍。因為res不再管理指標(它被移動到臨時),所以這裡沒有任何有趣的事情發生。
4)將臨時物件移動分配給mainres。這會將儲存在臨時中的動態建立的物件傳輸到mainres。
5)賦值表示式結束,臨時物件超出表示式範圍並被銷燬。但是,因為臨時不再管理指標(它被移動到mainres),所以這裡也沒有任何有趣的事情發生。
6)在main()結束時,mainres超出範圍,並顯示我們的最終“Resource destroyed”。

因此,我們不是兩次複製資源(一次用於複製建構函式,一次用於複製賦值),而是將它傳輸兩次。這是更有效的,因為資源只被構造和銷燬一次而不是三次。

什麼時候移動建構函式和移動賦值?

當定義了這些函式時,將呼叫移動建構函式和移動賦值,並且構造或賦值的引數是r值。最典型的是,該r值將是字面值或臨時值。

在大多數情況下,預設情況下不會提供移動建構函式和移動賦值運算子,除非該類沒有任何已定義的複製建構函式,複製賦值,移動賦值或解構函式。但是,預設的移動建構函式和移動賦值與預設的複製建構函式和複製賦值執行相同的操作(make copy,而不是移動)。

規則:如果你想要一個移動建構函式並移動一個移動的賦值,你需要自己編寫它們。

移動語義背後的關鍵

您現在有足夠的上下文來理解移動語義背後的關鍵。

如果我們構造一個物件或做一個引數是l值的賦值,我們唯一能合理做的就是複製l值。我們不能假設改變l值是安全的,因為它可能會在程式的後期再次使用。如果我們有一個表示式“a = b”,我們就不會合理地期望b以任何方式改變。

但是,如果我們構造一個物件或進行一個引數為r值的賦值,那麼我們就知道r值只是某種臨時物件。我們可以簡單地將其資源(便宜)轉移到我們正在構建或分配的物件上,而不是複製它(這可能很昂貴)。這是安全的,因為臨時的將在表示式的末尾被銷燬,所以我們知道它永遠不會被再次使用!

C ++ 11通過r值引用,使我們能夠在引數為r值與l值時提供不同的行為,使我們能夠更明智,更有效地決定物件的行為方式。

移動函式應始終使兩個物件都處於明確定義的狀態

在上面的示例中,移動建構函式和移動賦值函式都將a.m_ptr設定為nullptr。這似乎是無關緊要的 - 畢竟,如果“a”是一個臨時的r值,如果引數“a”無論如何都會被破壞,為什麼還要做“清理”呢?

答案很簡單:當“a”超出範圍時,將呼叫解構函式,並刪除a.m_ptr。如果在那時,a.m_ptr仍指向與m_ptr相同的物件,則m_ptr將保留為懸空指標。當包含m_ptr的物件最終被使用(或銷燬)時,我們將得到未定義的行為。

另外,在下一課中,我們將看到“a”可以是l值的情況。在這種情況下,“a”不會立即被銷燬,並且可以在其終生結束之前進一步查詢。

可以移動按值返回的自動l值而不是複製

在上面的Auto_ptr4示例的generateResource()函式中,當通過值返回變數res時,即使res是l值,也會移動它而不是複製它。C ++規範有一個特殊規則,即使它們是l值,也可以移動按值返回的自動物件。這是有道理的,因為res無論如何都會在函式結束時被銷燬!我們不妨竊取其資源,而不是製作昂貴且不必要的副本。

雖然編譯器可以移動l值返回,但在某些情況下,它可以通過簡單地完全刪除副本來做得更好(這樣就不需要複製或完全移動)。在這種情況下,既不會呼叫複製建構函式也不會呼叫移動建構函式。

禁用複製

在上面的Auto_ptr4類中,我們留下了複製建構函式和賦值運算子以進行比較。但是在啟用移動的類中,有時需要刪除複製建構函式和複製賦值函式以確保不進行復制。在我們的Auto_ptr類的情況下,我們不想複製我們的模板化物件T - 因為它很昂貴,而且無論什麼類T都不支援複製!

這是Auto_ptr的一個版本,它支援移動語義但不支援複製語義:

#include <iostream>
 
template<class T>
class Auto_ptr5
{
	T* m_ptr;
public:
	Auto_ptr5(T* ptr = nullptr)
		:m_ptr(ptr)
	{
	}
 
	~Auto_ptr5()
	{
		delete m_ptr;
	}
 
	// 複製建構函式 - 不允許複製!
	Auto_ptr5(const Auto_ptr5& a) = delete;
 
	// 移動建構函式
	// 將a.m_ptr的所有權轉讓給m_ptr
	Auto_ptr5(Auto_ptr5&& a)
		: m_ptr(a.m_ptr)
	{
		a.m_ptr = nullptr;
	}
 
	// 複製分配 - 不允許複製!
	Auto_ptr5& operator=(const Auto_ptr5& a) = delete;
 
	// 移動作業
	//將a.m_ptr的所有權轉讓給m_ptr
	Auto_ptr5& operator=(Auto_ptr5&& a)
	{
		// 自我指派檢測
		if (&a == this)
			return *this;
 
		// 釋放我們持有的任何資源
		delete m_ptr;
 
		// 將a.m_ptr的所有權轉讓給m_ptr
		m_ptr = a.m_ptr;
		a.m_ptr = nullptr;
 
		return *this;
	}
 
	T& operator*() const { return *m_ptr; }
	T* operator->() const { return m_ptr; }
	bool isNull() const { return m_ptr == nullptr; }
};

如果您嘗試按值將Auto_ptr5 l值傳遞給函式,編譯器會抱怨初始化複製建構函式引數所需的複製建構函式已被刪除。這很好,因為我們應該通過const l-value reference傳遞Auto_ptr5!

Auto_ptr5(最終)是一個很好的智慧指標類。而且,實際上標準庫包含一個非常類似於此類的類(您應該使用它),名為std :: unique_ptr。我們將在本章後面詳細討論std :: unique_ptr。

另一個例子

讓我們看看另一個使用動態記憶體的類:一個簡單的動態模板化陣列。該類包含深度複製的複製建構函式和複製賦值運算子。

#include <iostream>
 
template <class T>
class DynamicArray
{
private:
	T* m_array;
	int m_length;
 
public:
	DynamicArray(int length)
		: m_array(new T[length]), m_length(length)
	{
	}
 
	~DynamicArray()
	{
		delete[] m_array;
	}
 
	// 複製建構函式
	DynamicArray(const DynamicArray &arr)
		: m_length(arr.m_length)
	{
		m_array = new T[m_length];
		for (int i = 0; i < m_length; ++i)
			m_array[i] = arr.m_array[i];
	}
 
	// 複製作業
	DynamicArray& operator=(const DynamicArray &arr)
	{
		if (&arr == this)
			return *this;
 
		delete[] m_array;
		
		m_length = arr.m_length;
		m_array = new T[m_length];
 
		for (int i = 0; i < m_length; ++i)
			m_array[i] = arr.m_array[i];
 
		return *this;
	}
 
	int getLength() const { return m_length; }
	T& operator[](int index) { return m_array[index]; }
	const T& operator[](int index) const { return m_array[index]; }
 
};

現在讓我們在程式中使用這個類。為了向您展示當我們在堆上分配一百萬個整數時該類如何執行,我們將利用我們在前面中開發的Timer類- 定時程式碼。我們將使用Timer類來計算程式碼執行的速度,並向您展示覆制和移動之間的效能差異。

#include <iostream>
#include <chrono> // std :: chrono函式
 
// 使用上面的DynamicArray類
 
class Timer
{
private:
	// 鍵入別名可以更輕鬆地訪問巢狀型別
	using clock_t = std::chrono::high_resolution_clock;
	using second_t = std::chrono::duration<double, std::ratio<1> >;
	
	std::chrono::time_point<clock_t> m_beg;
 
public:
	Timer() : m_beg(clock_t::now())
	{
	}
	
	void reset()
	{
		m_beg = clock_t::now();
	}
	
	double elapsed() const
	{
		return std::chrono::duration_cast<second_t>(clock_t::now() - m_beg).count();
	}
};
 
// 返回arr的副本,其中所有值都加倍
DynamicArray<int> cloneArrayAndDouble(const DynamicArray<int> &arr)
{
	DynamicArray<int> dbl(arr.getLength());
	for (int i = 0; i < arr.getLength(); ++i)
		dbl[i] = arr[i] * 2;
 
	return dbl;
}
 
int main()
{
	Timer t;
 
	DynamicArray<int> arr(1000000);
 
	for (int i = 0; i < arr.getLength(); i++)
		arr[i] = i;
 
	arr = cloneArrayAndDouble(arr);
 
	std::cout << t.elapsed();
}

在作者的一臺機器上,在釋出模式下,該程式在0.00825559秒內執行。

現在讓我們再次執行相同的程式,用移動建構函式替換複製建構函式和複製賦值以及移動賦值。

template <class T>
class DynamicArray
{
private:
	T* m_array;
	int m_length;
 
public:
	DynamicArray(int length)
		: m_array(new T[length]), m_length(length)
	{
	}
 
	~DynamicArray()
	{
		delete[] m_array;
	}
 
	// 複製建構函式
	DynamicArray(const DynamicArray &arr) = delete;
 
	// 複製作業
	DynamicArray& operator=(const DynamicArray &arr) = delete;
 
	// 移動建構函式
	DynamicArray(DynamicArray &&arr)
		: m_length(arr.m_length), m_array(arr.m_array)
	{
		arr.m_length = 0;
		arr.m_array = nullptr;
	}
 
	// 移動作業
	DynamicArray& operator=(DynamicArray &&arr)
	{
		if (&arr == this)
			return *this;
 
		delete[] m_array;
 
		m_length = arr.m_length;
		m_array = arr.m_array;
		arr.m_length = 0;
		arr.m_array = nullptr;
 
		return *this;
	}
 
	int getLength() const { return m_length; }
	T& operator[](int index) { return m_array[index]; }
	const T& operator[](int index) const { return m_array[index]; }
 
};
 
#include <iostream>
#include <chrono> //std :: chrono函式
 
class Timer
{
private:
	//鍵入別名可以更輕鬆地訪問巢狀型別
	using clock_t = std::chrono::high_resolution_clock;
	using second_t = std::chrono::duration<double, std::ratio<1> >;
 
	std::chrono::time_point<clock_t> m_beg;
 
public:
	Timer() : m_beg(clock_t::now())
	{
	}
 
	void reset()
	{
		m_beg = clock_t::now();
	}
 
	double elapsed() const
	{
		return std::chrono::duration_cast<second_t>(clock_t::now() - m_beg).count();
	}
};
 
// 返回arr的副本,其中所有值都加倍
DynamicArray<int> cloneArrayAndDouble(const DynamicArray<int> &arr)
{
	DynamicArray<int> dbl(arr.getLength());
	for (int i = 0; i < arr.getLength(); ++i)
		dbl[i] = arr[i] * 2;
 
	return dbl;
}
 
int main()
{
	Timer t;
 
	DynamicArray<int> arr(1000000);
 
	for (int i = 0; i < arr.getLength(); i++)
		arr[i] = i;
 
	arr = cloneArrayAndDouble(arr);
 
	std::cout << t.elapsed();
}

在同一臺機器上,該程式在0.0056秒內執行。

比較兩個程式的執行時間,0.0056 / 0.00825559 = 67.8%。移動版本快了近33%!