被誤解的C++——漢尼拔
by 莫華楓
公元前216年8月2日,意大利東部平原,一個叫做坎尼的地方,兩支大軍擺開陣勢,準備決一死戰。一方是由保羅斯和瓦羅兩位執政官率領的羅馬人,另一方則是偉大的軍事天才漢尼拔*巴卡率領的迦太基軍隊及其同盟。羅馬人超過8萬,而迦太基僅有4萬余人。然而到了傍晚,羅馬人被徹底擊敗,7萬人被殺,僅有少數得以逃脫。這就是著名的坎尼會戰。經此一役,(外加先前進行的特利比亞和特拉西梅諾湖會戰),羅馬人元氣大傷,成年公民損失達五分之一。部分城邦背叛羅馬,西西里也發生起義。羅馬已經到了搖搖欲墜的地步。
漢尼拔的這些勝利,完全得益于先前的一次異乎尋常的遠征。公元前218年,漢尼拔率領軍隊,從新迦太基城(西班牙)出發,翻越比利牛斯山,進入南高盧地域。在他面前有兩條路可走,翻越阿爾俾斯山,或者沿海岸進入意大利。但是,當時羅馬人已在沿海地區部署了兩支部隊,準備攔截漢尼拔。而且,羅馬人的海軍優勢,使得他們可以在任何時候將一支部隊登陸在他的背后。而翻越阿爾俾斯山,則是一條及其艱險的道路,更何況是在冬天。
漢尼拔選擇了阿爾俾斯山。他甩開了羅馬人,從小圣貝納德和日內瓦山之間越過阿爾俾斯山,進入意大利境內。此時,羅馬人便失去了戰略縱深,一把尖刀已經深深地插入他們的腹內...
C++的發展史上,也有著如同漢尼拔翻越阿爾俾斯山遠征。一切還得從C with Class時代說起。
Bjarne曾經反復強調,他創建C++為的是將Simular的抽象能力同C的性能結合起來。于是,在C語言的基礎上,誕生了一種擁有類、繼承、重載等等面向對象機制的語言。在這個階段,C++提供了兩個方面的抽象能力。一種是數據抽象,也就是將數據所要表達的含義通過類型以及依附于類型上的成員表述。另一種則是多態,一種最原始的多態(重載)。
數據抽象,通過被稱為“抽象數據類型(ADT)”的技術實現。ADT的一種方案,就是類。類所提供的封裝性將一個數據實體的外在特征,或者說語義的表述形式,同具體的實現,比如數據存儲形式,分離。這樣所增加的中間層將數據的使用者同數據的實現者隔離,使得他們使用共同的約定語義工作,不再相互了解彼此的細節,從而使得兩者得以解耦。
多態則是更加基礎更加重要的一種特性。多態使得我們得以用同一種符號實現某種確定的語義。多態的精髓在于:以一種形式表達一種語義。在此之前,我們往往被迫使用不同的符號來代表同一種抽象語義,為的是適應強類型系統所施加的約束。比如:
//代碼#1
int add_int(int lhs, int rhs);
float add_float(float lhs, float rhs);
很顯然,這兩個函數表達的語義分別是“把兩個int類型值加在一起”和“把兩個float類型值加在一起”。這兩個語義抽象起來都表達了一個意思:加。
我們在做算術題的時候是不會管被計算的數字是整數還是實數。同樣,如果能夠在編程的時候,不考慮算術操作對象的類型,只需關心誰和誰進行什么操作,那么會方便得多。當C++引入重載后,這種愿望便得以實現:
//代碼#2
int add(int lhs, int rhs);
float add(float lhs, float rhs);
重載使得我們只需關心“加”這個語義,至于什么類型和什么類型相加,則由編譯器根據操作數的類型自動解析。
從某種意義上說,重載是被長期忽視,但卻極為重要的一個語言特性。在多數介紹OOP的書籍中,重載往往被作為OOP的附屬品,放在一些不起眼的地方。它的多態本質也被動多態的人造光環所設遮蔽。然而,重載的重要作用卻在實踐中潛移默化地體現出來。重載差不多可以看作語言邁入現代抽象體系的第一步。它的實際效用甚至要超過被廣為關注的OOP,而不會像OOP那樣在獲得抽象的同時,伴隨著不小的副作用。
隨著虛函數的引入,C++開始具備了頗具爭議的動多態技術。虛函數是一種依附于類(OOP的類型基礎)的多態技術。其技術基礎是后期綁定(late-binding)。當一個類D繼承自類B時,它有兩種方法覆蓋(override)B上的某個函數:
//代碼#3
class B
{
public:
void fun1();
virtual void fun2();
};
class D:public B
{
public:
void fun1();
void fun2();
};
當繼承類D中聲明了同基類B中成員函數相同函數名、相同簽名的成員函數,那么基類的成員函數將被覆蓋。對于基類的非虛成員函數,繼承類會直接將其遮蔽。對于類型D的使用者,fun1代表了D中所賦予的語義。而類型D的實例,可以隱式地轉換成類型B的引用b,此時調用b的fun1,則執行的是類B的fun1 定義,而非類D的fun1,盡管此時b實際指向一個D的實例。
但是,如果繼承類覆蓋了基類的虛函數,那么將得到相反的結果:當調用引用b的fun2,實際上卻是調用了D的fun2定義。這表明,覆蓋一個虛函數,將會在繼承類和基類之間的所有層次上執行覆蓋。這種徹底的、全方位的覆蓋行為,使得我們可以在繼承類上修飾或擴展基類的功能或行為。這便是OOP擴展機制的基礎。而這種技術被稱為動多態,意思是基類引用所表達的語義并非取決于基類本身,而是來源于它所指向的實際對象,因此它是“多態”的。因為一個引用所指向的對象可以在運行時變換,所以它是“動”的。
隨著動多態而來的一個“副產品”,卻事實上成為了OOP的核心和支柱。虛函數的“動多態”特性將我們引向一個極端的情況:一個都是虛函數的類。更重要的,這個類上的虛函數都沒有實現,每個虛函數都未曾指向一個實實在在的函數體。當然,這樣的類是無法直接使用的。有趣的是,這種被稱為“抽象基類”的類,迫使我們繼承它,并“替它”實現那些沒有實現的虛函數。這樣,對于一個抽象基類的引用,多態地擁有了繼承類的行為。而反過來,抽象基類實際上起到了強迫繼承類實現某些特定功能的作用。因此,抽象基類扮演了接口的角色。接口具有兩重作用:一、約束繼承類(實現者)迫使其實現預定的成員函數(功能和行為);二、描述了繼承類必定擁有的成員函數(功能和行為)。這兩種作用促使接口成為了OOP設計體系的支柱。
C++在這方面的進步,使其成為一個真正意義上具備現代抽象能力的語言。然而,這種進步并非“翻越阿爾俾斯山”。充其量也只能算作“翻越比利牛斯山”。對于C++而言,真正艱苦的遠征才剛開始,那令人生畏的“阿爾俾斯山”仍在遙遠的前方。
同漢尼拔一樣,當C++一腳邁入“現代抽象語言俱樂部”后,便面臨兩種選擇。或者在原有基礎上修修補補,成為一種OOP語言;或者繼續前進,翻越那座險峻的山峰。C++的漢尼拔——Bjarne Stroustrup——選擇了后者。
從D&E的描述中我們可以看到,在C++的原始設計中就已經考慮“類型參數”的問題。但直到90年代初,才真正意義上地實現了模板。然而,模板只是第一步。諸如Ada等語言中都有類似的機制(泛型,generic),但并未對當時的編程技術產生根本性的影響。
關鍵性的成果來源于Alex Stepanov的貢獻。Stepanov在后來被稱為stl的算法-容器庫上所做的努力,使得一種新興的編程技術——泛型編程(Generic Programming,GP)——進入了人們的視野。stl的產生對C++的模板機制產生了極其重要的影響,促使了模板特化的誕生。模板特化表面上是模板的輔助特性,但是實際上它卻是比“類型參數”更加本質的機能。
假設我們有一組函數執行比較兩個對象大小的操作:
//代碼#4
int compare(int lhs, int rhs);
int compare(float lhs, float rhs);
int compare(string lhs, string rhs);
重載使得我們可以僅用compare一個函數名執行不同類型的比較操作。但是這些函數具有一樣的實現代碼。模板的引入,使得我們可以消除這種重復代碼:
//代碼#5
template<typename T> int compare(T lhs, T rhs) {
if(lhs==rhs)
return 0;
if(lhs>rhs)
return 1;
if(lhs<rhs)
return -1;
}
這樣一個模板可以應用于任何類型,不但用一個符號表達了一個語義,而且用一個實現代替了諸多重復代碼。這便是GP的基本作用。
接下來的變化,可以算作真正意義上的“登山”了。
如果有兩個指針,分別指向兩個相同類型的對象。此時如果我們采用上述compare函數模板,那么將無法得到所需的結果。因為此時比較的是兩個指針的值,而不是所指向的對象本身。為了應付這種特殊情況,我們需要對compare做“特別處理”:
//代碼#6
template<typename T> int compare(T* lhs, T* rhs) {
if(*lhs==*rhs)
return 0;
if(*lhs>*rhs)
return 1;
if(*lhs<*rhs)
return -1;
}
這個“特殊版本”的compare,對于任何類型的指針作出響應。如果調用時的實參是一個指針,那么這個“指針版”的compare將會得到優先匹配。如果我們將compare改成下面的實現,那么就會出現非常有趣的行為:
//代碼#7
template<typename T>
struct comp_impl
{
int operator()(T lhs, T rhs) {
if(lhs==rhs)
return 0;
if(lhs>rhs)
return 1;
if(lhs<rhs)
return -1;
}
};
template<typename T>
struct comp_impl<T*>
{
int operator()(T* lhs, T* rhs) {
comp_impl<T>()(*lhs, *rhs);
}
};
template<typename T> int compare(T* lhs, T* rhs) {
comp_impl<T>()(*lhs, *rhs);
}
當我們將指針的指針作為實參,調用compare時,神奇的事情發生了:
//代碼#8
double **x, **y;
compare(x, y);
compare居然成功地剝離了兩個指針,并且正確地比較了兩個對象的值。這個戲法充分利用了類模板的局部特化和特化解析規則。根據規則,越是特化的模板,越是優先匹配。T*版的comp_impl比T版的更加“特化”,會得到優先匹配。那么當一個指針的指針實例化comp_impl,則會匹配T*版的 comp_impl,因為指針的指針,也是指針。T*版通過局部特化機制,剝離掉一級指針,然后用所得的類型實例化comp_impl。指針的指針剝離掉一級指針,那么還是一個指針,又會匹配T*版。T*版又會剝離掉一級指針,剩下的就是真正可以比較的類型——double。此時,double已無法與 T*版本匹配,只能匹配基礎模板,執行真正的比較操作。
這種奇妙的手法是蘊含在模板特化中一些更加本質的機制的結果。這種意外獲得的“模板衍生產品”可以算作一種編譯時計算的能力,后來被一些“好事者”發展成獨立的“模板元編程”(Template Meta Programming,TMP)。
盡管TMP新奇而又奧妙,但終究只是一種輔助技術,用來彌補C++的一些缺陷、做一些擴展,“撿個漏”什么的。不過它為我們帶來了兩點重要的啟示:一、我們有可能通過語言本身的一些機制,進行元編程;二、元編程在一定程度上可以同通用語言一起使用。這些啟示對編程語言的發展有很好的指導意義。
模板及特化規則是C++ GP的核心所在。這些語言特性的強大能力并非憑空而來。實際上有一只“幕后大手”在冥冥之中操縱著一切。
假設有一個類型系統,包含n個類型:t1,...,tn,那么這些類型構成了一個集合T={t1,...,tn}。在當我們運用重載技術時,實際上構造了一組類型的tuple到函數實現的映射:<ti1,ti2,ti3,...> ->fj()。編譯器在重載解析的時候,就是按照這組映射尋找匹配的函數版本。當我們編寫了形如代碼#5的模板,那么就相當于構建了映射:<T, T,...> ->f0()。
而代碼#6,以及代碼#7中的T*版模板,實際上是構造了一個<Tp>->fp()的映射。這里Tp是T的一個子集:Tp={t'|t'=ti*, ti∈T}。換句話說,特化使泛型體系細化了。利用模板特化技術,我們已經能夠(笨拙地)分辨浮點數、整數、內置類型、內置數組、類、枚舉等等類型。具備為類型劃分的能力,也就是構造不同的類型子集的能力。
現在,我們便可以構造一個“泛型體系”:G={T} U T U Tp U Ta U Ti U Tf U Tc ...。其中,Tp是所有指針類型,Ta是數組,Ti是整數,Tf是浮點數,Tc是類等等。但是如果我們按照泛化程度,把G中的元素排列開:{T, Tp, Ta, Ti,...,t1,...,tn}。我們會發現這中間存在一些“斷層”。這些斷層位于T和Tp等之間,以及Tp等與ti等之間等等。這表明在C++98/03中,抽象體系不夠完整,存在缺陷。
所以,到目前為止,C++還沒有真正翻越阿爾俾斯山里那座最險峻的山峰。這正是C++0x正在努力做的,而且勝利在望。
在C++0x中,大牛們引入了first-class的concept支持。concept目前還沒有正式的法定描述(以及合理的中文翻譯)。通俗地講,concept描述了一個類型的(接口)特征。說具體的,一個concept描述了類型必須具備的公共成員函數,必須具備的施加在該類型上的自由函數(和操作符),以及必須具備的其他特征(通過間接手段)。下面是一個典型的concept:
concept has_equal<T>
{
bool T::equal(T const& v);
};
這個concept告訴我們它所描述的類型必須有一個equal成員,以另一個同類型的對象為參數。當我們將這個concept施加在一個函數模板上,并作為對類型參數的約束,那么就表明了這個模板對類型參數的要求:
template<has_equal T>bool is_equal(T& lhs, T const& rhs) {
return lhs.equal(rhs);
}
如果實參對象的類型沒有equal成員,那么is_equal將會拒絕編譯通過:這不是我要的!
concept是可以組合的,正式的術語叫做“refine”。我們可以通過refine進一步構造出約束更強的concept:
concept my_concept<T> : has_equal<T>, DefaultConstructable<T>, Swappable<T> {}
refine獲得的concept將會“繼承”那些“基concept”的所有約束。作為更細致的組合手段,concept還可以通過!操作符“去掉”某些內涵的concept約束:
concept my_concept1<T> : has_equal<T>, !DefaultConstructable<T> {}
這個concept要求類型具備equal成員,但不能有默認構造函數。
通過這些手段,concept可以“無限細分”類型集合T。理論上,我們可以制造一連串只相差一個函數或者只相差一個參數的concept。
一個concept實際上就是構成類型集合T的劃分的約束:Tx={ti| Cx(ti)==true, ti∈T}。其中Cx就是concept所構造的約束。不同的concept有著不同范圍的約束。這樣,理論上我們可以運用concept枚舉出類型集合 T的所有子集。而這些子集則正好填補了上述G中的那些斷層。換句話說,concept細化了類型劃分的粒度,或者說泛型的粒度。使得“離散”的泛型系統變成“連續”的。
當我們運用concept約束一個函數模板的類型參數時,相當于用concept所描述的類型子集構建一個映射:<Tx1,Tx2,...>->fx()。凡是符合tuple <Tx1,Tx2,...>的類型組合,對應fx()。所以,從這個角度而言,函數模板的特化(包括concept)可以看作函數重載的一種擴展。在concept的促進下,我們便可以把函數模板特化和函數重載統一在一個體系下處理,使用共同的規則解析。
在目前階段,C++差不多已經登上了“抽象阿爾俾斯山”的頂峰。但是就如同漢尼拔進入意大利后,還需要面對強盛的羅馬共和國,與之作戰那樣。C++的面前還需要進一步將優勢化作勝利。要做的事還很多,其中最重要的,當屬構建Runtime GP。目前C++的GP是編譯時機制。對于運行時決斷的任務,還需要求助于OOP的動多態。但是C++領域的大牛們已經著手在Runtime GP和Runtime Concept等方面展開努力。這方面的最新成果可以看這里、這里和這里。相信經過若干年的努力后,GP將會完全的成熟,成為真正主流的編程技術。
坎尼會戰之后,漢尼拔已經擁有了絕對的優勢。羅馬人已經戰敗,他同羅馬城之間已經沒有任何強大的敵對力量,羅馬人也已經聞風喪膽,幾無斗志。但是,漢尼拔卻犯下了或許令他一生后悔的錯誤。他放過了羅馬城,轉而攻擊羅馬的南部城邦和同盟。他低估了羅馬人的意志,以及羅馬同盟的牢固程度。羅馬人很快結束了最初的混亂,任命了新的執政官,采用了堅壁清野、以柔克剛的新戰略。隨著時間的推移,漢尼拔和他的軍隊限于孤立無援的境地,被迫為了生存而作戰。盡管迫降并占領了幾個羅馬城市,但是終究無法再次獲得給予羅馬人致命一擊的機會。
漢尼拔的戰略錯誤實際上在從新迦太基城出發之前已經注定。因為漢尼拔對羅馬人的遠征的根本目的并非擊潰并占領羅馬,而是通過打擊羅馬,削弱他們的勢力,瓦解他們的聯盟。以達到尋求簽訂和平協議的目的。然而這種有限戰略卻使導致了他的最終失敗。
不幸的是,C++或多或少地有著同漢尼拔一樣的戰略錯誤。C++最初的目的基本上僅僅局限于“更好的C”。并且全面兼容C。這在當時似乎很合理,因為C可以算作最成功的“底層高級語言”,擁有很高的性能和靈活性。但是,C的設計并未考慮將來會有一個叫做“C++”的語言來對其進行擴展。結果很多優點對于C而言是優點,但卻成了C++的負擔。比如,C大量使用操作符表達語法結構,對于C而言顯得非常簡潔,但對于C++,則使其被迫大規模復用操作符,為其后出現的很多語法缺陷埋下了伏筆。這一點上,Ada做得相對成熟些。它從Pascal那里繼承了主要語法,但不考慮兼容。這使得Ada更加完整,易于發展。新語言就是新語言,過分的兼容是鐐銬,不是優勢。而且,合理地繼承語法,同樣可以吸引眾多開發者。從經驗來看,程序員對于語法變化的承受能力還是很強的。他們更多地關心語言的功能和易用性。
另一方面,C++最初并未把目標定在“創建一種高度抽象,又確保性能的語言”。縱觀C++的發展,各種抽象機制并非在完整的規劃或路線圖的指導下加入語言。所有高級特性都是以“添油戰術”零打碎敲地加入語言。從某種程度上來看,C++更像是一種實驗性語言,而非工業語言。C++的強大功能和優點是長期積累獲得的,而它的諸多缺陷也是長期添加特性的結果。
漢尼拔和C++給予我們一個很好的教訓。對于一個試圖在1、20年后依然健康成長的語言,那么就必須在最初便擁有明確的目標和技術發展規劃。對于以往的語言特性應當擇優而取,不能照單全收。并且在技術上擁有足夠的前瞻性。我們知道,技術前瞻性是很難做到的,畢竟技術發展太快。如果做不到,那就得有足夠的魄力對過去的東西加以取舍。所謂“舍小就大,棄子爭先”。
總體而言,C++在抽象機制的發展方面,還算是成功的。盡管伴隨著不少技術缺陷,但是C++的抽象能力在各種語言中可稱得上出類拔萃。而且C++還在發展,它未來將會發展成什么形態,不得而知。但是,無論C++是繼續修修補補,還是根本性地變革,它的抽象能力都會不折不扣地保留,并且不斷完善和增強。
坎尼會戰之后,漢尼拔又打過幾次小規模的勝仗。但經過長期的作戰,也得不到迦太基的支援,漢尼拔的力量越來越弱,只能在意大利半島上勉強生存。羅馬很快恢復了元氣,改革了軍事體系和作戰方式,重新掌握了戰略主動權。更重要的是,羅馬也有了自己的“漢尼拔”——(征服非洲的)普布利烏斯·科爾內利烏斯·西庇阿(大西庇阿)。西庇阿被派往北非大陸,直接攻擊迦太基人的老巢。漢尼拔被召回,在扎馬與西庇阿擺開陣勢,展開一場決戰。最終,西庇阿運用從漢尼拔那里學到的戰術擊潰了迦太基人,為羅馬人贏得了第二次布匿戰爭的勝利。
此后,漢尼拔在羅馬人的通緝之下,流亡于地中海沿岸,試圖尋求東山再起的機會。但最終未能如愿,被迫于公元前183年自盡,享年64歲。有趣的是,他的老對手,小他12歲的西庇阿也于同年去世。一個偉大的傳奇就此結束。