關於C++複製控制

發表於2023-10-08

通常來說,對於類內動態分配資源的類需要進行複製控制:要在複製建構函式、複製賦值運算子、解構函式中實現安全高效的操作來管理記憶體。但是資源管理並不是一個類需要定義自己的複製控制成員的唯一原因。C++ Primer 第5版 中給出了一個Message類與Folder類的例子,分別表示電子郵件訊息和訊息目錄。每個Message可以出現在多個Folder中,但是,任意給定的Message的內容只有一個副本。如果一條Message的內容被改變,我們從任意的Folder中看到的該Message都是改變後的版本。為了記錄Message位於哪些Folder中,每個Message都用一個set儲存所在的Folder的指標,同樣的,每個Folder都用一個set儲存它包含的Message的指標。二者的設計如下圖所示:

C++ Primer中並沒有給出Folder類的實現。在對Message及Folder類的復現過程中,出現了一個問題,導致了嚴重錯誤。

Message及Folder類的初步設計如下:

Message類:

class Message
{
    friend class Folder;
private:
    string contents;
    set<Folder*> folders;

    //功能函式:在本訊息的folders列表中加入/刪除新資料夾指標f
    void addFolder(Folder* f);
    void remFolder(Folder* f);

    //功能函式:在本訊息folders列表中的所有Folder中刪除指向此訊息的指標
    void remove_from_folders();

public:
    string getContents();
    set<Folder*> getFolders();

    //建構函式與複製控制
    Message(const string& s = " ") :contents(s) {};
    ~Message();

    //介面:將本訊息存入給定資料夾f
    void save(Folder& f);
    //介面:將本訊息在給定資料夾中刪除
    void remove(Folder& f);
};

Folder類:

class Folder
{
    friend class Message;
private:
    set<Message*> messages;

    //功能函式:將給定訊息的指標新增到本資料夾的messages中
    void addMsg(Message* m);
    //功能函式:將給定訊息的指標在本資料夾中的messages中刪除
    void remMsg(Message* m);

public:
    set<Message*> getMessages();
};

這兩個類有對稱的功能函式:Message.addFolder(Folder* f)與Folder.addMsg(Message* m),以及Message.remFolder(Folder* f)與Folder.remMsg(Message* m),用來實現Message的儲存以及複製控制操作等。

所有成員函式的實現如下:

string Message::getContents()
{
    return contents;
}
set<Folder*> Message::getFolders()
{
    return folders;
}

void Message::addFolder(Folder* f)
{
    this->folders.insert(f);
}
void Message::remFolder(Folder* f)
{
    this->folders.erase(f);
}

//介面:將本訊息存入給定資料夾f
void Message::save(Folder& f)
{
    this->addFolder(&f);
    f.addMsg(this);
}
//介面:將本訊息在給定資料夾中刪除
void Message::remove(Folder& f)
{
    this->remFolder(&f);
    f.remMsg(this);
}

void Message::remove_from_folders()
{
    for (auto f : folders)
    {
        f->remMsg(this);
    }
}

Message::~Message()
{
    remove_from_folders();
}

/*Folder的成員函式*/
//功能函式:將給定訊息的指標新增到本資料夾的messages中
void Folder::addMsg(Message* m)
{
    messages.insert(m);
}
//功能函式:將給定訊息的指標在本資料夾中的messages中刪除
void Folder::remMsg(Message* m)
{
    messages.erase(m);
}

set<Message*> Folder::getMessages()
{
    return messages;
}

 在這個實現版本的程式碼測試中,出現了這樣一個問題:程式會有執行時錯誤,主函式的返回值不為0。測試程式碼如下:

void test()
{
    Message m1("Hello,"), m2("World"), m3("!");
    Folder f1, f2;
    m1.save(f1); m1.save(f2);
    m2.save(f2);
    m3.save(f2);
    m2.remove(f2);
}

int main()
{
    test();
    system("pause");
    return 0;
}

執行結果:

 經除錯排查原因之後,找到了問題所在:試圖對已經被銷燬了的物件的指標進行解引用。該bug和“函式返回指向區域性變數的指標”所導致的問題類似。我們為Message類定義了解構函式:

Message::~Message()
{
    remove_from_folders();
}

這個解構函式的實現與C++ Primer上的實現完全一致。該解構函式意圖在於當一個Message被銷燬時,應該清除它的folders中的所有指向它的指標。這看上去合理,可是在這裡卻導致了記憶體錯誤。原因在於,remove_from_folders()操作會訪問該Message所在的所有Folder的指標,而若這些Folder的銷燬在該Message的銷燬之前進行,則操作會試圖透過指標解引用,來訪問已被銷燬的Folder物件。這會導致嚴重的執行時錯誤。在本例中,區域性變數Folder f1的建立在m1之後,將m1加入f1,test()函式結束時,按照區域性變數的銷燬順序,會先銷燬後建立的物件f1,於是,m1的解構函式會試圖解引用已被銷燬物件f1的指標。出現這個問題,是因為在實現的時候沒有按照C++ Primer上的設計正確地實現Folder的解構函式。我們按照如下實現Folder的解構函式:

class Folder
{
    /*其他Folder的宣告不變*/

    /*加入Folder的解構函式,以及一個工具函式,對於將要銷燬的Folder,這個工具函式負責刪除該Folder中所有Message指向它的指標*/
private:  
    void remove_from_messages();
public:    
    ~Folder();
};

void Folder::remove_from_messages()
{
    for (auto m : messages)
        m->remFolder(this);
}

Folder::~Folder()
{
    remove_from_messages();
}

此時,Folder的解構函式在Folder被銷燬時可以正確地刪除所有Message中指向自身的指標,就避免了對已經銷燬的物件進行解引用的操作。反過來,若先定義的是f1,後定義的是m1,在m1先銷燬時,m1的解構函式也可以正確地刪除所有Folder中指向m1的指標。所以,無論Folder先被銷燬,還是Message先被銷燬,都能夠正確地執行析構操作。使用與上面同樣的test()函式進行測試,程式可以正常地退出了:

這個例子也給了我們又一次提醒:在C++中,指標與複製控制、記憶體管理一定要萬分小心謹慎,一點小的差錯也可能導致程式的災難。

相關文章