如何優雅地管理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_ptr
和unique_ptr
是最常用的兩個智慧指標,都需要包含標頭檔案<memory>
unique_ptr
unique_ptr
是唯一的,適用於儲存動態分配的舊C風格的陣列。
在宣告變數的時候,使用auto
和make_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
類似。使用auto
和make_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_ptr
和shared_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_ptr
和shared_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
能夠監控記憶體釋放正確釋放,在資源洩露時給出警告,如果你擔心它會造成執行效率降低,那麼不必要在所有類上新增它,而是當你懷疑某個類出現了記憶體洩漏時,再加上它