為什麼我希望用C而不是C++來實現ZeroMQ

bigship發表於2013-03-24

開始前我要先做個澄清:這篇文章同Linus Torvalds這種死忠C程式設計師吐槽C++的觀點是不同的。在我的整個職業生涯裡我都在使用C++,而且現在C++依然是我做大多數專案時的首選程式語言。自然的,當我從2007年開始做ZeroMQ(ZeroMQ專案主頁)時,我選擇用C++來實現。主要的原因有以下幾點:

1.  包含資料結構和演算法的庫(STL)已經成為這個語言的一部分了。如果用C,我將要麼依賴第三方庫要麼不得不自己手動寫一些自1970年來就早已存在的基礎演算法。

2.  C++語言本身在編碼風格的一致性上起到了一些強制作用。比如,有了隱式的this指標引數,這就不允許通過各種不同的方式將指向物件的指標做轉換,而那種做法在C專案中常常見到(通過各種型別轉換)。同樣的還有可以顯式的將成員變數定義為私有的,以及許多其他的語言特性。

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

4.  最後一點是:人人都喜歡解構函式,它能在變數離開其作用域時自動得到呼叫。

為什麼我希望用C而不是C++來實現ZeroMQ

為什麼我希望用C而不是C++來實現ZeroMQ

如今,5年過去了,我想公開承認:用C++作為ZeroMQ的開發語言是一個糟糕的選擇,後面我將一一解釋為什麼我會這麼認為。

首先,很重要的一點是ZeroMQ是需要長期連續不停執行的一個網路庫。它應該永遠不會出錯,而且永遠不能出現未定義的行為。因此,錯誤處理對於ZeroMQ來說至關重要,錯誤處理必須是非常明確的而且對錯誤應該是零容忍的。

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

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

在C++中,你只是丟擲一個異常,到底發生了什麼並不能馬上得知。

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

但是,考慮一下,如果同一個函式中丟擲了兩個異常時會發生什麼?

對比一下相同的C程式碼:

C程式碼的可讀性明顯高的多,而且還有一個附加的優勢——編譯器會為此產生更高效的程式碼。這還沒完呢。再考慮一下這種情況:異常並不是由所丟擲異常的函式來處理。在這種情況下,異常處理可能發生在任何地方,這取決於這個函式是在哪呼叫的。雖然乍一看我們可以在不同的上下文中處理不同的異常,這似乎很有用,但很快就會變成一場噩夢。

當你在解決bug的時候,你會發現幾乎同樣的錯誤處理程式碼在許多地方都出現過。在程式碼中增加一個新的函式呼叫可能會引入新的麻煩,不同型別的異常都會湧到呼叫函式這裡,而呼叫函式本身並沒有適當進行的處理,這意味著什麼?新的bug。

如果你依然堅持要杜絕“未定義的行為”,你不得不引入新的異常型別來區分不同的錯誤模式。然而,增加一個新的異常型別意味著它會湧現在各個不同的地方,那麼就需要在所有這些地方都增加一些處理程式碼,否則你又會出現“未定義的行為”。到這裡你可能會尖叫:這特麼算什麼異常規範哪!

好吧,問題就在於異常規範只是以一種更加系統化的方式,以按照指數規模增長的異常處理程式碼來處理問題的工具,它並沒有解決問題本身。甚至可以說現在情況更加糟糕了,因為你不得不去寫新的異常型別,新的異常處理程式碼,以及新的異常規範。

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

考慮一下當一個物件初始化失敗的情況。建構函式沒有返回值,因此出錯時只能通過丟擲異常來通知出現了錯誤。可是我已經決定不使用異常了,那麼我不得不這樣做:

當你建立這個類的例項時,建構函式被呼叫(不允許失敗),然後你顯式的去呼叫init來初始化(init可能會失敗)物件。相比於C語言中的做法,這就顯得過於複雜了。

但是以上的例子中,C++版本真正邪惡的地方在於:如果有程式設計師往建構函式中加入了一些真正的程式碼,而不是將建構函式留空時會發生什麼?如果有人真的這麼做了,那麼就會出現一個新的特殊的物件狀態——“半初始化狀態”。這種狀態是指物件已經完成了構造(建構函式呼叫完成,且沒有失敗),但init函式還沒有被呼叫。我們的物件需要修改(特別是解構函式),這裡應該以一種方式妥善的處理這種新的狀態,這就意味著又要為每一個方法增加新的條件。

看到這裡你可能會說:這就是你人為的限制使用異常處理所帶來的後果啊!如果在建構函式中丟擲異常,C++執行時庫會負責清理適當的物件,那這裡根本就沒有什麼“半初始化狀態”了!很好,你說的很對,但這根本無關緊要。如果你使用異常,你就不得不處理所有那些與異常相關的複雜情況(我前面已經描述過了)。而這對於一個面對錯誤時需要非常健壯的基礎元件來說並不是一個合理的選擇。

此外,就算初始化不是問題,那析構的時候絕對會有問題。你不能在解構函式中丟擲異常,這可不是什麼人為的限制,而是如果解構函式在堆疊輾轉開解(stack unwinding)的過程中剛好丟擲一個異常的話,那整個程式都會因此而崩潰。因此,如果析構過程可能失敗的話,你需要兩個單獨的函式來搞定它:

現在,我們又回到了前面初始化的問題上來了:這裡出現了一個新的“半終止狀態”需要我們去處理,又需要為成員函式增加新的條件了…

將上面的例子與同樣的C語言實現做下對比。C語言版本中只有兩個狀態。未初始化狀態:整個結構體可以包含隨機的資料;以及初始化狀態:此時物件完全正常,可以投入使用。因此,根本沒必要在物件中加入一個狀態機。

現在,考慮一下當你把繼承機制再加到這趟渾水中時會發生什麼。C++允許把對基類的初始化作為派生類建構函式的一部分。丟擲異常時將析構掉物件已經成功初始化的那部分。

但是,一旦你引入單獨的init函式,那麼物件的狀態數量就會增加。除了“未初始化”、“半初始化”、“初始化”、“半終止”狀態外,你還會遇到這些狀態的各種組合!!打個比方,你可以想象一下一個完全初始化的基類和一個半初始化狀態的派生類。

這種物件根本不可能保證有確定的行為,因為有太多狀態的組合了。鑑於導致這類失敗的原因往往非常罕見,於是大部分相關的程式碼很可能未經過測試就進入了產品。

總結以上,我相信這種“定義完全的行為”(fully-defined behaviour)打破了物件導向程式設計的模型。這不是專門針對C++的,而是適用於任何一種帶有建構函式和解構函式機制的物件導向程式語言。

因此,似乎物件導向程式語言更適合於當快速開發的需求比杜絕一切未定義行為要更為重要的場景中。這裡並沒有銀彈,系統級程式設計將不得不依賴於C語言。

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

 

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

 

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

譯文連結:http://blog.jobbole.com/19647/

【如需轉載,請標註並保留原文連結、譯文連結和譯者等資訊,謝謝合作!】

 

相關文章