1. 程式人生 > >C++基礎教程面向物件(學習筆記(33))

C++基礎教程面向物件(學習筆記(33))

淺層與深層複製

淺層拷貝

因為C ++對您的類知之甚少,所以它提供的預設拷貝建構函式和預設賦值運算子使用稱為成員拷貝的複製方法(也稱為淺層複製)。這意味著C ++單獨複製類的每個成員(使用賦值運算子過載operator =,並直接初始化複製建構函式)。當類很簡單時(例如,不包含任何動態分配的記憶體),這非常有效。

例如,讓我們來看看我們的Fraction類:

#include <cassert>
#include <iostream>
 
class Fraction
{
private:
    int m_numerator;
    int m_denominator;
 
public:
    // 預設建構函式
    Fraction(int numerator=0, int denominator=1) :
        m_numerator(numerator), m_denominator(denominator)
    {
        assert(denominator != 0);
    }
 
    friend std::ostream& operator<<(std::ostream& out, const Fraction &f1);
};
 
std::ostream& operator<<(std::ostream& out, const Fraction &f1)
{
	out << f1.m_numerator << "/" << f1.m_denominator;
	return out;
}

編譯器為此類提供的預設複製建構函式和賦值運算子如下所示:

#include <cassert>
#include <iostream>
 
class Fraction
{
private:
    int m_numerator;
    int m_denominator;
 
public:
    // 預設建構函式
    Fraction(int numerator=0, int denominator=1) :
        m_numerator(numerator), m_denominator(denominator)
    {
        assert(denominator != 0);
    }
 
    // 拷貝建構函式
    Fraction(const Fraction &f) :
        m_numerator(f.m_numerator), m_denominator(f.m_denominator)
    {
    }
 
    Fraction& operator= (const Fraction &fraction);
 
    friend std::ostream& operator<<(std::ostream& out, const Fraction &f1);
};
 
std::ostream& operator<<(std::ostream& out, const Fraction &f1)
{
	out << f1.m_numerator << "/" << f1.m_denominator;
	return out;
}
 
// 更好的實現operator=
Fraction& Fraction::operator= (const Fraction &fraction)
{
    // 自我賦值看門狗
    if (this == &fraction)
        return *this;
 
    // 拷貝
    m_numerator = fraction.m_numerator;
    m_denominator = fraction.m_denominator;
 
    // 返回現有物件,以便我們可以連結此運算子
    return *this;
}

請注意,因為這些預設運算子適用於拷貝此類,所以在這種情況下,沒有理由編寫這些函式的自己運算子。

但是,在設計處理動態分配記憶體的類時,成員(淺)複製可能會給我們帶來很多麻煩!這是因為指標的淺拷貝只是複製指標的地址 - 它不會分配任何記憶體或複製指向的內容!

我們來看一個這樣的例子:

#include <cstring> // for strlen()
#include <cassert> // for assert()
 
class MyString
{
private:
    char *m_data;
    int m_length;
 
public:
    MyString(const char *source="")
    {
        assert(source); // 確保source不是空字串
 
        // 找到字串的長度
        // 加上結束的一個字元
        m_length = std::strlen(source) + 1;
        
        // 分配等於此長度的緩衝區
        m_data = new char[m_length];
        
        // 將引數字串複製到內部緩衝區
        for (int i=0; i < m_length; ++i)
            m_data[i] = source[i];
    
        // 確保字串已結束
        m_data[m_length-1] = '\0';
    }
 
    ~MyString() // 銷燬
    {
        // 我們需要銷燬分配我們的字串
        delete[] m_data;
    }
 
    char* getString() { return m_data; }
    int getLength() { return m_length; }
};

上面是一個簡單的字串類,它分配記憶體來儲存我們傳入的字串。請注意,我們還沒有定義拷貝建構函式或過載賦值運算子。因此,C ++將提供一個預設的拷貝建構函式和預設賦值運算子,它們執行淺拷貝。拷貝建構函式看起來像這樣:

MyString::MyString(const MyString &source) :
    m_length(source.m_length), m_data(source.m_data)
{
}

請注意,m_data只是source.m_data的淺指標副本,這意味著它們現在都指向同一個東西。

現在,請考慮以下程式碼段:

int main()
{
    MyString hello("Hello, world!");
    {
        MyString copy = hello; // 使用預設建構函式
    } //copy是一個區域性變數,所以它在這裡被銷燬。解構函式刪除了copy的字串,它用一個懸空指標留下hello
 
    std::cout << hello.getString() << '\n'; // 這將有不確定的行為
 
    return 0;
}

雖然這段程式碼看起來無害,但它包含一個潛在的問題,會導致程式崩潰!你能發現它嗎?不要擔心,如果你不能,它是相當微妙的。

讓我們逐行分解這個例子:

MyString hello("Hello, world!");

這行程式碼是無害的。這將呼叫MyString建構函式,該建構函式分配一些記憶體,將hello.m_data設定為指向它,然後將字串“Hello,world!”複製到其中。

   MyString copy = hello; // 使用預設拷貝建構函式

這行程式碼似乎也無害,但它實際上是我們問題的根源!在評估此行時,C ++將使用預設的拷貝建構函式(因為我們沒有自己提供)。此複製建構函式將執行淺複製,將copy.m_data初始化為hello.m_data的相同地址。結果,copy.m_data和hello.m_data現在都指向同一塊記憶體!

} // 副本在這裡被銷燬

當副本超出範圍時,將在複製時呼叫MyString解構函式。解構函式刪除copy.m_data和hello.m_data指向的動態分配的記憶體!因此,通過刪除副本,我們也(無意中)影響了hello。變數副本然後被銷燬,但hello.m_data指向已刪除(無效)的記憶體!

 std::cout << hello.getString() << '\n'; // 這將有不確定的行為

現在你可以看到為什麼這個程式有未定義的行為。我們刪除了hello指向的字串,現在我們正在嘗試列印不再分配的記憶體值。

這個問題的根源是拷貝建構函式完成的淺複製 - 在複製建構函式中執行指標值的淺複製或過載賦值運算子幾乎總是要求麻煩。

深度複製

這個問題的一個答案是對正在複製的任何非空指標進行深層複製。一個深拷貝分配的副本儲存,然後複製的實際內容,因此複製住在從源不同的儲存器。這樣,副本和源是不同的,不會以任何方式相互影響。執行深層複製需要我們編寫自己的拷貝建構函式和過載賦值運算子。

讓我們繼續說明如何為MyString類完成此操作:

// 拷貝建構函式
MyString::MyString(const MyString& source)
{
    // 因為m_length不是指標,我們可以淺拷貝它
    m_length = source.m_length;
 
    // m_data是一個指標,因此如果它是非null,我們需要深度複製它
    if (source.m_data)
    {
        // 為我們的副本分配記憶體
        m_data = new char[m_length];
 
        // 拷貝
        for (int i=0; i < m_length; ++i)
            m_data[i] = source.m_data[i];
    }
    else
        m_data = 0;
}

正如您所看到的,這比簡單的淺拷貝更復雜!首先,我們必須檢查以確保源甚至有一個字串(第8行)。如果是,那麼我們分配足夠的記憶體來儲存該字串的副本(第11行)。最後,我們必須手動複製字串(第14和15行)。

現在讓我們來做過載賦值運算子。過載的賦值運算子稍微複雜一些:

// 賦值操作符
MyString& MyString::operator=(const MyString & source)
{
    // 檢查自我賦值
    if (this == &source)
        return *this;
 
    // 首先,我們需要釋放該字串所持有的任何值!
    delete[] m_data;
 
    // 因為m_length不是指標,我們可以淺拷貝它
    m_length = source.m_length;
 
    // m_data是一個指標,因此如果它是非null,我們需要深度複製它
    if (source.m_data)
    {
        // 為我們的副本分配記憶體
        m_data = new char[m_length];
 
        // 拷貝
        for (int i=0; i < m_length; ++i)
            m_data[i] = source.m_data[i];
    }
    else
        m_data = 0;
 
    return *this;
}

請注意,我們的賦值運算子與我們的拷貝建構函式非常相似, 但有三個主要區別: #我們添加了自我分配檢查。 #我們返回*這樣我們可以連結賦值運算子。 #我們需要顯式釋放字串已經存在的任何值(因此我們在以後重新分配m_data時沒有記憶體洩漏)。 當呼叫過載賦值運算子時,分配給的項可能已包含先前的值,我們需要確保在為新值分配記憶體之前清理它們。對於非動態分配的變數(固定大小),我們不必費心,因為新值只會覆蓋舊值。但是,對於動態分配的變數,我們需要在分配任何新記憶體之前顯式釋放任何舊記憶體。如果我們不這樣做,程式碼就會崩潰,但是每次我們完成一項任務時,我們都會有記憶體洩漏,這會洩漏我們的空閒記憶體!

更好的解決方案

處理動態記憶體的標準庫中的類(如std :: string和std :: vector)處理所有記憶體管理,並且過載了拷貝建構函式和賦值操作符,這些操作符可以進行適當的深度複製。因此,您可以像普通的基本變數一樣初始化或分配它們,而不是自己進行記憶體管理!這使得這些類更易於使用,更不容易出錯,而且您不必花時間編寫自己的過載函式!

Summary:

預設的拷貝建構函式和預設賦值運算子執行淺拷貝,這對於不包含動態分配的變數的類很好。 具有動態分配變數的類需要具有執行深層複製的拷貝建構函式和賦值運算子。 支援使用標準庫中的類而不是自己進行記憶體管理。