Move Semantics and Perfect Forwarding in C++11
原文連結:Move Semantics and Perfect Forwarding in C++11
介紹
在本文中,我將探討C++11的move相關的功能特性,重點是如何編寫移動建構函式和移動賦值操作符,以及完美轉發相關的問題。其中的一些功能在[1],[2]兩處有很好的介紹,推薦讀者們先去讀這些材料。在[3]處,給出了非常接近C++11標準的文件。
背景
Move語義主要是設計用來優化物件間的資料傳遞,特別是物件擁有指向分配在堆上的大量記憶體的時候。這樣的物件可以被表示成兩部分:物件本身(shell)和其在堆上建立的部分(heap-allocated contents (HAC)。物件含有指向HAC的指標。比如說,如果我們想拷貝B的內容到A,多數情況下,它們堆上建立部分的大小是不同的。A,B示意圖如下:
把B賦值給A的一般步驟是首先刪除掉A的HAC部分:
然後,A再申請和B同樣大小的空間:
最後再拷貝B的HAC部分的內容到A對應的地方:
如果我們後續需要同時適用A,B兩個物件,這是將B賦給A的正常做法。但是,很多時候,我們後續不需要使用B物件,我們可以交換A,B兩個物件的HAC部分:
對於交換A,B的HAC部分的方法,有兩個主要的優點:
- 我們不再需要在堆上申請新空間,因而可以節省時間和空間;
- 我們不需要拷貝B的內容到A,因而節省了拷貝時間;
顯然,物件的HAC部分的內容越大,優勢越明顯。
右值引用
為了標識哪些物件適合採用移動的方式來構造或拷貝,C++11中引入了右值引用的特性。右值引用通常作用到一個即將釋放的物件(也就是說該物件後續不會被再次使用)。在C++11中,將&&放在型別名稱後表示這是右值引用,例如T型別的右值引用寫作T&&。
右值引用的主要用途是用來指定函式的形參(formal parameters)。如果沒有右值引用,型別T的形參主要通過如下兩種方式:
- T&
- const T&
第一類用來指定可以改變實參的情況;第二類用來不可變實參的情況。
在C++11中,現在有三種選擇:
- T&
- const T&
- T&&
總的來說,我們可以仍然只用前面的兩個選項,但是,為了讓我們的程式碼更加高效,我們有必要使用第三個選項(右值引用)。顯然,如果使用第三個選項,那麼,前兩個選項的使用將會減少。為什麼呢?
這是因為第三個右值引用的選項對應於實參是即將銷燬的臨時變數。回想一下,什麼引數可以對應於右值引用呢?就是那些返回型別的值而不是引用的函式或者表示式。它解決了const T&和T&之間差異的問題:
- const T&指向一般的變數和常量
- T&& 指向表示式(包括函式)的返回值,但不包括返回T&的情形
有時,某個變數(比如x)在某個點之後可能再也不需要使用了,該物件可以被當作一個右值引用,在這種情況下,我們可以用std::move(x)將其轉化為右值。我們將在下面討論這一點。
對於形式引數,既然有上面兩種方案可以選擇:使用右值引用和不使用右值引用的方案。那麼對於傳統的拷貝建構函式和拷貝賦值操作符,現在也有移動構造T(T&& t)和移動賦值操作符=(T&& t).
所以,對於一個具體的類,如果定義了移動建構函式和移動賦值操作符,對於上面所示的交換物件內容的情形,程式可以利用形參值來進行move而不是拷貝。
編寫移動建構函式和移動賦值操作符有一點特別重要:對於被移動後的形參,其內部應該保持是有效的,這樣它才能正常析構。對於這一點,一般有兩種方案來實現:
- 交換大物件的內容,就像上面圖片所示的那樣。
- 設定被移動後的形參中的內容是一個合法的空值,例如將指向HAC部分的指標設為NULL。
對於移動建構函式,例如Foo b = std::move(a);也可以考慮方案1的交換方案,只需要交換前b的預設值是個空值就可以了。
示例
下面我們將從一個簡單的例子開始:一個類,內部定義了一個double型別的陣列,陣列的大小是固定的,但是在賦值時這個大小也可以改變。這樣的例子大家可能看到過很多,但是用這樣的例子可以很好的展示移動構造相關的功能。首先,沒有移動語義的陣列定義是這樣的:
class Array
{
int m_size;
double* m_array;
public:
Array():m_size(0),m_array(nullptr) {} // empty constructor
Array(int n):m_size(n),m_array(new double[n]) {}
Array(const Array& x):m_size(x.m_size),m_array(new double[m_size]) // copy constructor
{
std::copy(x.m_array, x.m_array+x.m_size, m_array);
}
virtual ~Array() // destructor
{
delete [] m_array;
}
auto Swap(Array& y) -> void
{
int n = m_size;
double* v = m_array;
m_size = y.m_size;
m_array = y.m_array;
y.m_size = n;
y.m_array = v;
}
auto operator=(const Array& x) -> Array& // copy assignment
{
if (x.m_size == m_size)
{
std::copy(x.m_array, x.m_array+x.m_size, m_array);
}
else
{
Array y(x);
Swap(y);
}
return *this;
}
auto operator[](int i) -> double&
{
return m_array[i];
}
auto operator[](int i) const -> double
{
return m_array[i];
}
auto size() const ->int { return m_size;}
friend auto operator+(const Array& x, const Array& y) -> Array // adding two vectors
{
int n = x.m_size;
Array z(n);
for (int i = 0; i < n; ++i)
{
z.m_array[i] = x.m_array[i]+y.m_array[i];
}
return z;
}
};
像上面,在賦值操作符中使用swap是非常方便的,也是非常安全的。下面是使用這個類的一個小程式:
int main()
{
Array v(3);
v[0] = 2.5;
v[1] = 3.1;
v[2] = 4.2;
const Array v2(v);
Array v3 = v+(v2+v);
std::cout << "v3:";
for (int i = 0; i < 3; ++i)
{
std::cout << " " << v3[i];
};
std::cout << std::endl;
Array v4(3);
v4[0] = 100.1;
v4[1] = 1000.2;
v4[2] = 10000.3;
v4 = v4 + v3;
std::cout << "v4:";
for (int i = 0; i < 3; ++i)
{
std::cout << " " << v4[i];
};
std::cout << std::endl;
return 0;
}
在這裡可以看到這個程式的完整程式碼。程式輸出:
v3: 7.5 9.3 12.6
v4: 107.6 1009.5 10012.9
接下來,我們新增一些Move語義相關的功能特性。首先,我們應該定義移動建構函式。形參是右值引用的形式:
Array(Array&& x) ...
正如之前提到的,形參對應的內容將會被移動到對應的部分:
Array(Array&& x):m_size(x.m_size),m_array(x.m_array) ...
同時,重要的是形參最後也要獲得正確的值:其內容不能保持不變,因為它會很快析構。因此,在建構函式體內,我們應將形參的內容賦值為空。下面是完整的移動建構函式的完整定義:
Array(Array&& x):m_size(x.m_size),m_array(x.m_array)
{
x.m_size = 0;
x.m_array = nullptr;
}
接下來,我們編寫移動賦值操作符。這很簡單。形參是右值引用,正如之前說的那樣,函式體可以簡單的交換形參的內容和當前物件的內容。
auto operator=(Array&& x) -> Array&
{
Swap(x);
return *this;
}
這就是全部了。
接下來,對於+操作符號,有個問題需要解決:+操作符號也應該利用右值引用的優點。這裡面有兩個引數,每個引數都有兩種選擇:使用右值引用和不使用右值引用。那麼應該有四種+操作符號的過載定義。如果寫完一個,還必須寫剩下的三個才算完整,下面是其中的一個:
friend auto operator+(Array&& x, const Array& y) -> Array
它的實現非常簡單,我們只是簡單的修改形參的內容。 下面是我們的實現:
friend auto operator+(Array&& x, const Array& y) -> Array
{
int n = x.m_size;
for (int i = 0; i < n; ++i)
{
x.m_array[i] += y.m_array[i];
}
return x;
}
我們不需要使用右值引用形參了,我們需要對其進行move。最主要的事情是最後能夠返回正確的內容。如果我們像上面那樣簡單的return x;那麼x部分的內容就會原封不動的返回。儘管x的型別是右值引用,但是變數x本身是左值:我們沒有將x的值move出來,這是右值引用該做的事情。也就是說,如果我們簡單的return x, 將不會有移動操作。正確的做法是將x的內容和目標物件(相加得到的和物件)進行交換,下面是這個+操作符的完整定義:
friend auto operator+(Array&& x, const Array& y) -> Array
{
int n = x.m_size;
for (int i = 0; i < n; ++i)
{
x.m_array[i] += y.m_array[i];
}
return std::move(x);
}
接下來,我們需要寫出另外兩個移動語義相關的+操作符,利用加法交換律,我們交換引數的順序,因而可以重用上面寫的第一個+操作符:
friend auto operator+(Array&& x, Array&& y) -> Array
{
return std::move(y)+x;
}
friend auto operator+(const Array& x, Array&& y) -> Array
{
return std::move(y)+x;
}
就像你看到的那樣,我們定義了4個+操作符函式,可以簡單點,少寫點嗎?可以的,但是首先我們必須學下完美轉發(Perfect Forwarding).
完美轉發:減少成員函式的個數
完美轉發主要是設計用來減小程式設計師編寫程式碼的量的,當然它也有其它的用途。
首先,我們看看最後兩個+操作符的實現。它們有非常多的共同點,唯一的不同是第一個形參有不同的定義:Array&& x 和const Array& x。這兩個+操作符的定義可以採用下面的方式用一個定義替代:
- 定義一個模板 template;
- 將對應的形參定義為T&& x;
- 將用到std::move(x)的地方換成std::forward(x)
上面後兩個+操作符的實現程式碼中沒有用std::move(x),那麼我們也不需要使用std::forward(x),整合後的程式碼如下:
template<class T>
friend auto operator+(T&& x, Array&& y) -> Array
{
return std::move(y)+x;
}
為了將前兩個操作符整合為一個,我們需要對將兩個+操作符的實現程式碼做少許修改,這樣它們的函式體就會看起來相似。
但是首先,我們需要考慮區域性變數的一些額外資訊。對於第一個+操作符的實現,你將會發現它有一個區域性變數,z。根據C++11的語法規則,當返回區域性變數時,區域性變數將會自動採用move語義移動到新的物件。我們不需要顯式的使用std::move(z)來使用move,事實上,只要定義了相關的移動建構函式和移動賦值操作符,move就會發生。
為了讓這兩個+操作符看起來相似,我們在兩個的實現中都建立了一個區域性變數,這樣我們就能將第一個形參的內容通過拷貝或者move的方式轉移到這個區域性變數中,然後將第二個新參的值與這個區域性變數相加,下面是前兩個+操作符修改後的實現版本:
friend auto operator+(const Array& x, const Array& y) -> Array
{
int n = x.m_size;
Array z(x);
for (int i = 0; i < n; ++i)
{
z.m_array[i] += y.m_array[i];
}
return z;
}
friend auto operator+(Array&& x, const Array& y) -> Array
{
int n = x.m_size;
Array z(std::move(x));
for (int i = 0; i < n; ++i)
{
z.m_array[i] += y.m_array[i];
}
return z;
}
這就是完美轉發需要的。如果我們採用之前完美轉發的三條原則,我們可以將上面的兩個+操作符的實現重寫為下面的樣子:
template<class T>
friend auto operator+(T&& x, const Array& y) -> Array
{
int n = x.m_size;
Array z(std::forward<T>(x));
for (int i = 0; i < n; ++i)
{
z.m_array[i] += y.m_array[i];
}
return z;
}
也許你會認為我們的一些程式碼的改動會降低程式碼的執行效率。如果考慮到效率問題,對於前面兩個+操作符的實現,我們最好不做修改。
測試
為了驗證move功能的優點,我們在下面的程式碼中添加了額外的輸出語句來看那個成員函式被呼叫了,這對我們測試程式碼的效率有幫助,下面是測試程式碼,也可以在這裡執行它:
//MOVE SEMANTICS REVISED
#include <iostream>
#include <string>
#include <algorithm>
#include <cmath>
#define MOVE_FUNCTIONALITY
int count_copies = 0;
int count_allocations = 0;
int elem_access = 0;
class Array
{
int m_size;
double *m_array;
public:
Array():m_size(0),m_array(nullptr) {}
Array(int n):m_size(n),m_array(new double[n])
{
count_allocations += n;
}
Array(const Array& x):m_size(x.m_size),m_array(new double[m_size])
{
count_allocations += m_size;
count_copies += m_size;
std::copy(x.m_array, x.m_array+x.m_size, m_array);
}
#ifdef MOVE_FUNCTIONALITY
Array(Array&& x):m_size(x.m_size),m_array(x.m_array)
{
x.m_size = 0; // clearing the contents of x
x.m_array = nullptr;
}
#endif
virtual ~Array()
{
delete [] m_array;
}
auto Swap(Array& y) -> void
{
int n = m_size;
double* v = m_array;
m_size = y.m_size;
m_array = y.m_array;
y.m_size = n;
y.m_array = v;
}
#ifdef MOVE_FUNCTIONALITY
auto operator=(Array&& x) -> Array&
{
Swap(x);
return *this;
}
#endif
auto operator=(const Array& x) -> Array&
{
if (x.m_size == m_size)
{
count_copies += m_size;
std::copy(x.m_array, x.m_array+x.m_size, m_array);
}
else
{
Array y(x);
Swap(y);
}
return *this;
}
auto operator[](int i) -> double&
{
elem_access++;
return m_array[i];
}
auto operator[](int i) const -> double
{
elem_access++;
return m_array[i];
}
auto size() const ->int { return m_size;}
#ifdef MOVE_FUNCTIONALITY
template<class T>
friend auto operator+(T&& x, const Array& y) -> Array
{
int n = x.m_size;
Array z(std::forward<T>(x));
for (int i = 0; i < n; ++i)
{
elem_access+=2;
z.m_array[i] += y.m_array[i];
}
return z;
}
template<class T>
friend auto operator+(T&& x, Array&& y) -> Array
{
return std::move(y)+x;
}
#else
friend auto operator+(const Array& x, const Array& y) -> Array
{
int n = x.m_size;
Array z(n);
for (int i = 0; i < n; ++i)
{
elem_access += 3;
z.m_array[i] = x.m_array[i] + y.m_array[i];
}
return z;
}
#endif
void print(const std::string& title)
{
std::cout << title;
for (int i = 0; i < m_size; ++i)
{
elem_access++;
std::cout << " " << m_array[i];
};
std::cout << std::endl;
}
};
int main()
{
const int m = 100;
const int n = 50;
{
Array v(m);
for (int i = 0; i < m; ++i) v[i] = sin((double)i);
const Array v2(v);
Array v3 = v+(v2+v);
v3.print("v3:");
Array v4(m);
for (int i = 0; i < m; ++i) v4[i] = cos((double)i);
v4 = v4 + v3;
v4.print("v4:");
Array v5(n);
for (int i = 0; i < n; ++i) v5[i] = cos((double)i);
Array v6(n);
for (int i = 0; i < n; ++i) v6[i] = tan((double)i);
Array v7(n);
for (int i = 0; i < n; ++i) v7[i] = exp(0.001*(double)i);
Array v8(n);
for (int i = 0; i < n; ++i) v8[i] = 1/(1+0.001*(double)i);
v4 = (v5+v6)+(v7+v8);
v4.print("v4 new:");
}
std::cout << "total allocations (elements):" << count_allocations << std::endl;
int total_elem_access = count_copies*2 + elem_access;
std::cout << "total elem access (elements):" << total_elem_access << std::endl;
return 0;
}
當我做過一些測試後,我意識到不能僅僅考慮拷貝操作的次數,有必要將元素訪問的次數也考慮進來,因為一個拷貝對應於兩次的元素訪問:源元素和目的元素的訪問。下面是測試結果:
Counter | Copy | Move |
---|---|---|
Element Allocations | 1000 | 800 |
Element Access | 2500 | 2350 |
拷貝優化(Copy Elision)
在某些情況下,C++11標準中允許省略拷貝構造或者移動建構函式,即使這些構造或解構函式有副作用,這個特性被稱為拷貝優化(省略不必要的拷貝)。拷貝優化被允許發生在下面幾種情形中:
- 當函式的返回值是non-volatile的區域性變數並且其型別和函式的返回值型別相同(在這裡,型別比較時忽略const等型別修飾符),這通常叫作返回值優化(RVO);
- 在一個throw語句中,當某個操作符是non-volatile的區域性變數並且其作用域不超過其最相鄰的try語句塊時;
- 當一個臨時物件(沒有被任何引用繫結)將要被拷貝或者移動到和它同類型的類物件時(和前面一樣,型別比較時忽略const等型別修飾符);
- 在異常語句處理處,通常是try-catch語句塊,當catch某個引數時,如果catch的引數和throw處丟擲的引數型別相同;
在上面所列出的這些情況中, 物件可能會直接構造或者移動到目的物件中,不僅僅是拷貝,其它的一些操作也可能會省略。在所有的這些情況中,在拷貝優化發生前,通常先考慮右值引用(move),然後才會考慮左值引用(copy)。
下面是一個使用了前面討論過的Array類的示例程式:
const Array f()
{
Array z(2);
z[0] = 2.1;
z[1] = 33.2;
return z;
}
Array f1()
{
Array z(2);
z[0] = 2.1;
z[1] = 33.2;
return z;
}
void g(Array&& a)
{
a.print("g(Array&&)");
}
void g(const Array& a)
{
a.print("g(const Array&)");
}
void pf(Array a)
{
a.print("pf(Array)");
}
int main()
{
{
Array p(f());
p.print("p");
g(f());
g(f1());
pf(f());
}
std::cout << "total allocations (elements):" << count_allocations << std::endl;
int total_elem_access = count_copies*2 + elem_access;
std::cout << "total elem access (elements):" << total_elem_access << std::endl;
return 0;
}
在定義了拷貝建構函式的情況下,程式輸出如下(可以在這裡允許該程式):
p 2.1 33.2
g(const Array&) 2.1 33.2
g(Array&&) 2.1 33.2
pf(Array) 2.1 33.2
total allocations (elements):8
total elem access (elements):16
首先,看一下函式呼叫,當用f()或者f1()時,不同選擇的過載函式:g(f())選擇g(const Array&),g(f1())選擇g(Array&&)。但是這不會阻止編譯器選擇使用移動操作或者省略使用這些操作。
為了追蹤哪個建構函式被呼叫了,你可以在函式裡面額外新增一些語句來列印額外的資訊。在刪掉#define MOVE_FUNCTIONALITY這一行後,執行結果依然是相同的。
從程式的角度來看,即使你沒有寫帶有右值相關的引數的函式,像移動建構函式或者移動操作符,你仍然享有move操作帶來的好處:這裡拷貝優化就相當於move。通常的方法是:
- 定義拷貝或者移動建構函式
- 定義可以利用移動語義優勢的相關函式或操作符(比如Array class中的+操作符)
在其餘的情況中,當沒有編譯器優化的時候,要像往常一樣,編寫帶有右值引用 (T&& or const T&&)引數的函式。
- Scott Meyers. “Move Semantics, Rvalue References, and Perfect Forwarding”. Notes in PDF. http://www.aristeia.com/TalkNotes/ACCU2011_MoveSemantics.pdf
- Scott Meyers. “Move Semantics, Rvalue References, and Perfect Forwarding”. Presentation.Scott Meyers. “Move Semantics, Rvalue References, and Perfect Forwarding”. Presentation. http://skillsmatter.com/podcast/home/move-semanticsperfect-forwarding-and-rvalue-references
- C++ Working Draft,C++ Working Draft, http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2012/n3376.pdf