譯註:這篇文章可能又會引發 C++ 程序員的諸多不適,就做者本文所描述的問題來看,某些「C++的問題」實際上是能夠有C++的解決方案的。請參閱侵入式和非侵入式容器。可是考慮到ZeroMQ是一個很底層的高性能網絡庫(ZeroMQ的目標是歸入Linux內核中,這也應該是改用C的一大緣由,畢竟目前的ZeroMQ是用C++實現的),對錯誤處理、內存分配次數、併發效率等有着極高的要求,這些特定的限制每每不是全部的C++程序員所常見的應用場景。所以但願各位在閱讀時能多從做者的角度來考慮這些問題,而不是一味地批判做者的C++編程實踐能力。html
在上一篇博文中,我已經討論過了在須要進行嚴格錯誤處理的系統底層基礎架構的開發中須要避免使用一些C++特性(異常、構造函數、析構函數)。個人結論是,當爲C++加上了這樣的使用限制後,用C來實現的話會使得代碼更簡短也更容易閱讀。這麼作的反作用是消除了對C++運行時庫的依賴,而這不該該輕易地去掉,尤爲是在嵌入式環境下。程序員
在這一篇博文中,我想從另外一個不一樣的角度來探究這個問題。即:使用C++和C相比,在性能上有什麼區別?理論上,這兩種語言產生的程序性能應該是相同的。面向對象只不過是在過程式語言之上的語法糖而已,這使得代碼對人類而言更加容易理解。人類大腦彷佛已經進化爲一種天然的能力來處理以流程,關係等這類實體爲主的對象。算法
每一個C++程序都能自動轉換爲等同的C程序——儘管這種說法理論上成立——但面向對象的觀念使得開發者以特定的方式來思考並相應地以面向對象的方式來設計他們的算法和數據結構,而這反過來會對程序性能帶來影響。編程
讓咱們來比較一下,C++程序員要如何實現對象鏈表:緩存
注:假設包含在鏈表中的對象是不可賦值(non-assignable)的,由於這種狀況下任何非簡單的對象,好比持有大量內存緩衝區,文件描述符,句柄等這樣的對象,若是對象是可賦值的,那麼簡單地使用std::list<person>就夠用了,不會有任何問題。網絡
1
2
3
4
5
6
|
class
person
{
int
age;
int
weight;
};
std::list <person*>
|
C程序員更傾向於按照以下的方式解決一樣的問題:數據結構
1
2
3
4
5
6
7
8
9
10
11
12
13
|
struct
person
{
struct
person *prev;
struct
person *next;
int
age;
int
weight;
};
struct
{
struct
person *first;
struct
person *last;
}people;
|
如今,讓咱們比較一下兩種解決方案的內存模型:多線程
首先注意到的是C++的解決方案對比C來講多分配了2倍的內存塊。針對鏈表中的每一個元素,都要建立一個小的幫助對象。當程序中有許多容器時,這些幫助對象的總數就會擴散開來。好比,在ZeroMQ中建立和鏈接一個socket將致使數十次內存分配。而在我當前正在作的C版本中,建立一個socket只須要一次內存分配,鏈接時會再須要一次。架構
很明顯,內存分配的次數會引發性能問題。分配內存所花費的時間多是可有可無的——在ZeroMQ中,這並非關鍵路徑(請參閱關於ZeroMQ中關鍵路徑的分析)——可是,內存使用量以及內存碎片帶來的問題就很是重要了。這直接影響到CPU緩存是如何填充的,以及由此帶來的緩存miss率。回顧一下,到目前爲止對物理內存的訪問是現代計算機上最慢的操做,這樣就知道這種性能影響會有多嚴重了。併發
固然,這還沒完呢。
實現方案的選擇對算法的複雜度有着直接的影響。在C++版中,從鏈表中移除一個對象是O(n)的複雜度:
1
2
3
4
5
6
7
8
9
|
void
erase_person(person *ptr)
{
for
(std::list <person*>::iterator it = people.begin();
it != people.end(); it++)
{
if
(*it == ptr)
people.erase(it);
}
}
|
在C版本中,能夠確保在常數時間內完成(簡化版):
1
2
3
4
5
6
7
|
void
erase_person(
struct
person *ptr)
{
ptr->next->prev = ptr->prev;
ptr->prev->next = ptr->next;
ptr->next = NULL;
ptr->prev = NULL;
}
|
C++版本效率的低下是因爲std::list的實現所致仍是因爲面向對象的編程範式所致呢?讓咱們深刻的探究這個問題。
C++程序員不會以C的方式來設計鏈表的真正緣由是由於這種設計破壞了封裝的原則:「person」類的實現者必需要知道person的實例最終會存儲到「people」鏈表中。此外,若是第三方開發者決定將其存儲到另一個鏈表中時,就必須修改person的實現。這正是奉行面向對象編程的程序員所極力避免的。
可是,若是咱們不把prev和next指針放在person類內部,咱們就必須把它們放置在別的地方。因此,除了多分配一個幫助對象外沒有別的辦法了,這正是std::list<>所採用的作法。
此外,雖然幫助對象中包含有指向「person」對象的指針,但「person」對象卻不能包含有指向幫助對象的指針。若是這麼作了,那就破壞了封裝的原則——「person」就必須知道包含本身的容器。結果就是,咱們能夠將指向幫助對象(迭代器iterator)的指針轉型爲指向「person」,但反過來卻不能夠。這就是爲何從std::list<>中移除一個元素須要遍歷整個鏈表,換句話說,這就是爲何須要O(n)的複雜度。
簡單來講,若是咱們聽從面向對象的編程範式,咱們就沒法實現一個全部操做都是O(1)的鏈表。若是要那麼作就必須破壞封裝的原則。
注:不少人都指出應該使用迭代器而不是指針。可是,假設某個對象須要被包含在10個不一樣的鏈表中。你將不得不傳遞一個包含10個迭代器的結構體,而不是隻傳一個指針。此外,這並無解決封裝的問題,只是把問題移到了別處而已。當你但願將對象添加到一個新的容器類型中時,雖然你不用修改「person」的實現了,但你仍然不得不去修改包含迭代器的結構體。
這應該就是本文的結論了。可是這個主題實在太有意思了,我還想再問一個問題:這種低效究竟是源於面向對象的設計仍是說只是特定於C++語言呢?咱們可否設想以一種面向對象的編程語言來實現全部相關操做都爲O(1)複雜度的鏈表呢?
要回答這個問題咱們必須理解問題的根本。而這個問題來自對術語「對象」的定義。在C++中「class」只是對C語言中「struct」的代名詞,這兩個關鍵字幾乎能夠互換使用。言下之意是指「對象」是一系列存儲在連續內存空間中的數據集合。
這對於C++程序員來講是想都不用想的問題。可是讓咱們從不一樣的角度來分析「對象」。
咱們說對象是一系列邏輯上相關聯的數據的集合,在多線程程序中應該處於同一個臨界區中受到保護。這必定義從根本上改變了咱們對程序架構的理解。下面這張圖展現了C語言版的person/people程序,並標識出了數據域應該由一個鏈表級的臨界區(黃色部分),仍是由元素級的臨界區(綠色部分)來保護。
從面向對象的角度來看,這張圖實在太詭異。「people」對象不只包含有「people」結構體內的字段,還包含有「person」結構體中的一些域(「prev」和「next」指針)。
可是出人意料的是,從技術角度來看這種分解卻十分有道理:
1. 鏈表級的臨界區保護着黃色部分的字段,這確保了鏈表的一致性。另外一方面,鏈表級的臨界區並無對綠色部分的字段進行保護(「age」和「weight」),所以 容許對單獨的數據進行修改而沒必要鎖住整個鏈表。
2. 黃色部分的字段應該只能由「people」類的方法來訪問,儘管從內存佈局上來看它們都是屬於「person」結構體的。
3. 若是編程語言容許咱們在「people」類的內部聲明黃色部分的字段,那麼封裝的原則就不會被打破。換句話說,將「person」添加到其它鏈表中時就不須要 對「person」類的定義進行修改。
最後,讓咱們作一個概念性的實驗,採用上述思想來擴展C++。請注意,咱們的目標不是爲了提供一種完美的語言擴展設計,更多的是爲了展現在C++中實現這種思想的可能性。
也就是說,讓我引入一種「private in X」的語法結構。它可使用在類定義中,遵循「private in X」形式的數據成員在物理上(做者指的是按內存佈局來看)屬於結構體X的一部分,可是它們只能由被定義的類來訪問:
1
2
3
4
5
6
|
class
person
{
public
:
int
age;
int
weight;
};
|
1
2
3
4
5
6
7
8
9
|
class
people
{
private
:
person *first;
person *last;
private
in person:
person *prev;
person *next;
};
|
個人結論是,若是ZeroMQ用C來實現的話,內存分配將更少,產生的內存碎片也更少。一些算法的複雜度將達到O(1),而不是O(n)或者O(logn)。
效率低下的問題不在於ZeroMQ的代碼自己,也不是面向對象編程的固有缺陷,更多的是在於C++語言的設計上。固然,公平的說C++並非惟一,一樣的問題也存在於大多數——若是不是所有的話——面向對象編程語言中。
英文原文:martin_sustrik 編譯:伯樂在線— 陳舸