1. 程式人生 > >C++的雜七雜八:我家的返回值才不可能這麼傲嬌(右值引用和移動語義)

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. 可以直接通過返回值返回一個物件,而不必擔心效率問題
聽起來似乎挺好,如果使用cow能夠解決所有問題的話,就算實現起來麻煩點也是可以接受的。

只可惜對於 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