【編者按】一直以來,C 和 C++ 都是非常優秀的編程語言。不過,兩種語言雖名稱有些相似,但應用場景存在巨大的不同。對于 C 語言而言,其主要被用于操作系統、容器、物聯網、數據庫等領域的開發,而 C++ 則是開發桌面軟件、圖形處理、游戲、網站的最佳工具。在本文中,作者原以為 C++ 在開發基礎設施時會更勝一籌,然而經過與 C 語言的嘗試對比,發現事實并非如此。
?以下為翻譯正文:
首先聲明,在整個職業生涯中,我一直在使用C++,而且在做大多數項目時,C++仍然是我的首選語言。
因此,在開始構建個人項目ZeroMQ(可伸縮的分布式或并發應用程序設計的高性能異步消息庫)時,我也選用了C++,主要原因如下:
-
C++會強制我在編程風格上保持一些基本的統一性。例如,this參數不允許使用幾種不同的機制將指針傳遞給正在處理的對象,而這個問題在C項目中很常見。同樣,不可以明確將成員變量標記為私有,此外還有C++的一些其他特征。
-
使用C語言實現虛函數非常復雜,會導致理解和管理代碼的難度加劇。不過,嚴格來說,這個問題其實是上一個問題的一個子集,但我覺得有必要單獨指出。
-
最后,每個人都喜歡在代碼的末尾自動調用析構函數。
然而,事到如今,我不得不承認C++是一個糟糕的選擇。下面,我來解釋一下原因。
首先,我的個人項目ZeroMQ是一個持續運行的基礎設施,永遠不應該出故障,永遠不應該表現出未定義的行為。因此,錯誤處理至關重要,必須做到明確且嚴格。
然而,C++的異常處理并不能滿足我的需求。如果程序不會出錯,那么選擇C++沒有任何問題,只需將main函數包裝在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語言更加方便閱讀,而且編譯器也會生成更高效的代碼。
然而,C++的問題還不僅限于此。考慮某個函數會引發異常,但不會處理異常的情況。在這種情況下,錯誤的處理可以放到任何地方,具體取決于從哪里調用該函數。
針對不同的情況,采用不同的方式處理異常?這種方法聽起來似乎很有道理,但很快就會變成一場噩夢。
在修復某個Bug時,你會發現許多其他地方也有相同的Bug,因為它們都復制了同一段錯誤處理代碼。每當添加一個函數調用,就有可能增加一個新異常,如果調用函數的代碼沒有妥善處理該異常,就意味著增加了一個新Bug。
如果你還想堅持“沒有未定義的行為”原則,就不得不引入新異常,以便區分不同的故障模式。但是,添加新異常就意味著,它會上升到不同的地方。你必須在所有地方添加相應的異常處理,否則就會出現未定義的行為。
看到這里,你可能想說:這就是異常的正確用法啊?
然而問題在于,異常只是一個工具,目的是用更系統的方式管理呈現指數增長的錯誤處理代碼,但它并不能解決根本的問題。甚至可以說,異常有可能導致情況惡化,因為你不僅需要編寫新的異常類型,還需要針對新類型編寫異常處理代碼。
考慮到上述問題,我決定使用C++,但不使用異常。如今我的這個項目就是這樣實現的。
不幸的是,問題并沒有就此止步……
考慮一下,如果對象的初始化失敗,會發生什么?構造函數沒有返回值,因此只能通過拋出異常來報告失敗。但是,我決定不使用異常。所以,我們必須像下面這樣處理:
class?foo
{
public:
foo?();
int?init?();
...
};
在創建實例時,會調用構造函數(這個函數不會失敗),然后調用init函數(這個函數可能會失敗)。
與C語言相比,C++代碼更復雜:
struct?foo
{
...
};
int?foo_init?(struct?foo?*self);
然而,C++代碼真正的問題在于,如果開發人員在構造函數中編寫一些代碼,會發生什么?
在這種情況下,會出現一個特殊的新對象狀態。由于對象已構造,但尚未調用init函數,因此是“半初始化”狀態。我們應該修改對象(特別是析構函數)來處理這個新狀態。這意味著,給每個方法添加新條件。
有人可能想說,這還不是因為你人為地添加了不使用異常的限制?!如果構造函數中拋出異常,C++運行時會正確地清理對象,不會出現“半初始化”狀態。
話雖如此,然而問題在于,如果使用異常,如上所述,就必須處理所有與異常相關的復雜性。對于一個需要在遇到故障時表現出優秀的健壯性的基礎設施組件來說,這不是一個合理的選擇。
此外,即使初始化沒有問題,對象的銷毀也絕對會遇到問題。你不能在析構函數中拋出異常。這可不是我強加的人為限制,而是因為如果在進程中調用析構函數,或者恢復棧時恰好拋出異常,就會導致整個進程崩潰。
因此,如果銷毀可能失敗,你就需要兩個單獨的函數來處理它:
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語言的代碼如下。其中只有兩種狀態。未初始化對象/內存,我們無需擔心上述問題,而且結構可以包含任意數據。而且只要對象進入已初始化的狀態,就可以正常工作。因此,對象中不需要狀態機:
struct?foo
{
...
};
int?foo_init?()
{
...
}
int?foo_term?()
{
...
}
int?foo_bar?()
{
...
}
考慮一下,如果在上述代碼中添加繼承,會發生什么。C++允許將基類初始化為派生類構造函數的一部分。如果拋出異常,就會破壞已成功初始化的對象:
class?foo?:?public?bar
{
public:
foo?()?:?bar?()?{}
...
};
然而,一旦引入單獨的init函數,狀態的數量就會開始增長。除了未初始化、半初始化、初始化和半終止狀態之外,你還會遇到這些狀態的組合。你可以想象一個基類已完全初始化、但派生類半初始化的對象。
對于這樣的對象,幾乎不可能確保其行為不出問題。對象的半初始化和半終止部分有很多不同的組合,并且鑒于它們只在非常罕見的情況下才會引發故障,因此大多數相關代碼可能未經測試就進入了生產。
綜上所述,我認為,如果你的需求是不允許出現未定義的行為,則不適合面向對象的編程。這個問題不僅限于C++,任何具有構造函數和析構函數的面向對象語言都不適合。
因此,更適合面向對象語言的項目是:對開發速度有要求、但對“不存在未定義的行為”沒有太高要求。
這個問題沒有靈丹妙藥。系統編程選擇C語言更為合適。
審核編輯:湯梓紅
評論
查看更多