跳轉到內容

C++ 程式設計/記憶體管理

來自華夏公益教科書,開放的書本,開放的世界

記憶體管理是一個龐大的主題,C++ 提供了多種選擇來管理記憶體(以及其他資源,但我們最初將重點放在記憶體上)。

好訊息是,現代 C++ 使得大多數情況下記憶體管理變得簡單明瞭,同時為那些需要偏離常規路徑的人提供全面的工具。我們將涵蓋高階方法(通常更可取),並詳細介紹低階方面,例如使用 new/delete/new[]/delete[],這些方面通常最好隱藏在實現高階模式的類內部。

垃圾回收和 RAII

[編輯 | 編輯原始碼]

垃圾回收(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++ 程式的簡單比較。這兩個示例都省略了錯誤處理,這些處理將出現在實際程式碼中。

首先,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->* 的類型別。需要注意的一點是,“智慧指標”從某種意義上來說,實際上並不是指標——但過載這些運算子允許智慧指標的行為非常類似於內建指標,並且可以使用許多與“真實”指標和智慧指標都可以工作的程式碼。

std::auto_ptr
[編輯 | 編輯原始碼]

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 指標之一是一個更好的選擇。


Clipboard

待辦事項

  • 提供 auto_ptr 同樣用途的示例。(已部分完成)
  • 警告不要在類中的成員變數中天真地使用 auto_ptr。包括一個不安全使用的示例,並展示如何使其安全。可能需要參考三法則(在這種情況下,我們需要對複製操作做一些特別的事情,但不需要對解構函式做任何特別的事情)。
  • 注意,auto_ptr 不能與不完整型別一起使用,因此它對於 pimpl 等沒有用。
  • 注意,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;
};

這個實現非常基本,只提供基本功能,並且存在很多嚴重的問題,例如複製這個智慧指標會導致雙重刪除,但我們在這裡不討論這些問題。


Clipboard

待辦事項
舉一個簡單的例子,也許是一個 scoped_ptr 的基本版本。


其他智慧指標
[編輯 | 編輯原始碼]

除了 auto_ptr 之外,還有許多其他智慧指標,可以用於完成從包裝 COM 物件到提供多執行緒訪問的自動同步或提供資料庫介面的事務管理等任務。

Boost 庫是許多這些智慧指標的一個很好的儲存庫;Boost 中的一些智慧指標包含在 C++ 委員會的“TR1”中,這是一組與標準 C++ 整合良好的庫元件。


Clipboard

待辦事項

  • 提及其他智慧指標來源。
  • 新增 c++11 指標資訊,例如 unique_ptr、shared_ptr 等。


使用 new、delete 等進行手動記憶體管理

[編輯 | 編輯原始碼]

現代 C++ 程式碼往往很少使用new,也很少使用delete。從記憶體的角度來看,它的缺點是“new”從堆中分配記憶體,而區域性物件從棧中分配記憶體。堆分配時間比棧分配時間慢得多。但是,仍然有一些情況下需要這樣做,並且深入瞭解這些低階功能的工作原理有助於理解通常在“幕後”發生的事情。甚至在某些情況下,newdelete 的級別太高,我們需要降級到mallocfree——但這些情況的確是罕見的例外。

newdelete 的基本思想很簡單:new 建立一個給定型別的物件,並返回指向它的指標,而 delete 銷燬由 new 建立的物件,並返回指向它的指標。newdelete 在語言中存在的原因是,程式碼在編譯時通常不知道它將在執行時需要建立哪些物件,或者需要建立多少個物件。因此,newdelete 表示式允許對物件進行“動態”分配。

示例
int main() {
  int * p = new int(3);
  int * q = p;
  delete q; // the same as delete p
  return 0;
}

不幸的是,很難用幾行程式碼編寫一個現實的例子;只有在更簡單的方法不起作用時,才需要動態分配,例如因為一個物件需要超出函式的範圍,或者因為它使用的記憶體太多,以至於我們只想按需建立它。


Clipboard

待辦事項
將下面的段落(比較 new/delete 和 malloc/free)放在某種側邊欄中或以其他方式與主文字隔開會很好,因為它主要針對具有 C 背景的人。我不知道使用什麼好的標記來實現這一點。


對於熟悉 C 程式語言的讀者來說,newmalloc的一種“型別感知”版本:表示式“new int”的型別是“int*”。因此,在 C++ 中,需要進行強制轉換才能編寫int * p = reinterpret_cast<int *>(malloc(sizeof *p));,而在使用new時不需要進行強制轉換。因為 new 是型別感知的,所以它還可以初始化新建立的物件,如果需要的話,呼叫建構函式。上面的示例使用了這種能力來初始化建立的int,使其值為 3。與mallocfree相比,newdelete的另一個改進是,C++ 標準提供了一種標準方法來更改newdelete分配記憶體的方式;在 C 中,這通常是透過一種稱為“插樁”的非標準技術來實現的。

注意
在指向它的指標(智慧指標除外)超出範圍之前,必須釋放所有動態分配的記憶體。因此,如果記憶體是在函式中的一個變數中動態分配的,則應該在函式中釋放該記憶體,除非將指向該記憶體的指標返回或由該函式儲存。


Clipboard

待辦事項

  • 使用一個簡單的非泛型侵入式連結串列作為 new/delete 使用的更現實的示例。
  • 談談刪除後是否將指標設定為 NULL 的背後考慮因素;討論為什麼 delete 不能這樣做。請注意,指標的值在傳遞給 delete 之後不能再使用。


基本的newdelete運算子旨在一次只分配一個物件;它們由new[]delete[]補充,用於動態分配整個陣列。new[]delete[]的使用比基本的 new 和 delete 還要少見;通常,std::vector 是管理動態分配陣列的更方便的方法。

請注意,當您動態分配一個物件陣列時,您必須在釋放時編寫delete[],而不是簡單的delete。編譯器通常無法在您出錯時給出錯誤;最有可能的是您的程式碼在執行時會崩潰。

注意
請記住,不要嘗試使用 C free 函式釋放使用new分配的記憶體,也不要使用delete釋放使用 C malloc 函式分配的記憶體。這可能會導致資料結構損壞和意外結果。

當呼叫delete[]時,它首先檢索由new[]儲存的資訊,這些資訊描述了動態分配的陣列中存在多少個元素,然後在釋放記憶體之前呼叫每個元素的解構函式。分配的記憶體塊的實際地址可能與由new[]返回的值不同,以便留出空間來儲存元素數量;這是意外混淆new[]的陣列形式與delete的單元素形式可能導致崩潰的原因之一。

注意
僅出於歷史原因:最初,在使用delete[]時,需要在[]中指定陣列中的元素數量,例如delete [number_of_elements] pointer_to_first_element;,但經驗表明這太容易出錯,因此new[]發生了改變,以便記錄分配的元素數量,以便以後可以由呼叫delete[]自動檢索。現在,C++ 語法不允許顯式指定元素數量。

特別敏銳的讀者可能會想知道是否有可能消除記住使用哪一個new/new[]delete/delete[]的需要,而是讓編譯器來弄清楚。答案是可以的,但這樣做會給每個單物件分配增加開銷(因為 delete 需要能夠確定分配是用於單個物件還是陣列),而 C++ 的一個設計原則一直是“不使用的東西就不必為它付費”,因此所做的權衡是使單物件分配保持高效,但使用者在使用這些低階功能時必須小心。


Clipboard

待辦事項
記錄operator newoperator delete,並嘗試解釋術語混淆。談論“new 表示式”很清楚,但“new 運算子”這個短語可能模稜兩可,並且經常被誤解為“operator new”。


常見錯誤

[編輯 | 編輯原始碼]
typedef 的使用
[編輯 | 編輯原始碼]

閱讀以下(有錯誤的)程式碼。

...
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** 後面的普通陣列。


Clipboard

待辦事項

  • 記錄常見的錯誤以及症狀。雙重釋放,記憶體損壞。還要注意,有時呼叫 new 或 delete 時發生的崩潰是由程式碼其他地方的記憶體損壞造成的,並提及可以使用工具來幫助解決這些問題。舉例說明此類工具,包括開源工具和商業工具。
  • 可以考慮提及 new[] 在元素具有平凡解構函式的情況下不需要記住元素數量的可能最佳化,這也解釋了為什麼不能從使用者程式碼中呼叫函式來查詢分配的元素數量;它可能沒有儲存在任何地方。或者,這對於本書來說可能過於詳細。您的意見如何?
華夏公益教科書