C++的最佳特性(譯)

最近看到了不少優秀的文章,包括《Why mobile web apps are slow》,實在忍不住翻譯出來跟你們分享。這篇文章經過大量的實驗和參考文獻向咱們說明移動應用開發所遇到的問題,基本的觀點能夠總結爲:移動平臺的編程環境是一種資源受限(主要是CPU和內存)的環境,在這樣的環境下編程,程序員不得不考慮如何高效地利用資源,這些問題不是僅僅靠一些高級語言特性(如垃圾回收)就可以解決的。由於這些高級語言特性在嘗試解決一個問題的同時,每每又會引入其它的問題html

也就是說,不管編程語言發展到多麼高級的層次,程序員的價值永遠存在。就像如今科技發展到今天這樣的地步,電子競技仍是沒法替代體育運動,咱們仍是喜歡看球員在球場上展示本身的風采。程序員須要在必要的時候取得對程序行爲100%的控制,而這一點偏偏是手動內存管理可以提供的,這也是Going Native最本質上的意義。java

今天想跟你們推薦一篇文章《C++’s best feature》,這篇文章介紹了C++手動內存管理的基礎:肯定的對象生命週期(Determined object life-time)。正如垃圾回收可能不像你感受的那麼可行同樣,手動內存管理也可能不像你感受的那麼難。我相信這篇文章可以給你一個基本的認識。程序員

下面是翻譯:web


更新:我以前關於數組初始化過程當中臨時對象生命週期的說法是不正確的。這一部分已經獲得了修改,另外根據Herb Sutter的建議,我還加入了一些必要的信息。算法

若是你想學會C++的所有內容,這是一件巨大、複雜和充滿陷阱的事情。若是你看到一些人使用它的方式,你可能會被嚇壞。如今新的功能正在陸續加入C++標準,因此要學會語言的各個細節沒有不少年的積累是不現實的。數據庫

可是你不必學會語言的方方面面才能去動手寫程序,高效地使用C++其實只須要學習它的幾個基本特性。在這篇文章中,我準備向你們介紹一下我認爲C++中最重要的特性之一,這個特性也是是我選擇C++而不是其它編程語言的緣由。編程

肯定的對象生命週期(Determined object life-time)

你在程序中建立的每個對象都有一個精確且肯定的生命週期。一旦你肯定使用某種生命週期,你就準確地知道你建立的對象的生命週期從何時開始,到何時結束。數組

對於局部變量(automatic variables),它們的生命週期從他們聲明處開始(在正常的初始化過程結束後,沒有產生異常),到離開所在的做用域爲止。對於兩個相鄰的局部變量,定義在前面的變量生命週期開始得較早,結束得較晚。安全

對於函數形參,它們的生命週期恰好在函數開始以前開始,恰好在函數完成執行以後結束。oracle

對於全局變量,它們的生命週期在main函數以前開始(譯註:其實是由系統的C Runtime進行初始化,初始化以後才調用main函數),在main函數完成執行後結束。定義在同一個編譯單元(譯註:translation unit,C++術語,能夠理解爲已經包含了引入了頭文件的cpp文件)中的兩個全局變量,定義在前面的變量生命週期開始得較早,結束得較晚。對於定義在不一樣編譯單元的兩個全局變量,不能對他們之間生命週期的關係作出假設(譯註:有些像C++中未定義的行爲,是實現相關的)。

對於幾乎全部的臨時變量(除了兩種已經良好定義的例外),它們的生命週期從一個較長的表達式內部的函數經過傳值返回(或者顯式地建立)開始,到整個表達式求值完成爲止。

兩個例外以下:當一個臨時對象綁定到一個全局或局部的引用上時,它的生命週期和那個引用的生命週期同樣長。

Base && b = Derived{};
int main()
{
  // ...
  b.modify();
  // ...
}
// Derived類型的臨時對象生命週期到此爲止

(注意引用的類型和臨時對象的類型不是同樣的,並且咱們能夠修改這個臨時對象。「臨時」表示存在時間是「短暫的」,可是當它綁定到一個全局引用上時,它的生命週期就和其它任何全局變量的生命週期同樣長了。)

第二個例外適用於初始化用戶自定義類型的數組。在這種狀況下,若是使用一個默認構造函數來初始化數組的第n個元素,並且默認構造函數有一個或多個默認參數,那麼在默認參數裏面建立的每一個臨時對象的生命週期在咱們繼續初始化第n+1個元素時結束。可是你極可能在寫程序的過程當中不須要知道這一點。

對於類內部的成員對象來講,它們的生命週期在其所在的對象生命週期開始以前開始,在所在的對象生命週期結束以後結束。

其它類型的對象的生命週期是相似的:函數局部靜態變量、thread-local變量以及咱們能夠手動控制的變量生命週期,好比new/delete和optional等。在這些狀況下,對象生命週期的開始和結束都是通過良好定義且可預測的。

在對象初始化的過程當中,它的生命週期立刻就要開始,但若是此時發生了異常,那麼它的生命週期並無真正開始。

簡而言之,這就是肯定的對象生命週期的本質。那什麼是不肯定的對象生命週期呢?在C++中(暫時)尚未,可是你能夠在其它帶有「垃圾回收」支持的語言中看到。在這種狀況下,當你建立一個對象的時候,它的生命週期開始了,可是你不知道它的生命週期何時結束。垃圾回收保證,若是你有引用指向一個對象,那個這個對象的生命週期就必定不會結束。可是若是指向這個對象的最後一個引用不存在了,那麼它存活的時間可能就是任意長的,直到整個進程的結束。

那麼,爲何肯定的對象生命週期如此重要呢?

析構函數

C++保證,在任何類型的對象在它們生命週期結束時調用它們的析構函數。析構函數是對象所在類的成員函數,並被確保是類的對象最後調用的函數。

全部都已經知道這一點了,可是不是全部人都瞭解它給咱們帶來的好處。首先,最重要的一點,你能夠經過析構函數來清理你的對象在生命週期中所獲取的資源。這種清理被封裝了起來,對於用戶是不可見的:用戶不須要手動調用任何dispose或者close函數。因此你通常不會忘記去清理資源,你甚至不用知道你當前使用的類是否有管理着資源。並且當對象銷燬時,它的資源會被當即清理,而不是在某個不肯定的未來。資源越早釋放越好,這會防止資源泄露。這個過程不會留下任何垃圾,也不會留下資源在爲肯定的時間須要清理。(當你看到「資源」這個字眼時,不要只想着內存,多想一想打開數據庫或者socket鏈接。)

肯定的對象生命週期還保證了對象銷燬的相對順序。假如一個做用域中有幾個局部對象,那麼它們會按照與聲明(和初始化)相反的順序被銷燬。相似的,對於類的內部對象來講,它們也是按照在類定義中聲明(和初始化)相反的順序被銷燬。這本質上是保證資源相互依賴關係的正確性。

這個特性是因爲垃圾回收的,有如下幾個緣由:

1. 它爲全部你能想到的全部資源管理提供了統一的方式,而不只僅是內存;

2. 資源會在它們再也不被使用時當即被釋放,而不是讓垃圾回收來決定何時去清理;

3. 它不會帶來像垃圾回收所帶來的運行時刻的額外開銷。

基於垃圾回收器的語言傾向於提供資源管理的替代方式:在C#中的using語句或者Java中的try語句。儘管它們是朝着好的方向去的,但仍是不如析構函數的用法好。

1. 資源管理直接暴露給了用戶:你須要知道你當前使用的類型管理着內存,而後添加額外的代碼來請求釋放資源;

2. 若是類的維護者決定將一個本來無論理資源的類改爲管理資源的類,那麼用戶須要修改本身的代碼,這是資源清理沒有被封裝帶來的問題;

3. 這種方式沒法與泛型編程一塊兒使用:你不能寫出對於處理和不處理資源的類的統一語法的代碼。

最後(譯註:做者表示這是雙關,可是我沒看懂什麼意思),這種保護語句塊(guarding statements)只能替代C++中的對於「局部」對象(也就是在函數或者某個語句塊中建立的對象)的處理方式。C++還提供了其它類型的對象生命週期。好比說,你可讓一個資源管理的對象成爲另一個「主」對象的成員,經過這種方式表達:這個資源的生命週期一直持續到主對象的生命週期結束時。

考慮如下打開n個文件流而後把他們放在一個容器裏面返回的函數,還有一個從這個容器裏面讀出這些文件流而後自動關閉這些流的函數:

vector<ifstream> produce()
{
  vector<ifstream> ans;
  for (int i = 0;  i < 10; ++i) {
    ans.emplace_back(name(i));
  } 
  return ans;
}
 
void consumer()
{
  vector<ifstream> files = produce();
  for (ifstream& f: files) {
    read(f);
  }
} // 關閉全部文件

若是你想經過using語句或者是try語句,你怎麼實現這個功能呢?

注意這邊有一個竅門。咱們用到了C++中的另外一個重要的特性:move構造函數,還用到了一個基本事實:std::fstream是不可拷貝,但倒是能夠移動的。(然而GCC 4.8.1的用戶可能不會注意這個)一樣的事情(傳遞地)發生在std::vector<std::ifstream>上。move操做像是仿真了另外一個惟一的對象的生命週期。在這個過程當中,咱們有資源(文件句柄的集合)的「虛擬的」生命週期和「手工的」生命週期,其中這個生命週期從ans被建立開始,到定義在另一個做用域裏的不一樣的對象生命週期結束而結束。

注意到,整個文件句柄的集合整個的「擴展的」生命週期中,若是有異常發生,每一個句柄都被保護不會泄露。即便name函數在第5次迭代時發生了異常,以前已經建立好的4個元素都會保證在produce函數被正確析構。

相似的,你沒法經過「保護」語句作到作到下面的效果:

class CombinedResource
{
  std::fstream f;
  Socket s;
 
  CombinedResource(string name, unsigned port) 
    : f{name}, s{port} {}
  // 沒有顯式地調用析構函數
};

這段代碼已經給了你好幾個有用的安全性保障。兩個資源會在CombinedResource的生命週期結束時被釋放:這是在隱式的析構函數中按照與初始化的相反的順序來處理的,你不須要去手工寫這些代碼。假設在初始化第二個資源s的時候,在其構造函數中發生了異常,已經被初始化好了的f的析構函數會被當即調用,這個過程在異常從s的構造函數中向上拋出時已經完成了。你能夠免費獲取到這些安全性保障。

試問,你怎麼經過using或者try來保證上面的安全性保障呢?

很差的方面

這邊有必要提一些有些人不喜歡析構函數的緣由。在某些狀況下,垃圾回收比C++提供的資源管理方式要好。好比,有了垃圾回收器(若是你用得起它的話),你能夠僅僅經過分配節點而後經過指針(你也能夠稱之爲「引用」)把他們鏈接起來,來很好地表示一個帶環的圖。在C++中,你無法作到這一點,甚至用「智能」指針也不行。固然,這種經過垃圾回收來管理的圖中的節點無法管理資源,由於它們可能會泄露:using或者try語句在這裏不起做用,由於finalizer函數不必定會被調用。

還有,我聽一些人說有一些高效的並行算法只能在垃圾回收器的幫助下完成。我認可我沒見過這樣的算法。

有些人不喜歡在看不到代碼中看不到析構函數,有些人喜歡這種方式,也有人不喜歡。當你在分析和調試程序時,你可能不會注意某個析構函數被調用了,並且這可能有一些反作用。我在調試一個大而混亂的程序時,就曾經落入這個陷阱中。一個被野指針(raw pointer)指向的對象可能忽然會由於某個未知的緣由變得無效了,並且我也看不出來有什麼函數會致使這種狀況。後來我才意識到同一個對象被另外一個unique_ptr所指向,而這個unique_ptr又悄無聲息地超出了做用域。對於臨時對象來講,狀況可能會更糟,你既看不到析構函數,也看不到對象自己。

在使用析構函數的時候有一些限制:爲了可以使析構函數與棧展開(stack unwinding,由異常致使,是C++中異常處理的標準流程)正確地協做,它們自己不能拋出異常。這個限制對於某些人來講很是難,由於他們須要來標誌資源釋放失敗了,或者用析構函數達到其它的目的。

注意,在C++中,除非你將析構函數定義爲noexcept(false),那麼它會被隱式地聲明爲noexcept,若是異常異常從其中拋出的話,就會調用std::terminate。加入你想在發生異常的時候標誌資源釋放失敗,推薦的作法是提供一個像release這樣的成員函數用來顯示調用,而後讓析構函數檢查資源是否已釋放,若是沒有,則「安靜地」釋放(吞下任何異常而不繼續拋出)。

這種經過析構函數來釋放資源的另外一個潛在的弊端是,有些時候你須要在你的函數中引入額外的手工的做用域(或者叫塊),而這僅僅是爲了在函數的做用域結束以前觸發局部對象的析構函數。好比:

void Type::fun()
{
  doSomeProcessing1();
  {
    std::lock_guard<std::mutex> g{mutex_};
    read(sharedData_);
  }
  doSomeProcessing2();
}

這裏,咱們不得不加入一個額外的程序塊,保證咱們再調用doSomeProcessing2函數的時候mutex沒有被鎖住:咱們想在中止使用資源後當即釋放它們。這個看上去就有點像using或try語句了,可是有兩個區別:

1. 這是一種例外,而不是以一種規則;

2. 若是咱們忘了這個做用域,資源會被持有更長的時間,但不會泄露,由於它的析構函數綁定在調用者身上。

這就是我要講的。我我的感受析構函數是全部程序語言裏面最優雅和實用的特性,並且我還沒提到其它的優點:和異常處理機制的相互做用。這是C++中比性能更能吸引個人特色:優雅。

最後我想說的是,我並不是聲稱這是C++中真正最好的特性,我只是想讓標題更能引人人的眼球。

相關文章
相關標籤/搜索