C++ 程式設計/記憶體管理
記憶體管理是一個龐大的主題,C++ 提供了多種選擇來管理記憶體(以及其他資源,但我們最初將重點放在記憶體上)。
好訊息是,現代 C++ 使得大多數情況下記憶體管理變得簡單明瞭,同時為那些需要偏離常規路徑的人提供全面的工具。我們將涵蓋高階方法(通常更可取),並詳細介紹低階方面,例如使用 new/delete/new[]/delete[],這些方面通常最好隱藏在實現高階模式的類內部。
垃圾回收(GC)處理動態記憶體的管理,具有不同程度的自動化,其中稱為收集器的構造試圖回收垃圾(由應用程式物件使用但永遠不會被訪問或修改的記憶體)。這通常被認為是近些年語言的一個重要特性,特別是如果它們禁止手動記憶體管理,因為手動記憶體管理很容易出錯,因此需要程式設計師具有很高的經驗。由於記憶體管理而導致的錯誤主要會導致不穩定和崩潰,這些錯誤只會在執行時才被發現,這使得它們難以檢測和糾正。
C++ 可選擇支援垃圾回收,並且某些實現包括垃圾回收(通常基於所謂的 Boehm 收集器)。C++ 標準定義了該語言的實現及其底層平臺,但允許包含擴充套件。例如,Sun 的 C++ 編譯器產品確實包含libgc庫(一個保守的垃圾收集器)。
與許多高階語言不同,C++ 並不強制使用垃圾回收,並且主流 C++ 記憶體管理習慣用法並不假定使用傳統的自動垃圾回收。C++ 中最常見的垃圾回收方法是使用奇怪命名的習語“RAII”,它代表“資源獲取即初始化”,此習語在本書的 RAII 部分 中介紹。RAII 的核心思想是,無論是在初始化時獲取還是在其他時間獲取,資源都由一個物件擁有,並且該物件的解構函式將在適當的時間自動釋放該資源。這使得 C++ 透過 RAII 支援對資源的確定性清理,因為用於釋放記憶體的相同方法也可以用於釋放其他資源(檔案控制代碼、互斥鎖、資料庫連線、事務以及更多)。
在沒有預設垃圾回收的情況下,RAII 是一種可靠的方法,可以確保即使在可能導致異常丟擲的程式碼中也不發生資源洩漏。它可以說比 Java 和類似語言中的finally 結構更好;當一個類擁有一個資源時,Java 要求該類的每個使用者都將它的使用封裝在 try/finally 塊中。在 C++ 中,類提供了一個解構函式,該類的使用者無需執行任何操作,只需確保在他們完成使用物件後銷燬該物件(這通常不需要任何工作,例如,在物件是區域性變數或另一個物件的成員變數的情況下)。
對於常見應用程式,適當的類已經編寫完成:std::string 和 std::vector(以及其他標準容器,例如 std::map 和 std::list)涵蓋了記憶體管理的許多簡單情況。
許多從 C 轉向 C++ 的程式設計師習慣於進行手動記憶體管理,特別是對於字串操作。
以下是對具有類似功能的 C 程式和 C++ 程式的簡單比較。這兩個示例都省略了錯誤處理,這些處理將出現在實際程式碼中。
首先,C 程式碼(使用 C99,但可以輕鬆更改為與 C90 相容)
#include <stdio.h> // for puts, getchar, stdin
#include <stdlib.h> // for malloc and free
char *getstr(int minlen, int inc) // minlen - Minimum length, inc - Increment of length
{
int index;
int ch;
char *str = malloc(minlen);
for (index = 0; (ch = getchar()) != EOF && ch != '\n'; index++)
{
if (index >= minlen - 1)
{
minlen += inc;
str = realloc(str, minlen);
}
str[index] = (char)ch;
}
str[index] = 0; // mark end of string
return str;
}
int main()
{
char* name;
puts("Please enter your full name: ");
name = getstr(10, 10); // 10, 10 are arbitrary
printf("Hello %s\n", name);
free(name);
return 0;
}
為了比較,C++ 程式碼
#include <string> // for std::string and std::getline
#include <iostream> // for std::cin and std::cout
int main() {
std::string name;
std::cout << "Please enter your full name: ";
std::getline(std::cin, name);
std::cout << "Hello " << name << '\n';
return 0;
}
C++ 版本更短,不包含任何明確的程式碼來計算要分配的記憶體量、分配或釋放記憶體,也不需要了解 'getstr()' 的實現細節;標準字串類負責所有這些工作。C++ 版本還捕獲記憶體分配失敗,而上面顯示的 C 版本需要在 realloc 的結果上進行額外的檢查,以便在記憶體不足的情況下保持安全。
雖然智慧指標在 C++ 中有比簡單記憶體管理更多的用途,但它們通常是用於管理其他動態分配物件的生存期的有效方法。
智慧指標型別被定義為任何過載 operator->、operator* 或 operator->* 的類型別。需要注意的一點是,“智慧指標”從某種意義上來說,實際上並不是指標——但過載這些運算子允許智慧指標的行為非常類似於內建指標,並且可以使用許多與“真實”指標和智慧指標都可以工作的程式碼。
2003 年 C++ 標準中包含的唯一智慧指標型別是 std::auto_ptr。雖然它有一定的用途,但它並不是最優雅或最強大的智慧指標設計。
提供以下功能:
- 模擬實際動態分配物件的區域性變數或成員變數的生存期
- 提供一種機制,用於將物件的“所有權”從一個所有者轉移到另一個所有者。
- 簡單的 auto_ptr 示例
#include <memory> // for std::auto_ptr
#include <iostream>
class Simple {
public:
std::auto_ptr<int> theInt;
Simple() : theInt(new int()) {
*theInt = 3; //get object like normal pointer
}
int f() {
return 42;
}
// when this class is destroyed, theInt will
// automatically be freed
};
int main() {
std::auto_ptr<Simple> simple(new Simple());
// note that the following won't work:
// std::auto_ptr<Simple> simple = new Simple();
// as auto_ptr can only be constructed with new values
// access member functions like normal pointers
std::cout << simple->f();
// the Simple object is freed when simple goes out of scope
return 0;
}
auto_ptr 中的 = 運算子的工作方式不同於普通方式。它所做的是將所有權從 rhs(右側)auto_ptr 轉移到 lhs(左側)auto_ptr。rhs 指標將指向 NULL,它指向的物件將被釋放。
- 例如
#include <memory>
#include <iostream>
int main() {
std::auto_ptr<int> a(new int(3));
// a.get() returns the raw pointer of a
std::cout << "a loc: " << a.get() << '\n';
std::cout << "a val: " << *a << '\n';
std::auto_ptr<int> b;
b = a; // now b points to the int, a is null
std::cout << "b loc: " << b.get() << '\n';
std::cout << "b val: " << *b << '\n';
std::cout << "a loc: " << a.get() << '\n';
return 0;
}
- 輸出(示例)
a loc: 0x3d5ef8 a val: 3 b loc: 0x3d5ef8 b val: 3 a loc: 0
有時,一個物件永遠不會被釋放可能並不明顯。考慮以下示例
- 記憶體洩漏
#include <memory>
#include <iostream>
class Sample {
public:
int value;
Sample(): value(42) {
std::cout << "The object is allocated.\n";
}
~Sample() {
std::cout << "The object is going to be deallocated.\n";
}
};
int main() {
// the object is allocated on the heap
// but cannot be deallocated
// since there's no pointer to it
std::cout << (new Sample)->value << "\n";
// destructor ~Sample is never called
}
- 輸出
The sample class is allocated. 42
可以使用 auto_ptr 修復記憶體洩漏
// the rest of the code stays the same
int main() {
std::cout << (std::auto_ptr<Sample>(new Sample))->value << "\n";
}
注意,有時可以在堆疊上分配一個物件,避免這樣的問題。
總而言之,auto_ptr 的行為在希望只有一個指標始終指向特定物件,但指向它的指標可能會更改時很有用。如果需要不同的行為,使用 Boost 指標之一是一個更好的選擇。
Boost c++ 庫包含 5 種不同型別的智慧指標,這些指標連同 std::auto_ptr,幾乎可以在所有記憶體管理情況下使用。此外,Boost 中的一些智慧指標將在釋出的 C++0x 版本中成為標準庫的一部分。
- Boost 和 std 智慧指標
| 指標 | 使用情況 | 效能成本 | 所有權轉移 | 共享物件 | 適用於 | 其他 |
|---|---|---|---|---|---|---|
| std::auto_ptr | 在給定時間,一個物件只能由一個 auto_ptr 擁有,但該所有者可以更改 | nil | 是 | 否 | 單例項 | 不適用於標準容器(std::vector 等) |
| boost::scoped_ptr | 如果將一個物件分配給scoped_ptr,它就永遠無法分配給另一個指標。 | 空 | 否 | 否 | 單例項 | 如果用作類的成員,則必須在建構函式中分配。此外,它不適用於標準容器(例如 std::vector)。 |
| boost::shared_ptr | 多個 shared_ptr 可以指向同一個物件,當所有 shared_ptr 都超出範圍時,該物件將被銷燬。 | 是的,它使用引用計數。 | 是 | 是 | 單例項 | 適用於標準容器。 |
| boost::weak_ptr | 與 shared_ptr 一起使用,以打破可能導致記憶體洩漏的迴圈。要使用,必須將其轉換為 shared_ptr。 | 與 shared_ptr 相同。 | 是 | 是 | 單例項 | 僅與 shared_ptr 一起使用。 |
| boost::scoped_array | 與 scoped_ptr 相同,但適用於陣列。 | 空 | 否 | 否 | 例項陣列 | 參見 scoped_ptr。 |
| boost::shared_array | 與 shared_ptr 相同,但適用於陣列。 | 是的,它使用引用計數。 | 是 | 是 | 例項陣列 | 參見 shared_ptr。 |
| boost::intrusive_ptr | 用於為具有自己引用計數的物件建立自定義智慧指標。 | 取決於實現。 | 是 | 是的 | 單個例項 | 在大多數情況下,應使用 shared_ptr 代替它。 |
使用智慧指標的原因之一是為了避免記憶體洩漏。為了避免這種情況,我們應該避免手動管理堆基記憶體。因此,我們必須找到一個容器,當我們不再使用它時,它可以自動將記憶體返回給作業系統。類的解構函式可以滿足這一要求。
當然,我們需要在一個基本智慧指標中儲存的是分配記憶體的地址。為此,我們可以簡單地使用一個指標。假設我們正在設計一個用於儲存int記憶體片的智慧指標。
class smt_ptr
{
private:
int* ptr;
};
為了確保每個使用者在初始化時都將一個地址放入該智慧指標中,我們必須指定建構函式來接受一個帶有目標地址作為引數的智慧指標宣告,而不是智慧指標本身的“簡單宣告”。
class smt_ptr
{
public:
explicit smt_ptr(int* pointer) : ptr(pointer) { }
private:
int* ptr;
};
現在,我們必須指定該類在該智慧指標的例項析構時“刪除”該指標。
class smt_ptr
{
public:
explicit smt_ptr(int* pointer) : ptr(pointer) { }
~smt_ptr() { delete ptr; }
private:
int* ptr;
};
我們必須允許使用者訪問儲存在該智慧指標中的資料,並使其更像“指標”。為此,我們可以新增一個函式來提供對原始指標的訪問,並重載一些運算子,例如operator*和operator->,使其行為像一個真正的指標。
class smt_ptr
{
public:
explicit smt_ptr(int* pointer) : ptr(pointer) { }
~smt_ptr() { delete ptr; }
int* get() const { return ptr; } // Declares these functions const to indicate that
int* operator->() const { return ptr; } // there is no modification to the data members.
int& operator*() const { return *ptr; }
private:
int* ptr;
};
實際上,我們已經完成了基本部分,它已經可以使用了,但是,為了使這個“自制”的智慧指標與其他資料型別和類一起使用,我們必須將其轉換為類模板。
template<typename T>
class smt_ptr
{
public:
explicit smt_ptr(T* pointer) : ptr(pointer) { }
~smt_ptr() { delete ptr; }
T* get() const { return ptr; }
T* operator->() const { return ptr; }
T& operator*() const { return *ptr; }
private:
T* ptr;
};
這個實現非常基本,只提供基本功能,並且存在很多嚴重的問題,例如複製這個智慧指標會導致雙重刪除,但我們在這裡不討論這些問題。
除了 auto_ptr 之外,還有許多其他智慧指標,可以用於完成從包裝 COM 物件到提供多執行緒訪問的自動同步或提供資料庫介面的事務管理等任務。
Boost 庫是許多這些智慧指標的一個很好的儲存庫;Boost 中的一些智慧指標包含在 C++ 委員會的“TR1”中,這是一組與標準 C++ 整合良好的庫元件。
現代 C++ 程式碼往往很少使用new,也很少使用delete。從記憶體的角度來看,它的缺點是“new”從堆中分配記憶體,而區域性物件從棧中分配記憶體。堆分配時間比棧分配時間慢得多。但是,仍然有一些情況下需要這樣做,並且深入瞭解這些低階功能的工作原理有助於理解通常在“幕後”發生的事情。甚至在某些情況下,new和delete 的級別太高,我們需要降級到malloc和free——但這些情況的確是罕見的例外。
new和delete 的基本思想很簡單:new 建立一個給定型別的物件,並返回指向它的指標,而 delete 銷燬由 new 建立的物件,並返回指向它的指標。new和delete 在語言中存在的原因是,程式碼在編譯時通常不知道它將在執行時需要建立哪些物件,或者需要建立多少個物件。因此,new和delete 表示式允許對物件進行“動態”分配。
- 示例
int main() {
int * p = new int(3);
int * q = p;
delete q; // the same as delete p
return 0;
}
不幸的是,很難用幾行程式碼編寫一個現實的例子;只有在更簡單的方法不起作用時,才需要動態分配,例如因為一個物件需要超出函式的範圍,或者因為它使用的記憶體太多,以至於我們只想按需建立它。
對於熟悉 C 程式語言的讀者來說,new是malloc的一種“型別感知”版本:表示式“new int”的型別是“int*”。因此,在 C++ 中,需要進行強制轉換才能編寫int * p = reinterpret_cast<int *>(malloc(sizeof *p));,而在使用new時不需要進行強制轉換。因為 new 是型別感知的,所以它還可以初始化新建立的物件,如果需要的話,呼叫建構函式。上面的示例使用了這種能力來初始化建立的int,使其值為 3。與malloc和free相比,new和delete的另一個改進是,C++ 標準提供了一種標準方法來更改new和delete分配記憶體的方式;在 C 中,這通常是透過一種稱為“插樁”的非標準技術來實現的。
基本的new和delete運算子旨在一次只分配一個物件;它們由new[]和delete[]補充,用於動態分配整個陣列。new[]和delete[]的使用比基本的 new 和 delete 還要少見;通常,std::vector 是管理動態分配陣列的更方便的方法。
請注意,當您動態分配一個物件陣列時,您必須在釋放時編寫delete[],而不是簡單的delete。編譯器通常無法在您出錯時給出錯誤;最有可能的是您的程式碼在執行時會崩潰。
當呼叫delete[]時,它首先檢索由new[]儲存的資訊,這些資訊描述了動態分配的陣列中存在多少個元素,然後在釋放記憶體之前呼叫每個元素的解構函式。分配的記憶體塊的實際地址可能與由new[]返回的值不同,以便留出空間來儲存元素數量;這是意外混淆new[]的陣列形式與delete的單元素形式可能導致崩潰的原因之一。
特別敏銳的讀者可能會想知道是否有可能消除記住使用哪一個new/new[]和delete/delete[]的需要,而是讓編譯器來弄清楚。答案是可以的,但這樣做會給每個單物件分配增加開銷(因為 delete 需要能夠確定分配是用於單個物件還是陣列),而 C++ 的一個設計原則一直是“不使用的東西就不必為它付費”,因此所做的權衡是使單物件分配保持高效,但使用者在使用這些低階功能時必須小心。
閱讀以下(有錯誤的)程式碼。
...
typedef char CStr[100];
...
void foo()
{
...
char* a_string = new CStr;
...
delete a_string;
return;
}
上面的程式碼會導致資源洩漏(或者在某些情況下導致崩潰)。常見的錯誤是使用 **delete** 釋放一塊記憶體陣列,而不是使用 "array delete"(即 **delete[]**)。在這種情況下,**typedef** 會讓人誤以為 "a_string" 是一個指向一塊記憶體的指標,這塊記憶體足夠容納一個 "**char**" 變數,但不足以容納一個記憶體陣列。錯誤地執行 **delete**,而不是 **delete[]**,只會釋放分配給陣列第一個元素的記憶體,而會讓另外 99 個 "**char**" 元素的記憶體洩漏。在這種情況下,只會洩漏 99 位元組,但是當陣列用來存放包含很多非靜態資料成員的複雜類時,就會洩漏數兆位元組的記憶體。此外,當包含此錯誤的同一程式再次執行時,將會洩漏另一塊記憶體。
因此,上面的程式碼
delete a_string;
應該更正為
delete[] a_string;
或者,更理想的是,使用像 std::string 這樣的字串類,而不是隱藏在 **typedef** 後面的普通陣列。
