為什么我希望用C而不是C++來實現ZeroMQ
開始前我要先做個澄清:這篇文章同Linus Torvalds這種死忠C程序員吐槽C++的觀點是不同的。在我的整個職業生涯里我都在使用C++,而且現在C++依然是我做大多數項目時的首選編程語言。自然的,當我從2007年開始做ZeroMQ時,我選擇用C++來實現。主要的原因有以下幾點:
1. 包含數據結構和算法的庫(STL)已經成為這個語言的一部分了。如果用C,我將要么依賴第三方庫要么不得不自己手動寫一些自1970年來就早已存在的基礎算法。
2. C++語言本身在編碼風格的一致性上起到了一些強制作用。比如,有了隱式的this指針參數,這就不允許通過各種不同的方式將指向對象的指針做轉換,而那種做法在C項目中常常見到(通過各種類型轉換)。同樣的還有可以顯式的將成員變量定義為私有的,以及許多其他的語言特性。
3. 這個觀點基本上是前一個的子集,但值得我在這里顯式的指出:用C語言實現虛函數機制比較復雜,而且對于每個類來說會有些許的不同,這使得對代碼的理解和維護都會成為痛苦之源。
4. 最后一點是:人人都喜歡析構函數,它能在變量離開其作用域時自動得到調用。
如今,5年過去了,我想公開承認:用C++作為ZeroMQ的開發語言是一個糟糕的選擇,后面我將一一解釋為什么我會這么認為。
首先,很重要的一點是ZeroMQ是需要長期連續不停運行的一個網絡庫。它應該永遠不會出錯,而且永遠不能出現未定義的行為。因此,錯誤處理對于ZeroMQ來說至關重要,錯誤處理必須是非常明確的而且對錯誤應該是零容忍的。
C++的異常處理機制卻無法滿足這個要求。C++的異常機制對于確保程序不會失敗是非常有效的——只要將主函數包裝在try/catch塊中,然后你就可以在一個單獨的位置處理所有的錯誤。然而,當你的目標是確保沒有未定義行為發生時,噩夢就產生了。C++中引發異常和處理異常是松耦合的,這使得在C++中避免錯誤是十分容易的,但卻使得保證程序永遠不會出現未定義行為變得基本不可能。
在C語言中,引發錯誤和處理錯誤的部分是緊耦合的,它們在源代碼中處于同一個位置。這使得我們在錯誤發生時能很容易理解到底發生了什么:
int rc = fx (); if (rc != 0) handle_error();
在C++中,你只是拋出一個異常,到底發生了什么并不能馬上得知。
int rc = fx(); if (rc != 0) throw std::exception();
這里的問題就在于你對于誰處理這個異常,以及在哪里處理這個異常是不得而知的。如果你把異常處理代碼也放在同一個函數中,這么做或多或少還有些明智,盡管這么做會犧牲一點可讀性。
try { … int rc = fx(); if (rc != 0) throw std::exception(“Error!”); … catch (std::exception &e) { handle_exception(); }
但是,考慮一下,如果同一個函數中拋出了兩個異常時會發生什么?
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代碼:
… if (condition1) handle_exception1(); … if (condition2) handle_exception2(); …
C代碼的可讀性明顯高的多,而且還有一個附加的優勢——編譯器會為此產生更高效的代碼。這還沒完呢。再考慮一下這種情況:異常并不是由所拋出異常的函數來處理。在這種情況下,異常處理可能發生在任何地方,這取決于這個函數是在哪調用的。雖然乍一看我們可以在不同的上下文中處理不同的異常,這似乎很有用,但很快就會變成一場噩夢。
當你在解決bug的時候,你會發現幾乎同樣的錯誤處理代碼在許多地方都出現過。在代碼中增加一個新的函數調用可能會引入新的麻煩,不同類型的異常都會涌到調用函數這里,而調用函數本身并沒有適當進行的處理,這意味著什么?新的bug。
如果你依然堅持要杜絕“未定義的行為”,你不得不引入新的異常類型來區分不同的錯誤模式。然而,增加一個新的異常類型意味著它會涌現在各個不同的地方,那么就需要在所有這些地方都增加一些處理代碼,否則你又會出現“未定義的行為”。到這里你可能會尖叫:這特么算什么異常規范哪!
好吧,問題就在于異常規范只是以一種更加系統化的方式,以按照指數規模增長的異常處理代碼來處理問題的工具,它并沒有解決問題本身。甚至可以說現在情況更加糟糕了,因為你不得不去寫新的異常類型,新的異常處理代碼,以及新的異常規范。
通過上面我描述的問題,我決定使用去掉異常處理機制的C++。這正是ZeroMQ以及Crossroads I/O今天的樣子。但是,很不幸,問題到這并沒有結束…
考慮一下當一個對象初始化失敗的情況。構造函數沒有返回值,因此出錯時只能通過拋出異常來通知出現了錯誤。可是我已經決定不使用異常了,那么我不得不這樣做:
class foo { public: foo(); int init(); … };
當你創建這個類的實例時,構造函數被調用(不允許失敗),然后你顯式的去調用init來初始化(init可能會失敗)對象。相比于C語言中的做法,這就顯得過于復雜了。
struct foo { … }; int foo_init(struct foo *self);
但是以上的例子中,C++版本真正邪惡的地方在于:如果有程序員往構造函數中加入了一些真正的代碼,而不是將構造函數留空時會發生什么?如果有人真的這么做了,那么就會出現一個新的特殊的對象狀態——“半初始化狀態”。這種狀態是指對象已經完成了構造(構造函數調用完成,且沒有失敗),但init函數還沒有被調用。我們的對象需要修改(特別是析構函數),這里應該以一種方式妥善的處理這種新的狀態,這就意味著又要為每一個方法增加新的條件。
看到這里你可能會說:這就是你人為的限制使用異常處理所帶來的后果啊!如果在構造函數中拋出異常,C++運行時庫會負責清理適當的對象,那這里根本就沒有什么“半初始化狀態”了!很好,你說的很對,但這根本無關緊要。如果你使用異常,你就不得不處理所有那些與異常相關的復雜情況(我前面已經描述過了)。而這對于一個面對錯誤時需要非常健壯的基礎組件來說并不是一個合理的選擇。
此外,就算初始化不是問題,那析構的時候絕對會有問題。你不能在析構函數中拋出異常,這可不是什么人為的限制,而是如果析構函數在堆棧輾轉開解(stack unwinding)的過程中剛好拋出一個異常的話,那整個進程都會因此而崩潰。因此,如果析構過程可能失敗的話,你需要兩個單獨的函數來搞定它:
class foo { public: … int term(); ~foo(); };
現在,我們又回到了前面初始化的問題上來了:這里出現了一個新的“半終止狀態”需要我們去處理,又需要為成員函數增加新的條件了…
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語言版本中只有兩個狀態。未初始化狀態:整個結構體可以包含隨機的數據;以及初始化狀態:此時對象完全正常,可以投入使用。因此,根本沒必要在對象中加入一個狀態機。
struct foo { ... }; int foo_init () { ... } int foo_term () { ... } int foo_bar () { ... }
現在,考慮一下當你把繼承機制再加到這趟渾水中時會發生什么。C++允許把對基類的初始化作為派生類構造函數的一部分。拋出異常時將析構掉對象已經成功初始化的那部分。
class foo: public bar { public: foo ():bar () {} … };
但是,一旦你引入單獨的init函數,那么對象的狀態數量就會增加。除了“未初始化”、“半初始化”、“初始化”、“半終止”狀態外,你還會遇到這些狀態的各種組合!!打個比方,你可以想象一下一個完全初始化的基類和一個半初始化狀態的派生類。
這種對象根本不可能保證有確定的行為,因為有太多狀態的組合了。鑒于導致這類失敗的原因往往非常罕見,于是大部分相關的代碼很可能未經過測試就進入了產品。
總結以上,我相信這種“定義完全的行為”(fully-defined behaviour)打破了面向對象編程的模型。這不是專門針對C++的,而是適用于任何一種帶有構造函數和析構函數機制的面向對象編程語言。
因此,似乎面向對象編程語言更適合于當快速開發的需求比杜絕一切未定義行為要更為重要的場景中。這里并沒有銀彈,系統級編程將不得不依賴于C語言。
最后順帶提一下,我已經開始將Crossroads I/O(ZeroMQ的fork,我目前正在做的)由C++改寫為C版本。代碼看起來棒極了!
譯注:這篇新出爐的文章引發了大量的回復,有覺得作者說的很對的,也有人認為這根本不是C++的問題,而是作者錯誤的使用了異常,以及設計上的失誤,也有讀者提到了Go語言可能是種更好的選擇。好在作者也都能積極的響應回復,于是產生了不少精彩的技術討論。建議中國的程序員們也可以看看國外的開發者們對于這種“吐槽”類文章的態度以及他們討論問題的方式。
英文原文:martin_sustrik 編譯:伯樂在線— 陳舸