C++ 的五個普遍誤解(2):垃圾回收

Sheng Gordon發表於2014-12-22

[編注:為了增加您冬天閱讀的樂趣,我們很榮幸的奉上Bjarne Stroustrup大神的這個包含3個部分的系列文章。第一部分在這裡第三部分將在下個週一釋出,即在聖誕節之前完成這個系列。請欣賞。]

1. 簡介

本系列包括 3 篇文章,我將向大家展示並澄清關於C++的五個普遍的誤解:

  • 1. “要理解C++,你必須先學習C”
  • 2. “C++是一門物件導向的語言”
  • 3. “為了軟體可靠性,你需要垃圾回收”
  • 4. “為了效率,你必須編寫底層程式碼”
  • 5. “C++只適用於大型、複雜的程式”

如果你深信上述誤解中的任何一個,或者有同事深信不疑,那麼這篇短文正是為你而寫。對某些人,某些任務,在某些時間,其中一些誤解曾經只是正確的。然而,在如今的C++,應用廣泛使用的最先進的ISO C++ 2011編譯器和工具,它們只是誤解。

我認為這些誤解是“普遍的”,是因為我經常聽到。偶爾,它們有原因來支援,但是它們經常地被作為明顯的、不需要理由的支援地表達出來。有時,它們成為某些場景下不考慮使用C++的理由。

每一個誤解,都需要一大篇文章,甚至一本書來澄清,但是這裡我的目標很簡單,就是丟擲問題,並簡明地陳述我的原因。

前兩個誤解在我的第一篇文中呈現。

4. 誤解3:“對可靠的軟體,你需要垃圾回收”

在回收不再使用的記憶體上,垃圾回收做的很好,但是並不完美。它並非靈丹妙藥。因為記憶體可以被間接地引用,並且很多資源並不是普通記憶體。考慮:

Filter的建構函式開啟了兩個檔案。之後,Filter從它的輸入檔案讀取資料,執行一些任務,然後輸出到輸出檔案。任務與Filter直接有關,可能通過一個lambda提供,或者通過一個函式返回過載了虛方法的繼承類來提供;這些細節在資源管理的討論中並不重要。我們可以這樣建立Filter:

從資源管理的觀點來看,這裡的問題在於如何保證關閉被開啟的檔案,以及回收這兩個流物件的相關資源,以供後續重複使用。

對於依賴垃圾回收的語言和系統,常規的解決方法是消除delete(它很容易被遺忘,導致洩漏)和解構函式(因為支援垃圾回收的語言很少有解構函式,而最好避免使用“finalizers”,因為它在邏輯上容易被取巧,並經常損壞效能)。記憶體回收器能夠回收所有記憶體,但是我們需要使用者手動(程式碼)關閉檔案,以及釋放與流相關的非記憶體資源(如鎖)。因此,記憶體是自動(此例中很完美)回收的,但是需要手動管理其他資源,從而存在錯誤和洩露的可能性。

C++中常用和推薦的方法是使用解構函式,來保證資源被回收。典型的,在此例和通用技術中,這類資源在構造器中申請,並遵循有著笨拙名字的“資源申請即初始化”(RAII)原則。在user()中,flt的解構函式隱式地呼叫了流is和os的解構函式。這些解構函式依次關閉檔案並釋放流相關的資源。delete對*p做同樣的操作。

有經驗的現代C++11使用者會注意到,user()相當笨拙並容易出錯。這樣會更好一些:

現在當user()退出時,*p將被隱式地釋放。程式設計師不會忘記這麼做。unique_ptr是標準庫類,它被設計用來在沒有執行時(RTTI)或者空間開銷的前提下,增強內建“裸“指標的資源釋放。

然而,我們仍然可以看到new,這個解決方案有點囉嗦(Filter型別重複了兩次),並且將普通指標構造(通過new)和智慧指標(這裡是unique_ptr)分離開阻止了一些有效的優化。我們可以使用C++14中的輔助函式make_unique來改進,它構造一個指定型別的物件,並返回一個unique_ptr:

Unless we really needed the second Filter to have pointer semantics (which is unlikely) this would be better still:
除非我們在語法上真正地需要第二個Filter指標(這不太可能),否則這樣會更好:

最後一個版本比最初的程式碼更簡短,更簡單,更清晰,更快。

但是Filter的解構函式做什麼?它釋放Filter擁有的資源;即,它關閉檔案(通過觸發它們的解構函式)。實際上,這是隱式完成的,因此除非有其他需要,我們可以忽略Filter解構函式的顯式宣告,讓編譯器來處理它。因此,我需要編寫的只有:

這比你在多數垃圾回收語言(如Java或C#)中寫的程式碼更簡單;並且對那些健忘的程式設計師,它不會導致洩漏。它也比其他方案(不需要使用free/dynamic,也不需要執行垃圾回收器)更快。典型的,相對與手動方式,RAII也縮短了資源的生命週期。

這是我理想的資源管理方式。它不單單處理記憶體,同時也處理通用(非記憶體)資源,例如檔案控制程式碼,執行緒控制程式碼和鎖。但是這就夠了嗎?怎麼處理需要從一個函式傳遞到另一個函式的物件?那些沒有明顯單獨擁有者的物件呢?

4.1傳遞擁有關係:move

讓我們先來看一看把物件從一個程式碼塊傳遞到另一個程式碼塊的問題。關鍵問題是,在不復制或者錯誤使用指標導致嚴重效能問題的前提下,如何從一個程式碼塊中得到大量資訊。使用指標的傳統方式是:

現在,誰有責任來釋放物件呢?在這個簡單的例子裡,明顯是make_X()的呼叫者,但是通常情況下答案並不是顯而易見的。假如make_X()為了最小化申請負荷而儲存了物件的快取呢?假如user()把指標傳遞給了其他如other_user()函式呢?潛在的可能性很多,在這類程式中的洩露並非罕見。

我可能會使用一個shared_ptr或者unique_ptr,來明確表明對建立物件的擁有關係。例如:

但是為什麼要使用一個指標(不管是否智慧)呢?通常,我不想使用指標;並且,指標會導致從物件的常規使用中分心。例如,一個矩陣求和函式,根據兩個引數建立了一個新的物件(求和結果),但是返回一個指標會導致非常奇怪的程式碼:

這裡需要使用*操作符來得到求和結果,否則得到的是指向結果的指標。在很多情況下,我真正需要的是一個物件,而不是指向物件的指標。很多時候,我可以容易地做到。尤其是,複製一個小的物件很快,我不想使用指標:

從另一方面來說,一個包含了很多資料的物件,一般會處理這麼多的資料。考慮istream,string,vector,list和thread。它們都只包含了少數幾個位元組的資料,來保證潛在的大量資料訪問。再次考慮矩陣求和。我們需要的是

我們可以輕鬆的做到。

預設情況下,它將res的元素複製給r,但是因為res即將被銷燬,儲存元素的記憶體即將被釋放,因此這裡沒有必要複製:我們可以“竊取”元素。自從C++誕生以來,任何人都可能這麼做,並且很多人確實這麼做了。但是這是程式碼實現的技巧,而且這項技術並不好理解。C++11直接支援“竊取表示法(stealing the representation)”,通過move操作傳遞一個控制程式碼的擁有關係。考慮一個簡單的2維double型別的矩陣:

通過引用引數(&),可以識別一個複製操作。類似地,通過右值引用(&&)引數,可以識別一個move操作。move操作的目的是“竊取”物件表現,並留下一個“空物件”。對Matrix,意味著這樣的情形:

就是這樣!當編譯器看到返回值res,它意識到res即將被銷燬。即,在函式返回後res將不再被使用。因此它使用了一個move建構函式來傳遞返回值,而不是複製建構函式。特殊的,對於

在operator+()內部的res變成了空——解構函式將空執行一次——然後r擁有了res的元素。我們成功地從函式的結果中取得了元素——可能是數M位元組的記憶體——並存入呼叫函式的變數中。我們用最小的代價實現了(可能是4個字的賦值)。

老練的C++使用者指出,一個好的編譯器能夠完全消除返回值複製操作(這個例子中是,消除掉4個字的賦值和解構函式呼叫)。然而,這是依賴於實現的,我不喜歡我的基本程式設計技術的效能依賴於獨立編譯器的聰明程度。更進一步,一個能夠消除複製的編譯器,也能夠輕易的消除move。這裡我們所擁有的,是一個簡單、可靠和通用的方式,能夠消除從一個程式碼塊移動大量資訊到另一個塊的複雜度和代價。

通常,我們甚至不需要定義所有這些賦值和移動操作。如果一個類由擁有特定表現的成員組成,我們可以簡單地依賴編譯器自動生成的預設操作。考慮:

這個版本的Matrix和之前版本的表現相同,除了它處理錯誤稍好一些,以及稍大一些(一個vector通常是3個字)。

不是控制程式碼的物件怎麼處理呢?如果它們很小,像int,或者complex,不用擔心。否則,把它們改成控制程式碼,或者使用“智慧”指標返回,如unique_ptr和shared_ptr。不要和“裸”操作new和delete混用。

不幸的是,類似我上面例子中的Matrix類不是ISO C++標準庫的一部分,但是還是可以找到的(開源或者商業)。例如,在網上搜尋“Origin Matrix Sutton”,閱讀我The C++ Programming Language (Fourth Edition)的第29章,裡面有如何設計類似矩陣類的討論。

4.2 共享擁有關係:shared_ptr

在關於垃圾回收的討論中,通常會注意到一個現象,即不是每一個物件都有唯一的擁有者。這意味著,我們必須確保當最後一個引用消除後,才能銷燬/釋放這個物件。在這個模型中,我們必須有一個機制,來保證當物件的最後一個擁有者銷燬時,銷燬這個物件。即,我們需要一種共享的擁有關係形式。假設我們有一個同步的佇列,sync_queue,用作任務之間的通訊。生產者和消費者都擁有一個指向sync_queue的指標:

我假定task1,task2,iqueue和oqueue已經在其他地方定義好了;很抱歉讓執行緒的生存週期比建立執行緒的域更長(使用detatch())。你可能會想到多工處理中的管道和同步佇列。然而,這裡我只對一個問題感興趣:“誰來釋放startup()中建立的sync_queue?”。如前面所寫,只有一個正確答案:“最後使用sync_queue的那個執行緒”。這是一個刺激產生垃圾回收的經典情形。垃圾回收的最初形式是引用計數:保持物件被使用的計數,當計數降為0時,釋放物件。今天很多語言都依賴於這種想法,而C++11通過shared_ptr的形式支援它。例子變成這樣:

這樣當task1和task2析構時,會銷燬它們的shared_ptr(在良好的設計中會隱式地呼叫),並且最後一個析構的任務會銷燬sync_queue。

它很簡單並高效。它並不包含需要複雜執行時系統的垃圾回收器。更重要的是,它不僅僅回收sync_queue關聯的記憶體資源,它同時回收內建在sync_queue中管理兩個任務執行緒同步的物件(互斥,鎖,或其他)。我們這裡做到的,仍然不僅僅是記憶體管理,而是通用資源管理。“隱藏的”同步物件也被處理了,和前面例子中處理檔案控制程式碼和流緩衝區一樣。

在圍繞任務的某些範圍內,我們可以嘗試引入一個唯一的擁有者,從而不使用shared_ptr;但是這樣做通常不簡單。因此C++11同時提供了unique_ptr(對唯一擁有關係)和shared_ptr(對共享擁有關係)。

4.3 型別安全

我剛剛只提到了和資源管理有關聯的垃圾回收。它還在型別安全中扮演一個角色。只要我們有顯式的delete操作,它就可能被錯誤使用。例如:

不要這樣做。裸露的delete非常危險——而且在常用的程式碼中是不必要的。把delete放到資源管理類的內部,例如string,ostream,thread,unique_ptr和shared_ptr。這樣,delete就會和new正確對應,不會出錯。

4.4 總結:資源管理理念

對於資源管理,我認為垃圾回收是最後的選擇,而不是“解決方案”或者理念:
1. 運用適當的抽象,遞迴和顯式地處理自己擁有的資源。限定物件的作用域會更好。
2. 當你需要使用指標/引用語義時,使用諸如unique_ptr和shared_ptr的“智慧指標”,來表明擁有關係。
3. 如果其他都行不通(如,你的程式碼是一個程式的一部分,而程式中使用了大量不滿足語言資源管理和錯誤處理策略的指標),嘗試“手動”處理非記憶體資源,並內嵌一個保守的垃圾回收器,用它來處理那些幾乎不可避免的記憶體洩露。

這個策略完美嗎?不,但它是通用的,並且簡單。傳統的基於垃圾回收的策略也不完美,並且它們不能直接處理非記憶體資源。

附言

系列的第一篇在這裡

  • 誤解1:“要理解C++,你必須先學習C”
  • 誤解2:“C++是一門物件導向的語言”

在下一篇中我將講解

  • 誤解4:“為了效率,你必須編寫底層程式碼”
  • 誤解5:“C++只適用於大型、複雜的程式”

相關文章