C-- 爲什麼不加入垃圾回收機制

來源:http://www.codeceo.com/article/why-cpp-not-use-gc.html

作者:M - 先生

Java 的愛好者們經常批評 C++ 中沒有提供與 Java 類似的垃圾回收 (Gabage Collector) 機制 (這很正常,正如 C++ 的愛好者有時也攻擊 Java 沒有這個沒有那個,或者這個不行那個不夠好),導致 C++ 中對動態存儲的官吏稱爲程序員的噩夢,不是嗎?你經常聽到的是內存遺失(memory leak) 和非法指針存取,這一定令你很頭疼,而且你又不能拋棄指針帶來的靈活性。

在本文中,我並不想揭露 Java 提供的垃圾回收機制的天生缺陷,而是指出了 C++ 中引入垃圾回收的可行性。請讀者注意,這裏介紹的方法更多的是基於當前標準和庫設計的角度,而不是要求修改語言定義或者擴展編譯器。

什麼是垃圾回收?

作爲支持指針的編程語言,C++ 將動態管理存儲器資源的便利性交給了程序員。在使用指針形式的對象時 (請注意,由於引用在初始化後不能更改引用目標的語言機制的限制,多態性應用大多數情況下依賴於指針進行),程序員必須自己完成存儲器的分配、使用和釋放,語言本身在此過程中不能提供任何幫助,也許除了按照你的要求正確的和操作系統親密合作,完成實際的存儲器管理。標準文本中,多次提到了 “未定義 (undefined)”,而這大多數情況下和指針相關。

某些語言提供了垃圾回收機制,也就是說程序員僅負責分配存儲器和使用,而由語言本身負責釋放不再使用的存儲器,這樣程序員就從討厭的存儲器管理的工作中脫身了。然而 C++ 並沒有提供類似的機制,C++ 的設計者 Bjarne Stroustrup 在我所知的唯一一本介紹語言設計的思想和哲學的著作《The Design and Evolution of C++》(中譯本:C++ 語言的設計和演化) 中花了一個小節討論這個特性。簡而言之,Bjarne 本人認爲,

“我有意這樣設計 C++,使它不依賴於自動垃圾回收 (通常就直接說垃圾回收)。這是基於自己對垃圾回收系統的經驗,我很害怕那種嚴重的空間和時間開銷,也害怕由於實現和移植垃圾回收系統而帶來的複雜性。還有,垃圾回收將使 C++ 不適合做許多底層的工作,而這卻正是它的一個設計目標。但我喜歡垃圾回收的思想,它是一種機制,能夠簡化設計、排除掉許多產生錯誤的根源。

需要垃圾回收的基本理由是很容易理解的:用戶的使用方便以及比用戶提供的存儲管理模式更可靠。而反對垃圾回收的理由也有很多,但都不是最根本的,而是關於實現和效率方面的。

已經有充分多的論據可以反駁:每個應用在有了垃圾回收之後會做的更好些。類似的,也有充分的論據可以反對:沒有應用可能因爲有了垃圾回收而做得更好。

並不是每個程序都需要永遠無休止的運行下去;並不是所有的代碼都是基礎性的庫代碼;對於許多應用而言,出現一點存儲流失是可以接受的;許多應用可以管理自己的存儲,而不需要垃圾回收或者其他與之相關的技術,如引用計數等。

我的結論是,從原則上和可行性上說,垃圾回收都是需要的。但是對今天的用戶以及普遍的使用和硬件而言,我們還無法承受將 C++ 的語義和它的基本庫定義在垃圾回收系統之上的負擔。”

以我之見,統一的自動垃圾回收系統無法適用於各種不同的應用環境,而又不至於導致實現上的負擔。稍後我將設計一個針對特定類型的可選的垃圾回收器,可以很明顯地看到,或多或少總是存在一些效率上的開銷,如果強迫 C++ 用戶必須接受這一點,也許是不可取的。

關於爲什麼 C++ 沒有垃圾回收以及可能的在 C++ 中爲此做出的努力,上面提到的著作是我所看過的對這個問題敘述的最全面的,儘管只有短短的一個小節的內容,但是已經涵蓋了很多內容,這正是 Bjarne 著作的一貫特點,言簡意賅而內韻十足。

下面一步一步地向大家介紹我自己土製佳釀的垃圾回收系統,可以按照需要自由選用,而不影響其他代碼。

構造函數和析構函數

C++ 中提供的構造函數和析構函數很好的解決了自動釋放資源的需求。Bjarne 有一句名言,“資源需求就是初始化 (Resource Inquirment Is Initialization)”。

因此,我們可以將需要分配的資源在構造函數中申請完成,而在析構函數中釋放已經分配的資源,只要對象的生存期結束,對象請求分配的資源即被自動釋放。

那麼就僅剩下一個問題了,如果對象本身是在自由存儲區 (Free Store,也就是所謂的“堆”) 中動態創建的,並由指針管理(相信你已經知道爲什麼了),則還是必須通過編碼顯式的調用析構函數,當然是藉助指針的 delete 表達式。

智能指針

幸運的是,出於某些原因,C++ 的標準庫中至少引入了一種類型的智能指針,雖然在使用上有侷限性,但是它剛好可以解決我們的這個難題,這就是標準庫中唯一的一個智能指針::std::auto_ptr。

它將指針包裝成了類,並且重載了反引用 (dereference) 運算符 operator * 和成員選擇運算符 operator ->,以模仿指針的行爲。關於 auto_ptr 的具體細節,參閱《The C++ Standard Library》(中譯本:C++ 標準庫)。

例如以下代碼,

#include <cstring>
#include <memory>
#include <iostream>

class string
{
public:
    string(const char* cstr) { _data=new char [ strlen(cstr)+1 ]; strcpy(_data, cstr); }
    ~string() { delete [] _data; }
    const char* c_str() const { return _data; }
private:
    char* _data;
};

void foo()
{
    ::std::auto_ptr <string> str ( new string( " hello " ) );
    ::std::cout << str->c_str() << ::std::endl;
}

由於 str 是函數的局部對象,因此在函數退出點生存期結束,此時 auto_ptr 的析構函數調用,自動銷燬內部指針維護的 string 對象 (先前在構造函數中通過 new 表達式分配而來的),並進而執行 string 的析構函數,釋放爲實際的字符串動態申請的內存。在 string 中也可能管理其他類型的資源,如用於多線程環境下的同步資源。下圖說明了上面的過程。

現在我們擁有了最簡單的垃圾回收機制 (我隱瞞了一點,在 string 中,你仍然需要自己編碼控制對象的動態創建和銷燬,但是這種情況下的準則極其簡單,就是在構造函數中分配資源,在析構函數中釋放資源,就好像飛機駕駛員必須在起飛後和降落前檢查起落架一樣。),即使在 foo 函數中發生了異常,str 的生存期也會結束,C++ 保證自然退出時發生的一切在異常發生時一樣會有效。

auto_ptr 只是智能指針的一種,它的複製行爲提供了所有權轉移的語義,即智能指針在複製時將對內部維護的實際指針的所有權進行了轉移,例如

auto_ptr <string> str1( new string( <str1> ) );
cout << str1->c_str();
auto_ptr <string> str2(str1); // str1內部指針不再指向原來的對象
cout << str2->c_str();
cout << str1->c_str(); // 未定義,str1內部指針不再有效

某些時候,需要共享同一個對象,此時 auto_ptr 就不敷使用,由於某些歷史的原因,C++ 的標準庫中並沒有提供其他形式的智能指針,走投無路了嗎?

另一種智能指針

但是我們可以自己製作另一種形式的智能指針,也就是具有值複製語義的,並且共享值的智能指針。

需要同一個類的多個對象同時擁有一個對象的拷貝時,我們可以使用引用計數 (Reference Counting/Using Counting) 來實現,曾經這是一個 C++ 中爲了提高效率與 COW(copy on write,改寫時複製)技術一起被廣泛使用的技術,後來證明在多線程應用中,COW 爲了保證行爲的正確反而導致了效率降低(Herb Shutter 的在 C++ Report 雜誌中的 Guru 專欄以及整理後出版的《More Exceptional C++》中專門討論了這個問題)。

然而對於我們目前的問題,引用計數本身並不會有太大的問題,因爲沒有牽涉到複製問題,爲了保證多線程環境下的正確,並不需要過多的效率犧牲,但是爲了簡化問題,這裏忽略了對於多線程安全的考慮。

首先我們仿造 auto_ptr 設計了一個類模板 (出自 Herb Shutter 的《More Execptional C++》),

template <typename T>
class shared_ptr
{
private:
  class implement  // 實現類,引用計數
  {
  public:
    implement(T* pp):p(pp),refs(1){}

    ~implement(){delete p;}

    T* p; // 實際指針
    size_t refs; // 引用計數
  };
  implement* _impl;

public:
  explicit shared_ptr(T* p)
    :  _impl(new implement(p)){}

  ~shared_ptr()
  {
    decrease();  // 計數遞減
  }

  shared_ptr(const shared_ptr& rhs)
    :  _impl(rhs._impl)
  {
    increase();  // 計數遞增
  }

  shared_ptr& operator=(const shared_ptr& rhs)
  {
    if (_impl != rhs._impl)  // 避免自賦值
    {
      decrease();  // 計數遞減,不再共享原對象
      _impl=rhs._impl;  // 共享新的對象
      increase();  // 計數遞增,維護正確的引用計數值
    }
    return *this;
  }

  T* operator->() const
  {
    return _impl->p;
  }

  T& operator*() const
  {
    return *(_impl->p);
  }

private:
  void decrease()
  {
    if (--(_impl->refs)==0)
    {  // 不再被共享,銷燬對象
      delete _impl;
    }
  }

  void increase()
  {
    ++(_impl->refs);
  }
};

這個類模板是如此的簡單,所以都不需要對代碼進行太多地說明。這裏僅僅給出一個簡單的使用實例,足以說明 shared_ptr 作爲簡單的垃圾回收器的替代品。

void foo1(shared_ptr <int>& val)
{
  shared_ptr <int> temp(val);
  *temp=300;
}

void foo2(shared_ptr <int>& val)
{
  val=shared_ptr <int> ( new int(200) );
}

int main()
{
  shared_ptr <int> val(new int(100));
  cout<<"val="<<*val;
  foo1(val);
  cout<<"val="<<*val;
  foo2(val);
  cout<<"val="<<*val;
}

在 main() 函數中,先調用 foo1(val),函數中使用了一個局部對象 temp,它和 val 共享同一份數據,並修改了實際值,函數返回後,val 擁有的值同樣也發生了變化,而實際上 val 本身並沒有修改過。

然後調用了 foo2(val),函數中使用了一個無名的臨時對象創建了一個新值,使用賦值表達式修改了 val,同時 val 和臨時對象擁有同一個值,函數返回時,val 仍然擁有這正確的值。

最後,在整個過程中,除了在使用 shared_ptr 的構造函數時使用了 new 表達式創建新之外,並沒有任何刪除指針的動作,但是所有的內存管理均正確無誤,這就是得益於 shared_ptr 的精巧的設計。

擁有了 auto_ptr 和 shared_ptr 兩大利器以後,應該足以應付大多數情況下的垃圾回收了,如果你需要更復雜語義 (主要是指複製時的語義) 的智能指針,可以參考 boost 的源代碼,其中設計了多種類型的智能指針。

標準容器

對於需要在程序中擁有相同類型的多個對象,善用標準庫提供的各種容器類,可以最大限度的杜絕顯式的內存管理,然而標準容器並不適用於儲存指針,這樣對於多態性的支持仍然面臨困境。

使用智能指針作爲容器的元素類型,然而標準容器和算法大多數需要值複製語義的元素,前面介紹的轉移所有權的 auto_ptr 和自制的共享對象的 shared_ptr 都不能提供正確的值複製語義,Herb Sutter 在《More Execptional C++》中設計了一個具有完全複製語義的智能指針 ValuePtr,解決了指針用於標準容器的問題。

然而,多態性仍然沒有解決,我將在另一篇文章專門介紹使用容器管理多態對象的問題。

語言支持

爲什麼不在 C++ 語言中增加對垃圾回收的支持?

根據前面的討論,我們可以看見,不同的應用環境,也許需要不同的垃圾回收器,不管三七二十一使用垃圾回收,需要將這些不同類型的垃圾回收器整合在一起,即使可以成功 (對此我感到懷疑),也會導致效率成本的增加。

這違反了 C++ 的設計哲學,“不爲不必要的功能支付代價”,強迫用戶接受垃圾回收的代價並不可取。

相反,按需選擇你自己需要的垃圾回收器,需要掌握的規則與顯式的管理內存相比,簡單的多,也不容易出錯。

最關鍵的一點, C++ 並不是 “傻瓜型” 的編程語言,他青睞喜歡和善於思考的編程者,設計一個合適自己需要的垃圾回收器,正是對喜愛 C++ 的程序員的一種挑戰。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/IqrKywGSY8tiS9z9WMXbNw