1. 程式人生 > >C++常見問題總結_拷貝控制(拷貝、賦值、銷燬)

C++常見問題總結_拷貝控制(拷貝、賦值、銷燬)

當我們定義一個類時,我們顯示或隱式地指定在此型別物件拷貝、賦值和銷燬時做什麼。
一個類通過定義五種特殊的成員函式來控制這些操作,包括:拷貝建構函式、拷貝賦值運算子、移動建構函式、移動賦值運算子和解構函式
拷貝和移動建構函式定義了當用同類型的另一個物件初始化本物件時做什麼。拷貝和移動賦值運算子定義了將一個物件賦予同類型的令一個物件時做什麼,解構函式定義了當此型別物件銷燬時做什麼。
在本片文章主要介紹拷貝建構函式、拷貝賦值運算子和解構函式。

拷貝建構函式
拷貝建構函式:一個建構函式的第一個引數是自身類型別的引用,且任何額外引數都有預設值

class foo{
    public
: foo(); foo(const foo&); };

幾點說明:
1、第一個引數是引用型別?
在函式呼叫過程中,具有非引用型別的引數要進行拷貝初始化,即拷貝建構函式被用來初始化非引用類型別引數,如果不是,為了呼叫拷貝建構函式,我們必須拷貝他的實參,為了拷貝實參,我們又需要呼叫拷貝建構函式如此無限迴圈。
2、通常此引數是const的,並且拷貝建構函式在幾種情況下都可以被隱式的使用,因此拷貝建構函式通常不應該是explicit的。

  • 合成的拷貝建構函式
    與合成的預設建構函式不同,即使我們定義了一個拷貝建構函式,編譯器也會為我們合成拷貝建構函式。
    合成的拷貝建構函式作用:
    1、阻止我們拷貝該類型別的物件;
    2、將其引數的成員(非static)逐個拷貝到正在建立的物件中(對於類型別,使用其拷貝構
    造函式拷貝,對內建型別直接拷貝)。
class sales_data(){
    public:
        //與合成的拷貝建構函式等價的拷貝建構函式的宣告
        sales_data(const sales_data&);
    private:
        string bookno;
        int units_sold=0;
        double revenue=0.0;
};

sales_data:sales_data(const sales_data &orig):
    bookno(orig.bookno),
    units_sold(orig.units_sold),
    revenue(orig.revenue)
    {}
  • 拷貝初始化
string dots(10,'.');  //直接初始化
string s(dots);       //直接初始化
string s2=dots;       //拷貝初始化
string null_book="9-999-99999-9";//拷貝初始化
string nines=string(10,'9'); //拷貝初始化

當我們使用直接初始化時,實際上是要求編譯器使用普通的函式匹配,來選擇與我們提供的引數最匹配的建構函式。當我們使用拷貝初始化時,我們要求編譯器將右側運算物件拷貝到正在建立的物件中,如果需要可能會進行型別轉換。
拷貝初始化通常發生在:
1、用=號定義變數時
2、講一個物件作為實參傳遞給一個非引用型別的形參
3、從一個返回型別為非引用型別的函式返回一個物件
4、用花括號列表初始化一個數組中的元素或一個聚合類中的物件某些類型別物件會對他們所分配的物件使用拷貝初始化。容器呼叫push 或insert,與之相對呼叫 emplace 使用直接初始化。
note:
1、當我們使用的初始化值要求通過一個explicit的建構函式來進行型別轉換時:

vector<int> v1(10);//正確:直接初始化
vector<int> v2=10; // 錯誤:接收大小引數的建構函式是 explicit
void f(vector<int>); //f 的引數進行拷貝初始化
f(10);// 錯誤
f(vector<int>(10));//正確

2、在拷貝初始化過程中,編譯器可以跳過拷貝建構函式,直接建立物件

string null=”9-999-9”;//拷貝初始化
改寫為:
string null(”9-999-9”);//編譯器跳過了拷貝建構函式

即使編譯器跳過了拷貝建構函式,但在這個程式點上,拷貝建構函式必須是存在的且可以訪問的。
example:
為給定類編寫一個拷貝建構函式:

class hasptr{
public:
    hasptr(const string&s=string()):
    ps(new string(s)),i(0){}
    hasptr(const hasptr&hp);
private:
    string *ps;
    int i;
};
//應該動態分配一個新的string,並將物件拷貝到ps指向的位置,而不是拷貝ps本身
hasptr::hasptr(const hasptr&hp)
{
    ps=new string(*hp.ps);
    i=hp.i;
}

拷貝賦值運算子
與類控制其物件如何初始化一樣,類也可以控制其物件如何賦值,如果一個類未定義自己的拷貝賦值運算子,編譯器會為它合成一個。

  • 過載賦值運算子
    過載運算子本質上是一個函式,其名字由operator關鍵字後接表示要定義的運算子的符號組成。如:operator=。運算子函式也有一個返回型別和一個引數列表。過載運算子的引數表示運算子的運算物件。某些運算子,包括賦值運算子,必須定義為成員函式。如果一個運算子是一個成員函式,其左側物件被繫結到隱式的this引數。對於一個二元運算子,其右側物件,作為顯示引數傳遞。
    拷貝賦值運算子接收一個與其所在類相同型別的引數:
//通常返回一個指向其左側物件的引用
class foo{
public:
foo& operator=(const foo&);//拷貝賦值運算子
};
  • 合成拷貝賦值運算子
    作用: 1、禁止該型別物件的賦值; 2、將右側的每個非 static 物件賦予左側物件的成員,這一工作通過成員型別的拷貝賦值運算子來完成的。
//等價於合成的拷貝運算子
sales_data& sales_data::operator=(const sales_data &rhs)
{
    bookno=rhs.bookno;
    units_sold=rhs.units_sold;
    revenue=rhs.revenue;
    return *this;
}

example:
為給定類編寫一個拷貝賦值運算子

class hasptr{
public:
    hasptr(const string&s=string()):
    ps(new string(s)),i(0){}
    hasptr(const hasptr&hp);
    hasptr& opeartor=(const hasptr&);
private:
    string *ps;
    int i;
};
//應該動態分配一個新的string,並將物件拷貝到ps指向的位置,而不是拷貝ps本身
hasptr::hasptr(const hasptr&hp)
{
    ps=new string(*(hp.ps));
    i=hp.i;
}

//賦值運算子應該將物件拷貝到ps指向的位置
hasptr& hasptr::opeartor=(const hasptr &rhs)
{
    auto newps=new string(*rhs.ps);//拷貝指標指向的物件
    delete ps;    //銷燬源物件
    ps=newps;     //指向新的string
    i=rhs.i;
    return *this;
}

三/五法則
通常並不要求我們定義所有這些拷貝操作,可以定義其中一個或兩個,而不必定義所有。下面介紹一些常見的準則。
1、需要解構函式的類也需要拷貝和賦值操作
通常使用了動態記憶體的類,一般需要定義自己的解構函式

//
class hasptr{
public:
    hasptr(const string&s=string()):
    ps(new string(s)),i(0){}
    ~hasptr(){delete ps;}
private:
    string *ps;
    int i;
};
//如果我們沒有為我們的hasptr類定義拷貝和賦值操作將會有錯誤
例如:
hasptr f(hasptr hp)
{
    hasptr ret=hp;
    return ret;  //ret 和hp被銷燬
}
/*
ret和hp被銷燬時,會執行對應的建構函式,
此時會delete ret和hp中的指標成員,
此時會導致被delete兩次。*/

2、如果一個類需要一個拷貝建構函式,則它肯定需要一個拷貝賦值運算子,反之亦然。並且,一個類無論是需要一個拷貝建構函式還是需要一個拷貝賦值運算子,都不意味著它需要一個解構函式。

兩點說明
1、使用=default
可以通過將拷貝控制成員定義為=default 來顯示要求編譯器生成合成的版本。當我們在類內用=default 修飾成員的宣告時,合成的函式將隱式的宣告為內聯的。如果我們不希望合成的成員是行內函數,應該只對成員的類外定義使用=default。(只能對具有合成版本的成員函式使用delete)

2、阻止拷貝
定義刪除的函式
雖然大多數類應該定義拷貝建構函式和拷貝賦值運算子,但對於某些類來說,這些操作沒有合理的意義。在此情況下,定義類時必須採用某種機制阻止拷貝或賦值。
在引數列表後面加上=delete 來指出我們希望將它定義為刪除的(我們雖然定義了它,但不希望以任何方式使用它)
與=default 不同,=delete 必須出現在函式第一次宣告的時候
與=default 不同,我們可以對任何函式指定=delete(只能對編譯器可以合成的預設建構函式或拷貝控制成員使用=default)

struct nocopy{
    nocopy()=default;
    nocopy(const nocopy&)=delete;
    nocopy &operator=(const nocopy&)=delete;
    ~nocopy()=default;
};

解構函式不能是刪除的成員
解構函式被刪除, 就無法銷燬此型別的物件了。 對於刪除了解構函式的型別, 我們不能定義這種型別的變數或成員, 但可以動態分配這種型別的物件, 但是不能釋放這些物件。

struct nodtor{
    nodtor()=default;
    ~nodtor()=delete;
};
nodtor nd;//錯誤:他的解構函式是刪除的
nodtor *p=new nodtor();//正確但是不能delete p
delete p;//錯誤

3、合成的拷貝控制成員可能是刪除的
如果一個類中有資料成員不能預設構造、拷貝、複製或銷燬,則對應的成員函式將會被定義成刪除的。本質上,當不能拷貝、賦值或銷燬類的成員時,類的合成拷貝控制成員就被定義為刪除的。
NOTE:

  • 類有一個const的或引用成員,則類的合成拷貝賦值運算子被定義為刪除的。
    雖然我們可以將一個新值賦予一個引用成員,但這樣做改變的是引用指向的物件的值,不是引用本身。如果為這樣的類合成的拷貝賦值運算子,則賦值後,左側物件(引用)仍然繫結與賦值前一樣的物件,而不是與右側物件指向相同的物件。

4、拷貝控制 private
通過將其拷貝建構函式和拷貝賦值運算子宣告為private 的來阻止拷貝:

class HomeForSale{
public:
...
private:
...
HomeForSale(const HomeForSale&); //只有宣告
HomeForSale& operator=(const HomeForSale&);
};

拷貝建構函式和拷貝賦值運算子宣告為 private,使用者程式碼將不能拷貝這個型別物件,但成員函式和友元可以拷貝物件,為了阻止友元和成員進行拷貝,我們只宣告而不定義他們,也做到了即使是成員函式和友元也無法進行呼叫。
試圖訪問拷貝物件的使用者程式碼在編譯階段是標記為錯誤;
成員函式或友元函式中的拷貝操作將會導致連結(訪問一個未定義的成員) 時錯誤。

example:定義一個類,它包含僱員姓名和唯一的僱員證件號。

#include<iostream>
#include<string>

using namespace std;

class employee
{
public:
    employee() { mysn = sn++; }
    employee(const string&s) { name = s; mysn = sn++; }
    //必須定義,自己的拷貝和賦值,如果使用合成的版本,將簡單的複製,使得證件號相同
    employee(employee&e) { name = e.name; mysn = sn++; }
    employee& operator=(employee&e) { name = e.name; mysn = sn++; return *this; }
    const string&get_name() { return name; }
    int get_mysn() { return mysn; }
private:
    static int sn ;
    string name;
    int mysn;
};

int employee::sn = 0;

void f(employee&s)
{
    cout << s.get_name() << ":" << s.get_mysn() << endl;
}

int main()
{
    employee a("毛"), b = a, c;
    c = b;
    f(a); f(b); f(c);
    getchar();
}

這裡寫圖片描述