智慧指標和二叉樹(2):資源的自動管理

apocelipes發表於2019-05-07

上一篇文章中我們提到了用智慧指標構建二叉樹來減輕我們的工作負擔。今天我們來討論下稍微複雜的情況下如何藉助智慧指標管理資源。

一般來說,當我們在程式中使用了智慧指標後就無需親自過問資源管理的問題了。然而隨著資料結構和演算法逐漸變得複雜,資源之間的關係也可能不再是簡單的共享,比如下面的例子。

誤用shared_ptr導致記憶體洩露

現在為了方便刪除我們二叉樹的某些節點,我們需要每個節點都包含自己的父節點的資訊,也許你會寫成如下的樣子:

struct BinaryTreeNode: public std::enable_shared_from_this<BinaryTreeNode> {
    using NodeType = std::shared_ptr<BinaryTreeNode>;

    explicit BinaryTreeNode(const int value = 0)
    : value_{value}, left{NodeType{}}, right{NodeType{}}
    {}

    // 插入/搜尋/刪除
    void insert(const int value);
    NodeType search(int value);
    NodeType max();
    NodeType min();
    void remove(int value);
    void ldr();
    void layer_print();

    int value_;
    NodeType parent; // 危險!請勿模仿
    NodeType left;
    NodeType right;

private:
    // methods
};

這樣改寫後的insert方法在插入節點時需要附加上父節點資訊,不過這一步很簡單:

void BinaryTreeNode::insert(const int value)
{
    if (value < value_) {
        if (left) {
            left->insert(value);
        } else {
            left = std::make_shared<BinaryTreeNode>(value);
            // 新增指向父節點的智慧指標
            left->parent = shared_from_this();
        }
    }

    if (value > value_) {
        if (right) {
            right->insert(value);
        } else {
            right = std::make_shared<BinaryTreeNode>(value);
            right->parent = shared_from_this();
        }
    }
}

你可能會覺得這有什麼複雜的,管理資源還是一如既往的輕鬆。

然而你錯了,雖然從編譯到執行我們的程式都沒有肉眼可見的缺陷,然而我們用valgrind診斷一下就能發現問題了:

valgrind ./a.out

智慧指標和二叉樹(2):資源的自動管理

作為對比這是修復後的執行情況:

智慧指標和二叉樹(2):資源的自動管理

可見相比正常情況,有一半的智慧指標並沒被釋放,而我們的層級列印正好正好將所有元素複製了一遍,因此你可能已經意識到了,我們的節點最終並沒有被釋放,但是節點的副本卻被釋放掉了!(valgrind對於記憶體池等快取技術存在一定的誤報,但據我所知對於libstdc++的shared_ptr並未使用這類技術)

這是為什麼呢?答案很簡單,在insert中我們製造了迴圈引用。下面我們拿根節點和它的左子節點做個演示:

智慧指標和二叉樹(2):資源的自動管理

首先是根節點和其左子節點,在沒建立節點關係前兩者引用計數都為1,接著我們建立關係:

智慧指標和二叉樹(2):資源的自動管理

這種現象其實就是迴圈引用問題的一種。 現在問題變得明瞭了,我們是從根節點釋放資源的,根節點釋放後接著釋放它的子節點,但是現在根節點的計數是2,在使用者持有的根節點超出作用域時它的引用計數減去1,變成了1,資源不會被釋放,從而造成了記憶體洩漏,這就是valgrind發出抱怨的原因。

解決辦法也很簡單,因為葉子節點始終是引用計數為1的,所以先從葉子節點開始釋放人工解開迴圈引用即可,然而這樣又要手動管理記憶體與我們“自動”的初衷背道而馳,而且從葉子節點向上釋放資源也不夠直觀,很容易出錯。

因此還有一條路:std::weak_ptr

使用std::weak_ptr消除迴圈引用

weak_ptr如其名,是弱引用,不會增加智慧指標的引用計數,它可以從shared_ptr構造也可以轉換為shared_ptr。

weak_ptr是專門為了類似上一節的情況而設計的,當兩個資料物件之間互相存在引用關係時,如果雙方都使用shared_ptr為代表的強引用勢必會出現麻煩(主流的c++實現都沒有gc,而且編譯器也不會幫你自動切斷迴圈,因此出問題後往往導致記憶體洩露,而且這類問題較為隱蔽所以常常會折磨那些粗心的程式設計師),這就需要將一方的引用形式改為弱引用來避免出現問題,這裡便是weak_ptr。弱引用並不能保證引用的物件是可訪問的,因此我們選擇子節點引用parent的形式為弱引用,因為子節點的生命週期是父節點管理的,父節點生命週期是上層節點或使用者進行管理,不屬於子節點應該干涉的範圍內,因此最適合改為弱引用的形式。

現在我們把結構體修正成如下的樣子:

struct BinaryTreeNode: public std::enable_shared_from_this<BinaryTreeNode> {
    using NodeType = std::shared_ptr<BinaryTreeNode>;

    ...

    int value_;
    std::weak_ptr<BinaryTreeNode> parent; // 解決迴圈引用
    NodeType left;
    NodeType right;

private:
    // methods
};

相應的,insert中的shared_from_this也應該修改為weak_from_this。修改後的節點關係如下圖:

智慧指標和二叉樹(2):資源的自動管理

現在我們可以正常地依賴智慧指標進行資源管理了。而且再也不會聽到valgrind的抱怨了。

因此我們在使用智慧指標時應該仔細地分析資料之間的關係,選擇合理的方案,避免因誤用而產生bug。

相關文章