可拷貝和可移動的概念
在面向對象中,有的類是可以拷貝的,例如車、房等他們的屬性是可以復制的,可以調用拷貝構造函數,有點類的對象則是獨一無二的,或者類的資源是獨一無二的,比如 IO 、 std::unique_ptr等,他們不可以復制,但是可以把資源交出所有權給新的對象,稱為可以移動的。
C++11最重要的一個改進之一就是引入了move語義,這樣在一些對象的構造時可以獲取到已有的資源(如內存)而不需要通過拷貝,申請新的內存,這樣移動而非拷貝將會大幅度提升性能。例如有些右值即將消亡析構,這個時候我們用移動構造函數可以接管他們的資源。
移動構造函數和移動賦值函數
考慮這樣一個類A,里面的成員i 具有一個500的堆數組
#include <iostream>
#include <cstring>
using namespace std;
class A{
public:
A():i(new int[500]){
cout<<"class A construct!"<<endl;
}
A(const A &a):i(new int[500]){
memcpy(i, a.i,500*sizeof(int));
cout<<"class A copy!"<<endl;
}
~A(){
delete []i;
cout<<"class A destruct!"<<endl;
}
private:
int *i;
};
A get_A_value(){
return A();
}
void pass_A_by_value(A a){
}
int main(){
A a = get_A_value();
return 0;
}
編譯時為了看到臨時對象拷貝我們關閉了編譯器省略復制構造的優化
g++ main.cpp -o main.exe -fno-elide-constructors -std=c++11
運行時可以看到
class A construct!
class A copy!
class A destruct!
class A copy!
class A destruct!
class A destruct!
發生了一次構造和兩次拷貝!在每次拷貝中數組都得重新申請內存,而被拷貝后的對象很快就會析構,這無疑是一種浪費。
我們在類中加上移動構造函數:
...
#include <iostream>
A(A &&a)noexcept
:i(a.i)
{
a.i = nullptr;
cout<< "class A move"<<endl;
}
...
然后編譯、執行;可以看到輸出為
class A construct!
class A move
class A destruct!
class A move
class A destruct!
class A destruct!
原先的兩次構造變成了兩次移動!!在移動構造函數中,我們做了什么呢,我們只是獲取了被移動對象的資源(這里是內存)的所有權,同時把被移動對象的成員指針置為空(以避免移動過來的內存被析構),這個過程中沒有新內存的申請和分配,在大量對象的系統中,移動構造相對與拷貝構造可以顯著提高性能!這里noexcept
告訴編譯器這里不會拋出異常,從而讓編譯器省一些操作(這個也是保證了STL容器在重新分配內存的時候(知道是noexpect)而使用移動構造而不是拷貝構造函數),通常移動構造都不會拋出異常的。
@note: 這里僅僅為了演示,用 -fno-elide-constructions 關閉了g++編譯器會省略函數返回值時臨時對象的拷貝的優化。雖然編譯器很多時候可以為我們進行優化,有些時候編譯器優化不了的還是需要了解和運用移動語義的。
除了移動構造函數,移動賦值運算符應該一並給寫出來。
A &operator =(A &&rhs) noexcept{
// check self assignment
if(this != &rhs){
delete []i;
i = rhs.i;
rhs.i = nullptr;
}
cout<< "class A move and assignment"<<std::endl;
return *this;
}
小結移動構造和移動賦值
小結一下移動構造函數和移動賦值函數的書寫要訣:
- 偷梁換柱直接“淺拷貝”右值引用的對象的成員;
- 需要把原先右值引用的指針成員置為 nullptr,以避免右值在析構的時候把我們淺拷貝的資源給釋放了;
- 移動構造函數需要先檢查一下是否是自賦值,然后才能先delet自己的成員內存再淺拷貝右值的成員,始終記住第2條。
關於構造函數這部分有很多best practice :搜索“三五法則”、 “copy and swap”、 "move and swap" 了解詳情
std::move()
std::move(lvalue) 的作用就是把一個左值轉換為右值。關於左右值的含義我們上一篇博客C++11的右值引用進行過闡述。
int lv = 4;
int &lr = lv;// 正確,lr是l的左值引用
int &&rr = lv; // 錯誤,不可以把右值引用綁定到一個左值
如果使用std::move 函數
int &&rr = std::move(lv); // 正確,把左值轉換為右值
可以看到 std::move的作用是把左值轉換為右值的。
讓我們看一看 std::move 的源碼實現:
// FUNCTION TEMPLATE move
template <class _Ty>
_NODISCARD constexpr remove_reference_t<_Ty>&& move(_Ty&& _Arg) noexcept { // forward _Arg as movable
return static_cast<remove_reference_t<_Ty>&&>(_Arg);
}
可以看到std::move 是一個模板函數,通過remove_\reference_t獲得模板參數的原本類型,然后把值轉換為該類型的右值。用C++大師 Scott Meyers 的在《Effective Modern C++》中的話說, std::move 是個cast ,not a move.
值得注意的是: 使用move意味着,把一個左值轉換為右值,原先的值不應該繼續再使用(承諾即將廢棄)
使用 std::move 實現一個高效的 swap 函數
我們可以使用 move語義實現一個 交換操作,swap;
在不使用 Move 語義的情況下
swap(A &a1, A &a2){
A tmp(a1); // 拷貝構造函數一次,涉及大量數據的拷貝
a1 = a2; // 拷貝賦值函數調用,涉及大量數據的拷貝
a2 = tmp; // 拷貝賦值函數調用,涉及大量數據的拷貝
}
如果使用 Move語義,即加上移動構造函數和移動賦值函數:
void swap_A(A &a1, A &a2){
A tmp(std::move(a1)); // a1 轉為右值,移動構造函數調用,低成本
a1 = std::move(a2); // a2 轉為右值,移動賦值函數調用,低成本
a2 = std::move(tmp); // tmp 轉為右值移動給a2
}
可以看到move語義確實可以提高性能,事實上, move語義廣泛地用於標准庫的容器中。C++11標准庫
說到了 swap, 那就不得不說一下啊 move-and-swap 技術了
Move and swap 技巧
看下面一段代碼,實現了一個 unique_ptr ,和標准的std::unqiue_ptr的含義一致,智能指針的一種。
template<typename T>
class unique_ptr
{
T* ptr;
public:
explicit unique_ptr(T* p = nullptr)
{
ptr = p;
}
~unique_ptr()
{
delete ptr;
}
// move constructor
unique_ptr(unique_ptr&& source) // note the rvalue reference
{
ptr = source.ptr;
source.ptr = nullptr;
}
/* unique_ptr& operator=(unique_ptr&& source) // 這里使用右值引用
{
if (this != &source) // beware of self-assignment
{
delete ptr; // release the old resource
ptr = source.ptr; // acquire the new resource
source.ptr = nullptr;
}
return *this;
} */
// move and swap idiom replace the move assignment operator
unique_ptr& operator=(unique_ptr rhs) // 這里不用引用,會調用移動構造函數
{
std::swap(ptr, rhs.ptr);
// std::swap(*this,rhs) // is also ok
return *this;
}
T* operator->() const
{
return ptr;
}
T& operator*() const
{
return *ptr;
}
};
在這里如果要按照常規辦法寫移動賦值函數,函數體內需要寫一堆檢查自賦值等冗長的代碼。使用 move-and-swap語義,只用簡短的兩行就可以寫出來。 在移動賦值函數中 source 是個局部對象,這樣在形參傳遞過來的時候必須要調用拷貝構造函數(這里沒有實現則不可調用)或者移動構造函數
,(事實上僅限右值可以傳進來了)。然后 std::swap 負責把原先的資源和source 進行交換,完成了移動賦值。這樣寫節省了很多代碼,很優雅。
move-and-swap 和 copy-and-swap 見我另外一篇博客。
參考
以下兩篇是我查閱資料的時候在 stackoverflow 上發現覺得寫得非常到位的: