C++的雜七雜八:我家的返回值才不可能這麼傲嬌(右值引用和移動語義)
說這裡,返回值其實應該是一個很簡單的話題。當需要通過函式傳遞一個值出去的時候,使用返回值不是理所當然的嘛,比如說,像下面這樣:
int add(int a, int b)
{
return a + b;
}
int main(int argc, char* argv[])
{
int i = add(1, 2);
std::cout << i << std::endl;
return 0;
}
直接通過返回值將函式的計算結果返回出去,不論是函式還是呼叫者都會身心愉悅,因為這是最自然的使用方法了。但是在C++裡,通過函式返回值返回處理結果往往是一種奢侈的行為。
一、使用返回值的問題
比如說,這種寫法:int main(int argc, char* argv[])
{
std::string ss;
std::string s1("Hello"), s2("-"), s3("World"), s4("!");
ss = s1 + s2 + s3 + s4;
std::cout << ss << std::endl;
return 0;
}
我相信有經驗的C++程式設計師看到了都會皺眉頭,良好的做法應當是使用+=來代替之:
int main(int argc, char* argv[]) { std::string ss; std::string s1("Hello"), s2("-"), s3("World"), s4("!"); ss += s1 += s2 += s3 += s4; std::cout << ss << std::endl; return 0; }
原因很簡單,+和+=的操作符過載的實現一般而言是像這樣的:
operator char*(void) const
{
return str_;
}
Str& operator+=(const char* str)
{
if (str) strcat_s(str_, 1024, str);
return (*this);
}
friend Str operator+(const Str& x, const Str& y)
{
return Str(x) += y;
}
注意到上面程式碼的第14行,由於+操作符不能修改任何一個引數,所以必須構建一個臨時變數Str(x),並且Str(x)在把值傳遞出去之後,自身立馬銷燬了。外面負責接收的變數只能得到並複製一遍Str(x),於是一個簡單的返回值就造成了兩次x的拷貝。當像上文“ss = s1 + s2 + s3 + s4”這樣連加的時候,拷貝就會像擊鼓傳花一樣,在每一次+呼叫處發生。
我們不可能把+的返回值像+=一樣用引用或指標來代替,因為Str(x)在出了operator+的作用域後自然就被銷燬了,外部得到的引用將是一個懸空引用,無法通過它拿到處理後的資料。
為了說明問題,我們可以寫一個簡單的例子來看看這樣賦值到底會有多大的損耗:
class Str
{
protected:
char* str_;
public:
Str(void) // 預設的建構函式,什麼也不做
: str_(nullptr)
{}
Str(const char* rhs) // 普通賦值建構函式
: str_(nullptr)
{
if (!rhs) return;
str_ = new char[1024];
strcpy_s(str_, 1024, rhs);
std::cout << "Str constructor " << str_ << std::endl;
}
Str(const Str& rhs) // 拷貝建構函式
: str_(nullptr)
{
if (!rhs) return;
str_ = new char[1024];
strcpy_s(str_, 1024, rhs.str_);
std::cout << "Str copy constructor " << str_ << std::endl;
}
~Str() // 解構函式
{
if (!str_) return;
std::cout << "Str destructor " << str_ << std::endl;
delete [] str_;
}
const Str& operator=(Str rhs) // 賦值操作符過載
{
rhs.swap(*this); // 使用copy-and-swap慣用法獲得資料
return (*this); // 避免重複撰寫operator=
}
void swap(Str& rhs) // 交換演算法
{
std::swap(str_, rhs.str_);
}
operator char*(void) const
{
return str_;
}
Str& operator+=(const char* rhs)
{
if (rhs) strcat_s(str_, 1024, rhs);
return (*this);
}
friend Str operator+(const Str& x, const Str& y)
{
return Str(x) += y;
}
};
int main(int argc, char* argv[])
{
Str ss;
Str s1("Hello"), s2("-"), s3("World"), s4("!");
std::cout << std::endl;
ss = s1 + s2 + s3 + s4;
std::cout << std::endl;
std::cout << ss << std::endl;
std::cout << std::endl;
return 0;
}
這是一個簡單的Str類,包裝了一個char*,並限制字串長度為1024。
程式執行之後,我們得到如下列印資訊:
Str
copy constructor Hello
Str copy constructor Hello-
Str destructor Hello-
Str copy constructor Hello-
Str copy constructor Hello-World
Str destructor Hello-World
Str copy constructor Hello-World
Str copy constructor Hello-World!
Str destructor Hello-World!
Str destructor Hello-World
Str destructor Hello-
這個。。太漂亮了。。連續6次拷貝構造,並且最終這些臨時生成的字串統統炸鞭炮一樣噼裡啪啦被銷燬掉了。
一次拷貝的工作是new一個1024的大記憶體塊,再來一次strcpy。連續的構造-拷貝-析構,對效能會有相當大的影響。
所以儘量選擇+=其實是不得已而為之的事情。。
同樣的道理,我們也很少寫
Str intToStr(int i) ,取而代之是 void intToStr(int i, Str& s)。
為了避免返回值的效能問題,C++er們不得不犧牲掉程式碼的優雅,用蹩腳的引數來解決。
二、一些解決方案
因噎廢食不是辦法,作為一個程式設計師(不僅僅是C++程式設計師),追求效率是無可厚非的,但是不能容忍語言限制我們的生產力,禁錮我們的思想。
一個解決方法是使用cow(copy-on-write)。我們可以不需要每次都拷貝資料,只有當資料發生改寫的時候,才會出現拷貝操作。
體現在程式碼上,上文中Str的拷貝建構函式內,就不再直接使用strcpy了,取而代之的是一行短短的指標複製(注意不是內容複製)的淺拷貝。直到+=這種會改變自身內容的操作時,Str內部才會再建立一份記憶體,並把現有的資料拷貝一次。
這樣做的代價是:
- 1. 我們需要使用“引用計數”,來標記當前的記憶體塊被多少個Str共有,否則的話一個Str在析構時刪除記憶體,所有與之相關的其他Str類內部的指標全部都會成為野指標。
- 2. 我們需要處理所有有cow的觸發機制,也就是說,必須區分什麼時候Str正在被寫。
- 1. 可以簡單的通過引數型別,而不是引數引用或指標來傳遞一個物件
- 2. 可以直接通過返回值返回一個物件,而不必擔心效率問題
只可惜對於
Str intToStr(int i) 的情況,cow確實有足夠高的效能,但是我們一開始提出來的“ss = s1 + s2 + s3 + s4”,cow卻幫不了太多忙:雖然Str(x)時,生成臨時物件的拷貝不存在了,但馬上執行的賦值改寫操作不得不讓記憶體被複制一遍,傳遞到下一層情況也不會有任何改變,連續賦值導致連續複製,在這種情況下原先的效能問題依然存在。
問題的本質其實不在於怎樣複製,而在於不要複製。實際上對於返回值這種情況,返回的變數是一個臨時變數,它馬上就面臨被銷燬的命運,因此根本不需要把它的內容拷貝一遍,直接拿過來用不就好了。假如能這樣的話,函式就能把自己的處理結果直接交給外面,而不是用一個坑爹的臨時變數中轉一道。
拿來用的過程其實很簡單,一個swap操作就ok了。這樣的話,只要能在Str裡增加一個“移動建構函式”,這個世界就會變得很美好:
Str(... str) // 移動建構函式
: str_(nullptr)
{
std::swap(*this, str);
std::cout << "Str move constructor " << str_ << std::endl;
}
寫到這裡,真正頭疼的地方來了:程式碼裡...的位置應該填什麼?
在C++中,一個表示式有左值(lvalue)和右值(rvalue)之分,即是否可定址之分。
一般的變數都屬於左值,而像(1 + 2)這種表示式的結果,則是不可定址的,屬於右值。
可改變的左值,被 & 繫結;不可定址的值自然無法改變(或者說,不應該被改變),則會被 const & 繫結。
函式的返回值是一個什麼值?從上面來看,它應該是一個右值。那麼是否定義
const & 就可以了呢?
在C++03裡:
當一個變數為一般左值時,它會走到 & ;
所有其它情況(const左值、右值)都會被繫結到 const & 。
這個結果非常不好,我們無法準確控制const左值是拷貝還是移動。
Andrei
Alexandrescu,《Modern C++ Design》的作者,2003年曾在Generic上發過一篇著名的文章《Move Constructors》,裡面給出了他對於這個問題的解決方案:Mojo (Move of Joint Objects),核心在於從語義上區分出“臨時值”的概念來。
Mojo利用
& 繫結所有的左值;不定義 const &(因為它會把const左值和右值都吃掉),而是定義了一個constant(其內部儲存了const的物件指標)作為傳遞const左值的手段;最後,他定義了temporary,來處理並傳遞右值。
這兩個定義看起來像這樣:
namespace mojo
{
template < class T >
class constant
{
const T* data_;
public:
explicit constant(const T& obj) : data_(&obj)
{
}
const T& get() const
{
return *data_;
}
};
template < class T >
class temporary : private constant< T >
{
public:
explicit temporary(T& obj) : contant< T >(obj)
{
}
T& get() const
{
return const_cast< T& >(constant< T >::get());
}
};
}
使用Mojo的類(比如Str)需要定義operator constant<Str>() const、operator temporary<Str>();
然後,對於需要使用Mojo傳遞引數的函式定義三個版本的過載:func(Str&)、func(constant&)、func(temporary&),分別用於接收左值、常量左值和右值。
有了這些之後,Mojo就可以完美的判斷一般引數的左值/右值了。但僅僅這樣,還無法正確處理返回值。因為想要返回一個物件,就需要把Mojo應用到拷貝建構函式上。這時事情變得複雜起來,因為拷貝建構函式要求使用
const & 。
Mojo的對策是再建立一個新的型別:fnresult,來處理返回值:
namespace mojo
{
templat