1. 程式人生 > 程式設計 >如何優雅地管理C++ 中的記憶體

如何優雅地管理C++ 中的記憶體

C++的記憶體管理

C++是一門Native Language,而說到Native Languages就不得不說資源管理,其中記憶體管理又是資源管理中的一個大問題,由於堆記憶體需要手動分配和釋放,所以必須確保申請的記憶體得到正確的釋放。對此一般的原則是"誰分配的誰釋放",但即便如此仍然會出現記憶體洩漏,野指標等問題。

託管語言們為瞭解決這個問題引入了GC(Garbage Collection),它們認為記憶體太重要了,不能交給程式設計師來做。但GC對於Native開發常常有它自己的問題。而其另一方面Native界也常常詬病GC,說記憶體太重要了,不能交給機器做

C++提供了一種折中的解決方案,即:既不是完全交給機器做,也不是完全交給程式設計師做,而是程式設計師現在程式碼中指定怎麼做,至於什麼時候做,如何確保一定會得到執行,則交給編譯器來確定。

首先是C++98提供了語言機制:物件在超出作用域的時候其解構函式會自動被呼叫。接著,C++之父Bjarne Stroustrup定義了RAII(Resoure Acquisition is Initialization)正規化(即:物件構造的時候所需的資源應該在建構函式中初始化,而物件析構的時候應該釋放資源)。RAII 告訴我們應該應用類來封裝和管理資源。

沿著這一思想,首先要介紹的記憶體管理小技巧便是使用智慧指標

智慧指標

對於記憶體管理而言,Boost第一個實現了工業強度的智慧指標,如今的智慧指標(shared_ptr和unique_ptr)已經是C++11中的一部分,簡單來說有了智慧指標,你的C++程式碼幾乎就不應該出現delete了。

雖然智慧指標被稱為”指標“,它的行為像一個指標,但本質上它其實是個類。正如前面所說的:

RAII 告訴我們應該應用類來封裝和管理資源

智慧指標在物件初始化的時候獲取記憶體的控制權,在析構的的時候自動釋放記憶體,來正確的管理記憶體。

C++11中,shared_ptrunique_ptr是最常用的兩個智慧指標,都需要包含標頭檔案<memory>

unique_ptr

unique_ptr是唯一的,適用於儲存動態分配的舊C風格的陣列。 在宣告變數的時候,使用automake_unique搭配效率更高。此外,unique_ptr正如它的名字一樣,一個資源應該只有一個unique_ptr

進行控制,在需要轉移控制權時,應該使用std::move,失去控制權的指標無法再繼續訪問資源。

#include <iostream>
#include <memory>

using namespace std;

int main()
{
    int size = 5;
    auto x1 = make_unique<int[]>(size);
    unique_ptr<int[]> x2(new int[size]);        // 兩種宣告unique_ptr的方式

    x1[0] = 5;
    x2[0] = 10;                                 // 像指標一樣賦值

    for(int i = 0; i < size; ++i)
        cout << x1[i] << endl;                  // 輸出: 5 0 0 0 0

    auto x3 = std::move(x1);                    // 轉移x1的所有權
    for(int i = 0; i < size; ++i)
        cout << x3[i] << endl;                  // 輸出: 5 0 0 0 0

}
複製程式碼

unique_ptr物件在析構的時候釋放所控制的資源,當發生控制權轉移的時,有一種情況特別要注意,即千萬不要將控制權轉移給一個區域性變數。因為區域性變數退出作用域後會被析構,從而釋放資源,此時外部再要訪問一個被釋放的資源時,就會出錯。 下面的例子說明瞭這種情況

#include <iostream>
#include <memory>

using namespace std;

class A
{
public:
    A():a(new int(10))                      // 初始化a為10
    {
        cout << "Create A..." << endl;
    }

    ~A()
    {
        cout << "Destroy A..." << endl;
        delete a;                           // 釋放a
    }

    int* a;
};

void move_unique_ptr_to_local_unique_ptr(unique_ptr<A>& uptr)
{
    auto y(std::move(uptr));                // 轉移所有權
}                                           // 函式結束,y進行析構,便釋放了A的資源

int main()
{
    auto x = make_unique<A>();
    move_unique_ptr_to_local_unique_ptr(x);

    cout << *(x->a) << endl;                  // 記憶體訪問錯誤,x中的資源以及被區域性變數釋放了
}
複製程式碼

shared_ptr

shared_ptr的用法與unique_ptr類似。使用automake_shared搭配效率更高。此外,與unique_ptr不同的是,shared_ptr採用引用計數的方式管理記憶體,因此一個資源可以有多個shared_ptr同時引用,並且在引用計數為0時,釋放資源(引用計數可以用use_count來檢視)

void copy_shared_ptr_to_local_shared_ptr(shared_ptr<A>& sptr)
{
    auto y(sptr);                                                         // 複製shared_ptr,擁有同一片資源
    cout << "After copy,use_count : " << sptr.use_count() << endl;       // After copy,use_count : 2
}

int main()
{
    auto x = make_shared<A>();
    cout << "use_count: " << x.use_count() << endl;          // use_count: 1
    copy_shared_ptr_to_local_shared_ptr(x);
    cout << *(x->a) << endl;                                 // 記憶體未被釋放,可以正常訪問
}
複製程式碼

unique_ptrshared_ptr還可以指定如何釋放記憶體,這大大方便了我們對檔案、socket等資源的管理

#include <iostream>
#include <memory>

using namespace std;

void fclose_deletor(FILE* f)
{
    cout << "close a file" << endl;
    fclose(f);
}

int main()
{
    unique_ptr<FILE,decltype(&fclose_deletor)> file_uptr( fopen("abc.txt","w"),&fclose_deletor);
    shared_ptr<FILE> file_sptr( fopen("abc.txt",fclose_deletor);
}
複製程式碼

智慧指標unique_ptrshared_ptr利用RAII正規化,為我們的記憶體管理提供極大的方便,但是在使用時,存在一些弊端(C++ shared_ptr四宗罪),其中我覺得最令人頭痛的問題就是:介面汙染。

例如,我想傳一個int*到函式中去,由於所有權在智慧指標上,為了保證所有權的正確轉移,我就不得不將函式的引數型別改為unique_ptr<int>。同樣的,返回值也有類似的情況。

上述這種情況,如果在開發初期,明確所有指標都使用智慧指標的話,並不是什麼大問題。但是目前多數程式碼都是建立在舊程式碼的基礎上,在呼叫舊程式碼時,你需要用智慧指標中的get方法來返回所控制的資源。呼叫了get也就意味著智慧指標失去了對資源的完全控制,也就是說,它再也無法保證資源的正確釋放了。

Scope Guard

RAII正規化雖然好,但是還不夠易用,很多時候我們並不想為了一個closeHandle,ReleaseDC等去大張旗鼓的寫一個類出來;智慧指標方便了我們對記憶體的管理,但仍屬於“指標”的範疇,對非指標的資源使用起來不太方便,另外加上介面汙染的問題,所以這些時候我們往往會因為怕麻煩而直接手動去釋放函式,手動調的一個壞處就是,如果在資源申請和資源釋放之間發生了異常,那麼釋放將不會發生。此外,手動釋放需要在函式所有可能的出口都去呼叫釋放函式,萬一某天有人修改了程式碼,多了一個處return,而return之前忘記了呼叫釋放函式,資源就洩露了。理想情況,我們希望能夠這樣使用:

#include <fstream>
using namespace std;

void foo()
{
    fstream file("abc.txt",ios::binary);
    ON_SCOPE_EXIT{ file.close() };
}
複製程式碼

ON_SCOPE_EXIT裡面的程式碼就像在解構函式一樣:無論是以怎樣的方式退出,都比如會被執行

最開始,這種ScopeGuard的想法被提出的時候,由於C++沒有太好的機制來支援這個想法,其實現非常的繁瑣和不完美。再後來,C++11釋出了,結合C++11的Lambda Function和tr1::function就能夠簡化其實現

class ScopeGuard
{
public:
    explicit ScopeGuard(std::function<void()> onExitScope)
        : onExitScope_(onExitScope)
    { }

    ~ScopeGuard()
    {
        onExitScope_();
    }

private:
    std::function<void()> onExitScope_;

private: // noncopyable
    ScopeGuard(ScopeGuard const&) = delete;
    ScopeGuard& operator=(ScopeGuard const&) = delete;
};
複製程式碼

這個類使用非常簡單,你交給他一個std::function,它負責在析構的時候執行,絕大多數這個std::function是一個lambda,例如:

void foo()
{
    fstream file("abc.txt",ios::binary);
    ScopeGuard on_exit([&]{
        file.close();
    });
}
複製程式碼

on_exit在析構的時候會執行file.close。為了避免給這個物件起名字的麻煩,可以定義一個巨集,把行號混入其中,這樣每次定義一個ScopeGuard物件都是唯一命名的:

#define SCOPEGUARD_LINENAME_CAT(name,line) name##line
#define SCOPEGUARD_LINENAME(name,line) SCOPEGUARD_LINENAME_CAT(name,line)
#define ON_SCOPE_EXIT(callback) ScopeGuard SCOPEGUARD_LINENAME(EXIT,__LINE__)(callback)
複製程式碼

自從有了ON_SCOPE_EXIT之後,在C++中申請和釋放資源就變得非常方便啦

fstream file("abc.txt",ios::binary);
ON_SCOPE_EXIT( [&] { file.close(); })

auto* x = new A()
ON_SCOPE_EXIT( [&] { delete x; })
複製程式碼

這麼做的好處在於申請資源和釋放資源的程式碼緊緊的靠在一起,永遠不會忘記.更不用說只要在一個地方寫釋放的程式碼,下文無論發生什麼錯誤,導致該作用域退出,我們都能夠正確的釋放資源啦.

Leaked Object Detector

記憶體洩露最常見的原因就是new了一個資源,忘記delete了,雖然智慧指標和scope guard能夠有效地幫助我們正確地釋放記憶體,但由於種種原因和限制,還是會出現忘記釋放記憶體的問題,如何監控沒有正確釋放的記憶體呢? 也許我們需要一個Leaked Object Detector,讓它在發生洩漏的時候通知我們.

具體的,我們希望能它有這樣的作用:

int main()
{
   auto* x = new A();
} // 報錯,因為沒有delete
複製程式碼

在JUCE的原始碼中,我發現了一個LeakedObjectDetector類,它能夠實現我們想要的。LeakedObjectDetector內部維護了一個計數器,在OwnerClass被建立時,計數器+1,OwnerClass析構時,計數器-1

template <typename OwnerClass>
class LeakedObjectDetector
{
public:
    LeakedObjectDetector() noexcept
    {
        ++(getCounter().num_objects);
    }

    LeakedObjectDetector(const LeakedObjectDetector&) noexcept
    {
        ++(getCounter().num_objects);
    }

    ~LeakedObjectDetector()
    {
        if(--(getCounter().num_objects) < 0)
        {
            cerr << "*** Dangling pointer deletion! Class: " << getLeakedObjectClassName() << endl;

            assert(false);
        }
    }

private:
    class LeakCounter
    {
    public:
        LeakCounter() = default;

        ~LeakCounter()
        {
            if(num_objects > 0)
            {
                cerr << "*** Leaked object detected: " << num_objects << " instance(s) of class" << getLeakedObjectClassName() << endl;
                assert(false);
            }
        }

        atomic<int> num_objects{0};
    };

    static const char* getLeakedObjectClassName()
    {
        return OwnerClass::getLeakedObjectClassName();
    }

    static LeakCounter& getCounter() noexcept
    {
        static LeakCounter counter;
        return counter;
    }
};
複製程式碼

因為計數器是靜態的,它的生命週期是從程式開始到程式結束,因此在程式結束時,計數器做析構,解構函式進行判斷,如果計數器>0,說明有例項被建立但是沒有釋放。

另一個判斷在LeakedObjectDetector的解構函式中,如果計數器<0,說明被delete了多次

另外只要出現了記憶體洩露或者多次delete,就用assert來強制中斷

配合巨集,使用起來就非常方便

#define LINENAME_CAT(name,line) name##line
#define LEAK_DETECTOR(OwnerClass) \
        friend class LeakedObjectDetector<OwnerClass>;  \
        static const char* getLeakedObjectClassName() noexcept { return #OwnerClass; } \
        LeakedObjectDetector<OwnerClass>  LINENAME_CAT(leakDetector,__LINE__);

class A
{
public:
    A() = default;

private:
    LEAK_DETECTOR(A);
};
複製程式碼

只要用上LEAK_DETECTOR(ClassName),就可以監控類的記憶體釋放被正確釋放了,例如

int main()
{
    auto* x = new A();
    return 0;
}
// 忘記delete,出現警告:
// *** Leaked object detected: 1 instance(s) of classA
// Assertion failed: (false),function ~LeakCounter,file /Users/hw/Development/work/leaked_object_detector/main.cpp,line 44.
int main()
{
    auto* x = new A();

    delete x;
    delete x;
    return 0;
}
// 多次delete,出現警告
// *** Dangling pointer deletion! Class: A
// Assertion failed: (false),function ~LeakedObjectDetector,line 29.
複製程式碼

總結

在C++中,記憶體管理是半自動的,你需要告訴程式如何如何做,編譯器保證正確做。在介紹完以上三種記憶體管理的技巧後,這裡做一個小小的總結

  • RAII告訴我們,應該用類將資源進行封裝,保證類初始化時資源得到初始化,類析構時資源得到釋放.因此考慮用vector這樣的類來替代原生的陣列指標
  • 儘可能的使用智慧指標,但是要注意所有權的轉移
  • 用scope guard來管理區域性資源,它能夠保證無論以什麼方式退出作用域,資源都能夠被正確地釋放
  • LeakedObjectDetector能夠監控記憶體釋放正確釋放,在資源洩露時給出警告,如果你擔心它會造成執行效率降低,那麼不必要在所有類上新增它,而是當你懷疑某個類出現了記憶體洩漏時,再加上它