天天看點

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

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

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

誤用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。