程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> C語言 >> C++ >> 關於C++ >> C++ primer讀書筆記第13章:拷貝控制

C++ primer讀書筆記第13章:拷貝控制

編輯:關於C++

  當定義一個類時,我們顯式或者隱式地指定此類型的對象拷貝、移動、賦值和銷毀時做什麼,一個類通過定義五個特殊的成員函數來控制這些操作,包括:拷貝構造函數、拷貝賦值運算符、移動構造函數、移動賦值運算符、析構函數。我們稱這些操作為拷貝控制操作。通常實現拷貝控制操作最困難的地方是首先認識到什麼時候需要定義這些操作。

13.1拷貝、賦值與銷毀

13.1.1拷貝構造函數

  如果一個構造函數的第一個參數是自身類類型的引用,且任何額外參數都有默認值,則此拷貝函數是拷貝構造函數。

    class Foo
    {
    public:
        Foo(const Foo&);            //拷貝構造函數
        Foo(const Foo&, int i = 0); //也是拷貝構造函數,但是要記住,自身類型的引用必須是構造函數的第一個參數,且其他參數要有默認值
    }

  拷貝構造函數的第一個參數必須是一個引用類型,而且這個參數幾乎總是一個const引用。另外因為拷貝構造函數在很多情況下都會被隱式使用,因此,拷貝構造函數通常不應該是explicit的。如果我們將拷貝構造函數聲明為explicit,那麼我們必須顯示調用此拷貝函數。

class Test
{
public:
    Test() = default;
    explicit Test(const Test&t){ _a = t._a; }
private:
    int _a = 10;
};

int _tmain(int argc, _TCHAR* argv[])
{
    Test t1;
    Test t2 = t1;      //錯誤,拷貝構造函數為explicit,必須顯示調用
    Test t3(t1);       //顯示調用explicit拷貝構造函數,正確
}

合成拷貝構造函數

  如果我們沒有為類定義一個拷貝構造函數,那麼編譯器會為我們自動合成一個。與合成默認構造函數不同的是,即使我們定義了其他構造函數,但是沒有定義拷貝構造函數,那麼編譯器也會為我們合成一個默認拷貝構造函數。
合成的默認拷貝構造函數從給定對象中依次將沒給非static成員拷貝到正在創建的對象中,其進行的只是簡單的值拷貝。

拷貝初始化

  要注意區分直接初始化與拷貝初始化。直接初始化實際上是要求編譯器使用普通的函數匹配來選擇最匹配的構造函數創建一個對象,而拷貝初始化則是要求編譯器將右側運算對象拷貝到正在創建的對象中,如果需要的化還要進行類型轉換。所以拷貝初始化相較於直接初始化多了一步拷貝的工作,有時對於一個自定義類型,這將產生很大的額外開銷。

string dots("zhang");       //直接初始化
string dots = "zhang"       //拷貝初始化

  拷貝初始化通常發生在一下情況:

將一個對象作為實參傳遞給一個非引用類型的形參時 從一個函數返回一個非引用類型的對象時

用花括號初始化一個數組中的元素或一個聚合類中的成員時

  現在我們可以解釋為什麼拷貝構造函數的參數必須是引用類型了,因為如果形參類型不是引用類型,那麼調用將永遠不會成功——為了調用拷貝構造函數,我們必須拷貝它的實參,而為了拷貝實參,我們又必須調用它的拷貝構造函數,如此無限循環。
  注意在標准庫中,insert或者push成員時,容器會對成員進行拷貝初始化,而用emplace函數插入元素則會進行直接初始化,所以當元素類型是自定義類類型時,在效率上後者優於前者。
  在拷貝初始化過程中,編譯器可以跳過拷貝構造函數,直接創建對象,這是編譯器的一種優化手段,但是我們必須保證在此時拷貝構造函數是存在而且可訪問的。

13.1.2 拷貝賦值運算符

  拷貝賦值運算符其實就是對=運算符的重載函數。如果一個類未定義自己的拷貝賦值運算符,那麼編譯器也會合成一個默認的拷貝賦值運算符,其作用與默認拷貝構造函數函數類似,都是依次拷貝右側對象的每個非static成員給左側對象的相應成員,不同的是它不是用在構造對象的時候。
賦值運算符通常返回一個指向左側對象的引用,其形參通常是該類型的const引用。另外要注意的是,標准庫通常要求其元素類型要具有一個拷貝賦值運算符。

class Test
{
public:
    Test() = default;        //默認構造函數
    Test(const Test&t)       //拷貝構造函數
    {
        _a = t._a; 
    }
    Test &operator=(const Test &t)       //拷貝賦值運算符,用*this返回引用,形參為const引用
    {
        _a = t._a;
        return *this;
    }

private:
    int _a = 10;
};

int _tmain(int argc, _TCHAR* argv[])
{
    Test t1;                        
    Test t2 = t1;          //此處進行的是拷貝初始化,調用的是拷貝構造函數
    t1 = t2;               //此處進行的是賦值運算,調用的是拷貝賦值運算符,注意與上面的區別
}

要特別注意拷貝賦值運算符與拷貝構造函數的區別

13.1.3析構函數

  析構函數進行與構造函數相反的操作:構造函數初始化對象的非static數據成員,析構函數釋放函數對象使用的資源,並且銷毀對象的非static數據成員。
  析構函數是類的成員函數,其沒有返回值,也不接受任何參數,所以其不可以被重載,對一個給定類,只會有一個唯一的析構函數。
  同樣,當一個類未定義自己的析構函數時,編譯器會為它合成一個默認析構函數,合成析構函數的函數體為空。
下列情況下,會自動調用類的析構函數:

變量在離開其作用域被銷毀時 當一個對象被銷毀時,其成員被銷毀 容器被銷毀時,其元素被銷毀 對於一個動態分配的對象,當對指向它的指針使用delete運算時,對象會被銷毀 對於臨時對象,當創建它的完整表達式結束時被銷毀

一個要十分注意的點是,析構函數並不直接銷毀成員,在整個對象的銷毀過程中,析構函數是作為銷毀步驟之外的另一部分進行的。

13.1.4 三/五法則

  拷貝構造函數,拷貝賦值運算符、析構函數、(新標准中還有移動構造函數、移動賦值運算符)統稱為一個類的拷貝控制函數。
  我們決定一個類是否需要定義自己版本的拷貝控制函數時,有一下兩個基本原則:

如果一個類需要自定義析構函數,那麼我們幾乎可以肯定它也需要一個拷貝構造函數和一個拷貝賦值運算符。 如果一個類需要一個拷貝構造函數,幾乎可以肯定它也需要一個拷貝賦值運算符。

這其中其實主要是為了內存控制。

class Test
{
public:
    Test(){ _pa = new int(10);} //構造函數中申請了堆內存

    ~Test(){delete _pa;}  //必須自定義析構函數以釋放動態分配的內存,否則會造成內存洩露

    //默認拷貝構造函數的行為
    //Test(const Test &t)
    //{
    //  _pa =t._pa;
    //}
    //這樣會導致兩個對象的指針成員指向同一塊內存,然後在析構的時候會delete此指針兩次,造成錯誤,所以必須自定義拷貝構造函數和拷貝賦值運算符

    Test(const Test &t)     //自定義拷貝構造函數
    {
        _pa = new int();
        *_pa = *(t._pa);
    }

    Test &operator= (const Test &t)   //自定義拷貝賦值運算符
    {
        *_pa = *(t._pa);
        return *this;
    }

private:
    int *_pa = nullptr;
};

13.1.5使用=default

  在c++11中,我們可以通過把函數聲明為default來把函數聲明為使用系統默認合成的版本。當然我們只能對具有合成版本的成員函數使用=default。=defalut可以出現聲明或者定義處。要特別注意的一點是,當我們在類內用=default修飾成員的聲明時,合成的函數將隱式聲明為內聯函數

class Test
{
public:
    Test() = default;
    ~Test() = default;
    Test(const Test &) = default;
    Test &operator= (const Test &t) = default;

private:
    int *_pa = nullptr;
};

13.1.6阻止拷貝

  在c++11中,我們可以通過將拷貝控制函數聲明為刪除的函數來阻止拷貝。刪除的函數是這樣的一種函數:我們雖然聲明了它,但是我們不可以以任何方式使用它。我們通過=delete來聲明刪除的函數。
  與=default不同的是,=delete必須出現在函數第一次聲明的時候,且不可以出現在定義中。另外,我們可以指定任何函數為刪除的函數。
  
  關於拷貝控制有以下幾個原則:

值得注意的是,我們不能刪除析構函數,因為這樣的話對象將無法正常銷毀。 如果一個類有數據成員不能默認拷貝、構造、賦值、銷毀,那麼該類對應的成員函數將被默認定義為刪除的。 如果一個類有const成員,則它不能使用合成的拷貝賦值運算符,因為將一個新值賦值給const對象是非法的。

在c++11之前,阻止拷貝控制的方法是將相應的成員函數聲明為private並且不定義它。但是c++11之後可以直接使用delete關鍵字進行聲明。

13.2拷貝控制和資源管理

  通常,管理類外資源的類必須自定義拷貝控制成員。
  在定義拷貝控制成員時,通常有兩種選擇:使類的行為看起來像一個指針或者是類的行為看起來像一個值。

類的行為像一個值,意味著每個對象都有自己的狀態。當我們拷貝一個對象時,副本和原對象應該是完全獨立的。改變副本不會影響原對象的值,反之亦然。比如標准庫中的string類。 類的行為像一個指針,則對象之間應該應該共享狀態,副本和原對象應該使用相同的底層數據,改變副本也會改變相應的原對象,反之亦然。這種對象的實現多通過引用計數,比如標准庫中的shared_ptr類。

13.2.1行為像值的類

  在定義行為像值的類時,要特別注意拷貝賦值運算符的定義,其必須注意一下兩點:

如果一個對象賦予它自身,賦值運算符必須可以正常工作 大多數賦值運算符組合了析構函數和拷貝構造函數的工作 如果有可能,我們編寫的賦值運算符還應該是異常安全的——當異常發生時能將左側對象置於一個有意義的狀態

示例如下:

class Val
{
public:
    Val(const std::string &s = std::string()) :ps(new std::string(s)){}
    Val(const Val &v) :ps(new std::string(*(v.ps))){}

    //錯誤的寫法,無法處理自賦值,好的模式是先將右側運算對象拷貝到一個臨時局部對象中
    Val &operator=(const Val &p)
    {
        delete ps;
        ps = new string(*p.ps);     //如果是自賦值,此時ps指向刪除的內存,引用此指針將報錯
        return *this;
    }
    //特別注意正確賦值構造函數的寫法
    Val &operator=(const Val &p)
    {
        auto newp = new (std::nothrow) std::string(*p.ps);
        if (newp)
        {
            delete ps;
            ps = newp;
        }
        else   //異常安全,如果內存分配失敗,則不做任何處理
        {
            delete newp;
        }
        return *this;
    }
    ~Val(){ delete ps; }
private:
    std::string *ps = nullptr;
};

13.2.2定義行為像指針的類

  令一個類展現類似指針的行為的最好的方法是使用shared_ptr來管理類中的資源,但是有時我們希望直接管理資源,這種情況下我們通常使用引用計數。

引用計數的工作方式如下:

除了初始化對象外,每個構造函數還要創建一個引用計數,用來記錄有對象對象和正在創建的對象在共享狀態。 拷貝構造函數不分配新的計數器,而是拷貝給定對象的數據成員,包括計數器,然後拷貝對象函數遞增共享的計數器。 析構函數會遞減計數器,如果計數器變為0,則析構函數將釋放資源。

拷貝賦值運算符,將遞增右側運算對象的計數器,遞減左側對象的計數器,如果左側對象的計數器變為0,那麼將銷毀左側對象。

  在實現過程中我們也要注意拷貝賦值運算符的必須要能處理自賦值的情況,所以我們要先遞增右側運算對象的引用計數,然後遞減左側運算對象的引用計數
示例如下:

class HasPtr
{
public:
    //默認構造函數中將引用計數初始化為1
    HasPtr(const string &s = string()) :ps(new std::string(s)), pUser(new size_t(1)){}      
    //拷貝構造函數中將引用計數加1
    HasPtr(const HasPtr &p) :ps(p.ps), pUser(p.pUser){ ++ *pUser; }
    //特別注意拷貝賦值操作符的寫法
    HasPtr& operator=(const HasPtr &rhs)
    {
        //遞增右側對象的引用計數
        ++*rhs.pUser;
        //遞減左側運算對象的引用計數,如果為0,則刪除相應的資源
        if (--*pUser == 0)
        {
            delete pUser;
            delete ps;
        }
        ps = rhs.ps;
        pUser = rhs.pUser;
        return *this;
    }

private:
    std::string *ps = nullptr;
    std::size_t *pUser = nullptr;
};

13.3 交換操作

  除了定義拷貝控制成員,管理資源的類通常還定義一個名為swap的函數。對於那些與重排元素順序的算法(如sort、unique)一起使用的類來說,定義swap是非常重要的,因為這類算法在需要交換兩個元素時會調用swap。如果一個類定義了自己的swap,那麼算法將使用類自定義的版本,否則,算法將使用標准庫定義的swap(通常這會影響效率,有時甚至會產生錯誤的結果)。

  通常我們知道常見的交換兩個對象的方法是進行一次拷貝和兩次賦值,標准庫中的默認版本就采用此種方法:

    //交換v1,v2
    Hasptr temp = v1;
    v1 = v2;
    v2 = temp;

  但是當一個類存在自己分配的資源時,這樣重新分配資源是十分浪費的,比如對於HasPtr類來說,我們更希望交換指針,而不是在每次交換時都分配新的string副本。

編寫自己的swap函數

  可以在我們的類上定義一個自己版本的swap函數來重載swap的默認行為,如下:

class HasPtr
{
    friend void swap(HasPtr &lhs, HasPtr &rhs);    //swap函數首先必須聲明為HasPtr的友元函數
    ...
}

inline void swap(HasPtr &lhs, HasPtr &rhs)
{
    using std::swap;
    swap(lhs.ps, rhs.ps);         //僅交換指針
    swap(lhs.pUser, rhs.pUser);
}

與拷貝控制成員不同的是,swap並不是必須的,但是對於分配了資源的類來說,這是一種很重要的優化手段。

swap函數應該調用swap,而不是std::swap

  在上述代碼中我們要特別注意的一點是swap函數中調用的是swap而不是std::swap。
  比如有一個類Foo,其中有HasPtr成員h,我們希望可以通過自定義的swap操作來避免拷貝:

void swap(Foo &lhs, FOo &rhs)
{
    std::swap(lhs.h, rhs.h); 
}

  如果像上述代碼的寫法,可以正常編譯運行,但是函數中調用的swap函數是標准庫中的版本,而不是自定義交換HasPtr的版本,所以上述代碼沒有起到優化作用。正確寫法如下:

void swap(Foo &lhs, FOo &rhs)
{
    using std::swap;   //using聲明不可少
    swap(lhs.h, rhs.h); 
}

  這樣如果此類型存在自定義的swap版本,則會調用自定義的swap版本,否則將使用標准庫的swap版本。

在賦值運算符中使用swap

  定義了swap的類通常會使用swap來定義它們的賦值操作符,這種運算符運用了一種名為拷貝並交換的技術。如下:

    //要注意此函數中形參不是const引用,而變成了值傳遞,這麼做的目的是為了讓賦值函數能處理自賦值
    HasPtr &operator=(HasPtr rhs)
    {
        swap(*this, rhs);
        return *this;
    }

  這個技術有趣之處在於他自動處理了自賦值的情況且天生是異常安全的。它通過改變左側運算符對象之前拷貝右側運算對象保證了自賦值的正確性。而且代碼唯一可能拋出異常的是拷貝函數中的new表達式,如果真發生了異常,它也會在我們改變左側運算對象之前發生,所以其實異常安全的。(要特別注意理解其為什麼是異常安全的)
  要注意這種情況對行為像值的類適用,但是對於有引用計數的類,仔細思考其引用計數應該如何處理。(尚有疑問)

13.4 略

13.5 動態內存管理類

  有些類需要自己進行內存分配。在本節中,我們將定義一個功能類似於vector< string >的類StrVec,注意結合書本相應章節理解此類的結構:

class StrVec
{
public:
    StrVec() = default;
    //析構函數會釋放分配的內存
    ~StrVec()
    {
        free();
        alloc.deallocate(first_free, capacity - first_free);
    }
    StrVec(const StrVec& s)
    {
        //newdata作為返回值指明了首元素以及超出末端的位置
        auto newdata = alloc_n_copy(s.begin(), s.end());
        elements = newdata.first;
        first_free = cap = newdata.second;
    }
    StrVec &operator=(const StrVec &s)
    {
        auto data = alloc_n_copy(s.begin(), s.end());
        free();
        elements = data.first;
        first_free = data.second;
        return *this;
    }

public:
    size_t size() const { return first_free - elements; }
    size_t capacity() const { return cap - elements; }
    string *begin() const { return elements; }
    string *end() const{ return first_free; }
    void push_back(const string &s)
    {
        chk_n_alloc();
        //當我們使用allocator分配內存時,必須記住內存是未構造的,為了使用原始內存,我們必須調用construct函數構造一個對象
        alloc.construct(first_free++, s);
    }
private:
    //確認是否需要重新分配內存
    void chk_n_alloc()
    {
        if (size() == capacity())
            reallocate();
    }
    //工具函數
    std::pair alloc_n_copy(const string *b, const string *e)
    {
        auto data = alloc.allocate(e - b);
        //返回語句中完成拷貝工作,並且返回第一個元素以及最後一個元素後一位的指針,即begin和end
        return{ data, uninitialized_copy(b, e, data) };
    }
    //銷毀元素並釋放內存
    void free()
    {
        if (elements)
        {
            //逆序銷毀舊元素
            for (auto p = first_free; p != elements;)
                alloc.destroy(--p);
            //釋放分配的內存,我們傳遞給deallocate的指針必須是之前某次allocate返回的指針,因此我們首先要檢查elements是否為空
            alloc.deallocate(elements, cap - elements);
        }
    }
    //重新分配更多內存並且拷貝已有元素
    void reallocate()
    {
        auto newcapacity = size() ? 2 * size() : 1;
        auto newdata = alloc.allocate(newcapacity);
        auto dest = newdata;
        auto elem = elements;
        for (size_t i = 0; i != size(); ++i)
        {
            //注意此處重新分配內存拷貝舊成員的時候,使用標准庫的move函數,可以避免分配和釋放string的額外開銷,從而提升效率
            alloc.construct(dest++, std::move(*elem++));
            free();
        }
        elements = newdata;
        first_free = dest;
        cap = elements + newcapacity;
    }

private:
    static std::allocator alloc;   //用於分配元素的內存
    string *elements = nullptr;                 //指向數組中首元素的指針
    string *first_free = nullptr;               //指向數組中第一個空閒元素的指針
    string *cap = nullptr;                      //指向數組尾後位置的指針,為超出末端
};

13.6對象移動

  新標准的一個最主要的特性是可以移動對象而非拷貝對象的能力。
  回想一下,當函數返回一個非引用的值時,會建立一個臨時對象並對這個臨時對象進行拷貝,而臨時對象在拷貝後就立即被銷毀了,在這時一個對象的拷貝其實是不必要的,在這種情況下,移動而非拷貝對象可能會大幅度提升性能。而使用移動而非拷貝的另一個原因是因為有些類(如IO類或unique_ptr)都包含不能被共享的資源(如IO緩存),因此這些對象不能拷貝但可以被移動。

13.6.1右值引用

  讓我們先來回憶一下關於左值右值的知識。c++的表達式要不然是左值,要不然是右值。有一個簡單的歸納是:當一個對象被用作右值時,用的是對象的值(內容),而當一個對象用作左值的時候,用的是對象的身份(在內存中的位置)。有一個重要原則:在需要右值的地方可以使用左值代替,但是不能把右值當做左值使用。當一個左值被當做右值使用時,實際使用的是它的內容。

  現在我們來看看什麼是右值引用。c++11中,為了支持移動操作,引入了一種新的引用類型——右值引用。所謂右值引用就是必須綁定到右值的引用。我們可以通過&&而不是&來獲得右值引用。右值引用有一個很重要的特性——只能綁定到一個將要銷毀的對象。
  對於常規的引用,我們可以稱之為左值引用,我們不能將其綁定到要求轉換的表達式、字面值常量或者返回右值的表達式(除非它是一個const引用)。而右值引用有這完全相反的特性:我們可以將一個右值綁定到這類表達式上,但是不可以將一個右值引用綁定到一個左值上。

    int i = 42;
    int &r = i;             //正確,將一個左值引用綁定到一個左值
    int &&rr = i;           //錯誤,不能將一個右值引用綁定到一個左值
    int &r2 = i * 42;       //錯誤,i*42為一個右值表達式,不可以將一個非常量左值引用綁定到一個右值表達式
    const int &r3 = i * 42; //正確,const引用可以綁定到一個右值上
    int &&rr2 = i * 42;     //正確,將右值引用綁定到一個右值上

左值持久,右值短暫

  左值有持久的狀態,而右值要麼是字面常量,要麼是在表達式求值過程創建的臨時對象。由於右值引用只能綁定到臨時對象,我們可知

所引用的對象將要被銷毀 該對象沒有其他用戶

這兩個特性意味著:使用右值引用的代碼可以自由的接管所引用的對象的資源。

變量是左值

我們必須清楚認識到一點,變量都是左值,所以我們無法把一個右值引用綁定到一個變量上即使這個變量本身是一個右值引用:

int &&rr1 = 42;
int &&rr2 = rr1;    //錯誤,rr1是一個左值!!!

標准move函數

  我們可以顯式的將一個左值轉換成對應的右值引用類型,我們還可以通過標准庫函數std::move來獲得綁定到左值上的右值引用,此函數定義在頭文件utility頭文件中。

int r = 42;
int &&rr = std::move(r);

  我們必須意識到,當我們對一個對象使用move操作後,我們將不再使用它。在對一個對象調用move操作後,我們不能對移後源對象的值做任何假設。我們可以銷毀一個移後源對象,也可以賦予它新值,但不能使用一個移後源對象的值。

13.6.2移動構造函數和移動賦值操作符

  為了讓自定義類型支持移動操作,我們需要為其定義移動構造函數和移動賦值操作符。

移動構造函數

  移動構造函數的的第一個參數是該類類型的右值引用,任何額外的參數都必須要有默認值。而且為了完成資源移動,移動構造函數必須確保移後源對象處於這樣一個狀態——銷毀它是無害的。所以,一旦資源完成移動,源對象必須不再指向被移動的資源——這些資源的所有權已經歸屬新創建的對象了。

StrVec(StrVec &&s) noexcept      //移動操作不應拋出任何異常
{
    //與拷貝構造函數不同的是,移動構造函數並不分配新的資源
    elements = s.elements;
    first_free = s.first_free;
    cap = s.cap;
    //將移後源對象的相關指針置為空,這樣對其的析構是安全的
    s.elements = s.first_free = s.cap = nullptr;
}

  StrVec的析構函數在first_free上調用deallocate,如果我們忘記改變s.first_free的狀態,那麼銷毀移後源對象後將會釋放掉我們剛剛移動的內存。

移動操作、標准庫容器和異常

  我們必須先認清兩個事實:首先,雖然移動操作通常不拋出異常,但是拋出異常也是允許的;其次,標准庫容器能對異常發生時其自身的行為提供保障,例如vector保證,如果我們push_back時拋出異常,則vector自身將不發生改變。
  現在我們假設vector在push_back的過程需要重新分配資源,所以其會把舊元素移動到新內存中,就像StrVec中那樣。如果此過程中使用了移動構造函數,而移動構造函數在移動了部分元素後拋出了異常 ,那麼舊空間中的元素已經被改變,而新空間中未構造的元素尚不存在,此時vector將不能保證拋出異常時保持自身不變的要求。但是如果此過程使用的是拷貝構造函數而非移動構造函數,那麼即使拷貝構造函數拋出異常,舊元素的值仍未發生任何變化,vector可以滿足保持自身不變的要求。所以為了避免這種潛在問題,除非vector知道元素的移動構造函數不會拋出異常,否則其在重新分配內存的時候,它將使用拷貝構造函數而非移動構造函數。所以如果我們希望vector這類的容器在重新分配內存時對自定義類型使用移動構造函數而非拷貝構造函數,那麼我們必須將自定義類型的移動構造函數(以及移動賦值操作符)標記為noexcept(不會拋出異常)。
  

移動賦值運算符

  移動賦值運算符執行與析構函數和移動構造函數相同的工作,而且要注意的是其也必須正確處理自賦值的情況。

StrVec &operator=(StrVec &&rhs)
{
    //判斷是否是自賦值
    if (this != &rhs)
    {
        free();
        elements = rhs.elements;
        first_free = rhs.first_free;
        cap = rhs.cap;
        //使移後源對象處於可以安全銷毀的狀態
        rhs.cap = rhs.elements = rhs.first_free = nullptr;
    }
    return *this;
}

  我們費心檢查自賦值看起來有些奇怪,畢竟移動賦值運算符需要右側運算對象是一個右值。我們進行檢查的原因是此右值可能是move調用的返回結果,關鍵點在於我們不能在使用右側運算對象的資源之前就釋放左側對象的資源。

移後源對象必須可析構

  當我們編寫一個移動操作後,必須要確保移後源對象進入一個可安全析構的狀態,並且移動操作還必須保證移後源對象仍然是有效的。有效是指可以安全的對其賦新值或者可以安全使用而不依賴其當前值。但是用戶不能對移後源對象的值做任何假設,一般在對其重新賦值之前不要使用它。

合成的移動操作

  與處理拷貝構造函數和拷貝賦值運算符一樣,編譯器也會合成移動構造函數和移動賦值運算符,但是其合成的條件不同:只有當一個類沒有定義任何自己版本的拷貝控制成員,且它的所有非static數據成員都能夠移動構造或移動賦值時,編譯器才會為它合成移動構造函數或移動賦值運算符

//X的成員都可以被移動,所以編譯器會為其合成移動操作
struct X
{
    int i;          //內置類型可以被移動
    string s;       //string定義了自己的移動操作
};

struct hasX
{
    X mem;          //X可以被移動,所以hasX也有合成的移動操作
};

在以下情況下,編譯器會將移動操作定義為刪除的函數:

如果我們顯式地將移動操作聲明為default,且編譯器不能移動所有成員,那麼編譯器會將移動操作定義為刪除的函數。 移動構造函數被定義為刪除的函數的條件是:有類成員定義了自己的拷貝構造函數而未定義移動構造函數,或者有類成員未定義自己的拷貝構造函數且編譯器不能為其合成移動構造函數。移動賦值運算符的情況類似。 有類成員的移動構造函數或者移動賦值運算符被定義為刪除的或者是不可訪問時。 如果一個類的析構函數定義為刪除的或者是不可訪問的,則類的移動操作被定義為刪除的。 如果有類的成員是consth或者const引用,則類的移動賦值運算符被定義為刪除的。
struct hasY
{
    hasY() = default;
    hasY(hasY &&) = default;   //vs2013尚不支持將移動構造函數聲明為default
    Y mem;      //假設Y是一個類,其移動構造函數是刪除,則hasY的移動構造函數也會被定義為刪除的
};

  移動操作和合成的拷貝控制成員還有最後一個相互關系:如果一個類定義了一個移動構造函數或一個移動賦值操作符,則該類的合成拷貝構造函數或合成拷貝賦值運算符會被定義為刪除的函數。

移動右值,拷貝左值

  當一個類既有移動構造函數,也有拷貝構造函數,編譯器會使用普通的函數匹配機制來確定使用哪個構造函數,左值匹配拷貝構造函數,右值匹配移動構造函數。
  如果一個類沒有移動構造函數,那麼即使右值也會被拷貝,即使我們試圖通過move來移動它們。用拷貝構造函數代替移動構造函數幾乎總是安全的。

更新三/五原則

  所有五個拷貝控制成員都應看做一個整體:一般來說,如果一個類定義了任何一個拷貝操作,它就應該定義所有五個操作,特別是對於要在類內管理資源的類型。

移動迭代器

  新標准中定義了一種移動迭代器適配器,移動迭代器的解引用運算符將生成一個右值引用。我們可以通過make_move_iterator將一個普通迭代器轉換為移動迭代器,然後我們可以將一對移動迭代器傳遞給算法。於是我們可以重寫StrVec的reallocate函數

void StrVec::reallocate()
{
    auto newcapacity = size() ? 2 * size() : 1;
    auto first = alloc.allocate(newcapacity);
    //使用移動迭代器,將舊元素移動到新分配的內存中
    auto last = uninitialized_copy(make_move_iterator(begin()), make_move_iterator(end()), first);
    free();         //釋放舊內存
    elements = first;
    first_free = last;
    cap = elements + newcapacity;
}

不要隨意進行移動操作

  由於一個移後源對象具有不確定的狀態,所有我們必須確信對某對象移動後不再使用其值才對其進行移動操作,否則可能造成莫名其妙的錯誤。在代碼小心的使用std::move操作,可以大幅度提升性能。

13.6.3右值引用與成員函數

  除了移動操作外,我們也可以普通的成員函數提供拷貝和移動兩種版本,通常一個版本接受一個const的左值引用為參數,令一個版本接受一個非const的右值引用。比如我們可以為StrVec的push_back定義兩個版本,如下:

//拷貝版本,可綁定任意類型的string值
void StrVec::push_back(const string &s)
{
    chk_n_alloc();
    alloc.construct(first_free++, s);
}
//移動版本,只能綁定到一個string右值
void StrVec::push_back(string &&s)
{
    chk_n_alloc();
    alloc.construct(first_free++, std::move(s));    //差別只在此處使用移動操作而非拷貝操作    
}
StrVec sv;
string s = "copy";
sv.push_back(s);    //調用拷貝版本的成員函數
sv.push_back("move");  //調用移動版本的成員函數

右值與左值引用成員函數

  在c++11之前,我們在一個對象上調用成員函數,不會判斷該對象是一個左值還是右值,例如:

string s1 = "a value", s2 = "another";
auto n = (s1 + s2).find('a');           //s1+s2返回了一個右值,然後在右值對象上調用find函數

  然而有時我們可以以一種令人驚訝的方式使用右值:

s1 + s2 = "wow!"    //對一個右值進行賦值操作!!!

  在舊標准中我們無法阻止這種使用方式。為了維持向後兼容,c++11仍然允許向右值賦值,但是c++11增加了一種阻止這種用法的方法,即使用引用限定符
  我們通過在函數聲明與定義中加入引用限定符來限定調用對象的左右值屬性。

class Foo
{
public:
    Foo operator=(const Foo &) &;   //限定只能向可修改的左值賦值
};

  引用限定符實際作用和const的聲明一樣,我們知道const實際上是聲明this指針的類型,而引用限定符實際上也是聲明this指針的類型,&聲明this指針指向一個左值,&&聲明this指針指向一個右值。
一個函數可以同時使用const和引用限定,引用限定符必須跟隨在const限定符之後。

重載和引用函數

  就像一個成員函數可以根據是否有const來區分其重載版本一樣,引用限定符也可以區分重載版本。

class Foo
{
public:
    //以下函數會構成重載函數
    Foo sort() &;   
    Foo sort() &&;  
    Foo sort() const &;
    Foo sort() const &&;
};

  需要注意的是如果我們定義了兩個或者兩個以上的具有相同名字和相同參數列表的成員函數(注意並非所有重載函數集),就必須對所有函數加上引用限定符,或者所有都不加:

//錯誤,兩個sort函數要不都有引用限定符,要不都沒有引用限定符
class Foo
{
public:
    Foo sort() &;
    Foo sort() const;
}
//正確
class Foo
{
public:
    Foo sort() &;
    Foo sort() const &;
}
//正確
class Foo
{
public:
    Foo sort();
    Foo sort() const;
    //與上面的函數參數列表不同
    Foo sort(int *) &;
    Foo sort(int *) const &;
}

另外說明一點:vs2013尚不支持引用限定符的使用

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved