程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> C語言 >> C++ >> C++入門知識 >> C++的最佳特性(譯)

C++的最佳特性(譯)

編輯:C++入門知識

如果你想學會C++的全部內容,這是一件巨大、復雜和充滿陷阱的事情。如果你看到一些人使用它的方式,你可能會被嚇壞。現在新的功能正在陸續加入C++標准,所以要學會語言的各個細節沒有很多年的積累是不現實的。

但是你沒必要學會語言的方方面面才能去動手寫程序,高效地使用C++其實只需要學習它的幾個基本特性。在這篇文章中,我准備向大家介紹一下我認為C++中最重要的特性之一,這個特性也是是我選擇C++而不是其它編程語言的原因。

確定的對象生命周期(Determined object life-time)
你在程序中創建的每一個對象都有一個精確且確定的生命周期。一旦你確定使用某種生命周期,你就准確地知道你創建的對象的生命周期從什麼時候開始,到什麼時候結束。

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

對於函數形參,它們的生命周期剛好在函數開始之前開始,剛好在函數完成執行之後結束。

對於全局變量,它們的生命周期在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++中比性能更能吸引我的特點:優雅。

 

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved