爲何我但願用C而不是C++來實現ZeroMQ(一)

開始前我要先作個澄清:這篇文章同Linus Torvalds這種死忠C程序員吐槽C++的觀點是不一樣的。在個人整個職業生涯裏我都在使用C++,並且如今C++依然是我作大多數項目時的首選編程語言。天然的,當我從2007年開始作ZeroMQ(ZeroMQ項目主頁)時,我選擇用C++來實現。主要的緣由有如下幾點:python

1.  包含數據結構和算法的庫(STL)已經成爲這個語言的一部分了。若是用C,我將要麼依賴第三方庫要麼不得不本身手動寫一些自1970年來就早已存在的基礎算法。程序員

2.  C++語言自己在編碼風格的一致性上起到了一些強制做用。好比,有了隱式的this指針參數,這就不容許經過各類不一樣的方式將指向對象的指針作轉換,而那種作法在C項目中經常見到(經過各類類型轉換)。一樣的還有能夠顯式的將成員變量定義爲私有的,以及許多其餘的語言特性。算法

3.  這個觀點基本上是前一個的子集,但值得我在這裏顯式的指出:用C語言實現虛函數機制比較複雜,並且對於每一個類來講會有些許的不一樣,這使得對代碼的理解和維護都會成爲痛苦之源。編程

4.  最後一點是:人人都喜歡析構函數,它能在變量離開其做用域時自動獲得調用。網絡

現在,5年過去了,我想公開認可:用C++做爲ZeroMQ的開發語言是一個糟糕的選擇,後面我將一一解釋爲何我會這麼認爲。數據結構

首先,很重要的一點是ZeroMQ是須要長期連續不停運行的一個網絡庫。它應該永遠不會出錯,並且永遠不能出現未定義的行爲。所以,錯誤處理對於ZeroMQ來講相當重要,錯誤處理必須是很是明確的並且對錯誤應該是零容忍的。數據結構和算法

C++的異常處理機制卻沒法知足這個要求。C++的異常機制對於確保程序不會失敗是很是有效的——只要將主函數包裝在try/catch塊中,而後你就能夠在一個單獨的位置處理全部的錯誤。然而,當你的目標是確保沒有未定義行爲發生時,噩夢就產生了。C++中引起異常和處理異常是鬆耦合的,這使得在C++中避免錯誤是十分容易的,但卻使得保證程序永遠不會出現未定義行爲變得基本不可能。編程語言

在C語言中,引起錯誤和處理錯誤的部分是緊耦合的,它們在源代碼中處於同一個位置。這使得咱們在錯誤發生時能很容易理解到底發生了什麼:函數

1
2
3
int rc = fx ();
if (rc != 0)
     handle_error();

在C++中,你只是拋出一個異常,到底發生了什麼並不能立刻得知。工具

1
2
3
int rc = fx();
if (rc != 0)
     throw std::exception();

這裏的問題就在於你對於誰處理這個異常,以及在哪裏處理這個異常是不得而知的。若是你把異常處理代碼也放在同一個函數中,這麼作或多或少還有些明智,儘管這麼作會犧牲一點可讀性。

1
2
3
4
5
6
7
8
9
try {
    
     int rc = fx();
     if (rc != 0)
     throw std::exception(「Error!」);
    
catch (std::exception &e) {
     handle_exception();
}

可是,考慮一下,若是同一個函數中拋出了兩個異常時會發生什麼?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class exception1 {};
class exception2 {};
try {
    
     if (condition1)
         throw my_exception1();
    
     if (condition2)
         throw my_exception2();
    
}
catch (my_exception1 &e) {
     handle_exception1();
}
catch (my_exception2 &e) {
     handle_exception2();
}

對比一下相同的C代碼:

1
2
3
4
5
6
7
if (condition1)
     handle_exception1();
if (condition2)
     handle_exception2();

C代碼的可讀性明顯高的多,並且還有一個附加的優點——編譯器會爲此產生更高效的代碼。這還沒完呢。再考慮一下這種狀況:異常並非由所拋出異常的函數來處理。在這種狀況下,異常處理可能發生在任何地方,這取決於這個函數是在哪調用的。雖然乍一看咱們能夠在不一樣的上下文中處理不一樣的異常,這彷佛頗有用,但很快就會變成一場噩夢。

當你在解決bug的時候,你會發現幾乎一樣的錯誤處理代碼在許多地方都出現過。在代碼中增長一個新的函數調用可能會引入新的麻煩,不一樣類型的異常都會涌到調用函數這裏,而調用函數自己並無適當進行的處理,這意味着什麼?新的bug。

若是你依然堅持要杜絕「未定義的行爲」,你不得不引入新的異常類型來區分不一樣的錯誤模式。然而,增長一個新的異常類型意味着它會涌如今各個不一樣的地方,那麼就須要在全部這些地方都增長一些處理代碼,不然你又會出現「未定義的行爲」。到這裏你可能會尖叫:這特麼算什麼異常規範哪!

好吧,問題就在於異常規範只是以一種更加系統化的方式,以按照指數規模增加的異常處理代碼來處理問題的工具,它並無解決問題自己。甚至能夠說如今狀況更加糟糕了,由於你不得不去寫新的異常類型,新的異常處理代碼,以及新的異常規範。

經過上面我描述的問題,我決定使用去掉異常處理機制的C++。這正是ZeroMQ以及Crossroads I/O今天的樣子。可是,很不幸,問題到這並無結束…

考慮一下當一個對象初始化失敗的狀況。構造函數沒有返回值,所以出錯時只能經過拋出異常來通知出現了錯誤。但是我已經決定不使用異常了,那麼我不得不這樣作:

1
2
3
4
5
6
7
class foo
{
public :
     foo();
     int init();
    
};

當你建立這個類的實例時,構造函數被調用(不容許失敗),而後你顯式的去調用init來初始化(init可能會失敗)對象。相比於C語言中的作法,這就顯得過於複雜了。

1
2
3
4
5
struct foo
{
    
};
int foo_init( struct foo *self);

可是以上的例子中,C++版本真正邪惡的地方在於:若是有程序員往構造函數中加入了一些真正的代碼,而不是將構造函數留空時會發生什麼?若是有人真的這麼作了,那麼就會出現一個新的特殊的對象狀態——「半初始化狀態」。這種狀態是指對象已經完成了構造(構造函數調用完成,且沒有失敗),但init函數尚未被調用。咱們的對象須要修改(特別是析構函數),這裏應該以一種方式妥善的處理這種新的狀態,這就意味着又要爲每個方法增長新的條件。

看到這裏你可能會說:這就是你人爲的限制使用異常處理所帶來的後果啊!若是在構造函數中拋出異常,C++運行時庫會負責清理適當的對象,那這裏根本就沒有什麼「半初始化狀態」了!很好,你說的很對,但這根本可有可無。若是你使用異常,你就不得不處理全部那些與異常相關的複雜狀況(我前面已經描述過了)。而這對於一個面對錯誤時須要很是健壯的基礎組件來講並非一個合理的選擇。

此外,就算初始化不是問題,那析構的時候絕對會有問題。你不能在析構函數中拋出異常,這可不是什麼人爲的限制,而是若是析構函數在堆棧展轉開解(stack unwinding)的過程當中恰好拋出一個異常的話,那整個進程都會所以而崩潰。所以,若是析構過程可能失敗的話,你須要兩個單獨的函數來搞定它:

1
2
3
4
5
6
7
class foo
{
public :
    
     int term();
     ~foo();
};

如今,咱們又回到了前面初始化的問題上來了:這裏出現了一個新的「半終止狀態」須要咱們去處理,又須要爲成員函數增長新的條件了…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class foo
{
public :
     foo () : state (semi_initialised)
     {
          ...
     }
 
     int init ()
     {
         if (state != semi_initialised)
             handle_state_error ();
         ...
         state = intitialised;
     }
 
     int term ()
     {
          if (state != initialised)
              handle_state_error ();
          ...
          state = semi_terminated;
     }
 
     ~foo ()
     {
          if (state != semi_terminated)
              handle_state_error ();
          ...
     }
 
     int bar ()
     {
          if (state != initialised)
              handle_state_error ();
          ...
     }
};

將上面的例子與一樣的C語言實現作下對比。C語言版本中只有兩個狀態。未初始化狀態:整個結構體能夠包含隨機的數據;以及初始化狀態:此時對象徹底正常,能夠投入使用。所以,根本不必在對象中加入一個狀態機。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct foo
{
     ...
};
 
int foo_init ()
{
     ...
}
 
int foo_term ()
{
     ...
}
 
int foo_bar ()
{
     ...
}

如今,考慮一下當你把繼承機制再加到這趟渾水中時會發生什麼。C++容許把對基類的初始化做爲派生類構造函數的一部分。拋出異常時將析構掉對象已經成功初始化的那部分。

1
2
3
4
5
6
class foo: public bar
{
public :
     foo ():bar () {}
    
};

可是,一旦你引入單獨的init函數,那麼對象的狀態數量就會增長。除了「未初始化」、「半初始化」、「初始化」、「半終止」狀態外,你還會遇到這些狀態的各類組合!!打個比方,你能夠想象一下一個徹底初始化的基類和一個半初始化狀態的派生類。

這種對象根本不可能保證有肯定的行爲,由於有太多狀態的組合了。鑑於致使這類失敗的緣由每每很是罕見,因而大部分相關的代碼極可能未通過測試就進入了產品。

總結以上,我相信這種「定義徹底的行爲」(fully-defined behaviour)打破了面向對象編程的模型。這不是專門針對C++的,而是適用於任何一種帶有構造函數和析構函數機制的面向對象編程語言。

所以,彷佛面向對象編程語言更適合於當快速開發的需求比杜絕一切未定義行爲要更爲重要的場景中。這裏並無銀彈,系統級編程將不得不依賴於C語言。

最後順帶提一下,我已經開始將Crossroads I/O(ZeroMQ的fork,我目前正在作的)由C++改寫爲C版本。代碼看起來棒極了!

 

譯註:這篇新出爐的文章引起了大量的回覆,有以爲做者說的很對的,也有人認爲這根本不是C++的問題,而是做者錯誤的使用了異常,以及設計上的失誤,也有讀者提到了Go語言多是種更好的選擇。好在做者也都能積極的響應回覆,因而產生了很多精彩的技術討論。建議中國的程序員們也能夠看看國外的開發者們對於這種「吐槽」類文章的態度以及他們討論問題的方式。

 

英文原文:martin_sustrik      編譯:伯樂在線— 陳舸

相關文章
相關標籤/搜索