GP技術(shù)的展望——C--
莫華楓
C++的復(fù)雜是公認的,盡管我認為在人類的聰明智慧之下,這點復(fù)雜壓根兒算不上什么。不過我得承認,對于一般的應(yīng)用而言,C++對程序員產(chǎn)生的壓力還是不
小的。畢竟現(xiàn)在有更多更合適的選擇。僅僅承認復(fù)雜,這沒有什么意義。我不時地產(chǎn)生一個念頭:有什么辦法既保留C++的優(yōu)點,而消除它的缺點和復(fù)雜。我知道
D語言在做這樣的事情。但是,D更多地是在就事論事地消除C++的缺陷,而沒有在根本上消除缺陷和復(fù)雜性。
一般而言,一樣?xùn)|西復(fù)雜了,基本上都是因為東西太多。很顯然,C++的語言特性在眾多語言中是數(shù)一數(shù)二的。于是,我便想到或許把C++變成“C--”,可以治好C++的復(fù)雜之病。在探討這個問題之前,讓我們先從C出發(fā),看看C++為何如此復(fù)雜。
C和C++
盡管存在這樣那樣的不足,比如non-lalr的語法、隱式的指針類型轉(zhuǎn)換等,但C語言的設(shè)計哲學(xué)卻是足夠經(jīng)典的。C語言有一個非正式的分類,認為它既非
匯編這樣的
低級語言,也非
Pascal那樣的
高級語言,
而應(yīng)該算作中級語言,介于其他兩類語言之間。這種分類恰如其分地點出了C語言的特點和理念:以高級語言語法形式承載了低級語言的編程模型。低級語言的特點
是可以直接地描述硬件系統(tǒng)的結(jié)構(gòu)。C則繼承了這個特點。C語言直觀地反映了硬件的邏輯構(gòu)造,比如數(shù)組是內(nèi)存塊,可以等價于指針。在C語言中,我們可以幾乎
直接看到硬件的構(gòu)造,并且加以操作。這些特性對于底層開發(fā)至關(guān)重要。
然而,C的這種直觀簡潔的模型過于底層和瑣碎,不利于應(yīng)用在那些構(gòu)造復(fù)雜、變化多樣的應(yīng)用中。針對C的這些弱點,Bjarne
Stroustrup決心利用OOP技術(shù)對C語言進行改造,從而促使了C++的誕生。C++全面(幾乎100%)地兼容C,試圖以此在不損失C語言的直觀
和簡潔的情況下,同時具備更強的軟件工程特性,使其具備開發(fā)大型復(fù)雜系統(tǒng)的優(yōu)勢。這個目標“幾乎”達到了,但是代價頗為可觀。
在經(jīng)歷了80、90年代的輝煌之后,C++的應(yīng)用領(lǐng)域開始退步。一方面,在底層應(yīng)用方面,C++的很多特性被認為是多余的。如果不使用這些特性,那么
C++則同C沒有什么差別。相反這些特性的使用,對開發(fā)團隊的整體能力提出了更高的要求。因而,在最底層,很多人放棄了C++而回歸了C,因為那些高級特
性并未帶來很多幫助,反而產(chǎn)生了很多負擔(dān)。另一方面,在高層開發(fā)中,業(yè)務(wù)邏輯和界面也無需那么多底層的特性和苛刻的性能要求,更多簡單方便、上手容易的語
言相比C++更加適合。C++的應(yīng)用被壓縮在中間層,隨著業(yè)界系統(tǒng)級開發(fā)的不斷專業(yè)化,C++的使用規(guī)模也會越來越小。(當然,它所開發(fā)的應(yīng)用往往都是關(guān)
鍵性的,并且是沒有選擇余地的)。實際上,C++在這個層面也并非完美的工具。目前無法取代是因為沒有相同級別的替代品。D或許是個強有力的競爭者,但一
方面出于歷史遺留代碼的規(guī)模和應(yīng)用慣性,另一方面D也并未完全解決C++面臨的復(fù)雜性問題,D也很難在可見的將來取代C++。
實際上,C++的這種尷尬地位有著更深層次的原因。C++的本意是在保留C的底層特性基礎(chǔ)上,增加更好的軟件工程特性。但是,C++事實上并未真正意義上
地保留C的底層特性。回顧一下C的設(shè)計理念——直觀而簡潔地反映底層硬件的特性。C++通過兼容C獲得了這種能力。但是這里有個問題,如果我要獲得C的這
種簡單直觀性,就必須放棄C++中的很多高級特性。這里最明顯的一個例子便是pod(
plain old data)。
在C中壓根沒有pod的概念,因為所有的對象都是pod。但是,C++中有了pod。因為C++的對象可能不是一個pod,那么我們便無法象在C當中那樣
獲得直觀簡潔的內(nèi)存模型。對于pod,我們可以通過對象的基地址和數(shù)據(jù)成員的偏移量獲得數(shù)據(jù)成員的地址,或者反過來。但在非pod對象中,卻無法這么做。
因為C++的標準并未對非pod對象的內(nèi)存布局作出定義,因而對于不同的編譯器,對象布局是不同的。而在C中,僅僅會因為底層硬件系統(tǒng)的差異而影響對象布
局。
這個問題通常并不顯而易見。但在很多情況下為我們制造了不小的障礙。比如,對象的序列化:我們試圖將一個對象以二進制流的形式保存在磁盤中、數(shù)據(jù)庫中,或
者在網(wǎng)上傳輸,如果是pod,則直接將對象從基地址開始,按對象的大小復(fù)制出來,或傳輸,或存儲,非常方便。但如果是非pod,由于對象的不同部分可能存
在于不同的地方,因而無法直接復(fù)制,只能通過手工加入序列化操作代碼,侵入式地讀取對象數(shù)據(jù)。(這個問題不僅僅存在于C++,其他語言,如java、C#
等都存在。只是它們沒有很強烈的性能要求,可以使用諸如reflect等手段加以處理)。同樣的問題也存在于諸如hash值的計算等方面。這對很多開發(fā)工
作造成不小的影響,不僅僅在底層,也包括很多高層的應(yīng)用。
究其原因,C++僅僅試圖通過機械地將C的底層特性和OOP等高層特性混合在一起,意圖達到兩方兼顧的目的。但是,事與愿違,
OOP的
引入實際上使得C的編程模型和其他更高級的抽象模型無法兼容。在使用C++的過程中,要么只使用C的特性,而無法獲得代碼抽象和安全性方面的好處,要么放
棄C的直觀簡潔,而獲得高層次的抽象能力。反而,由于C和OOP編程模型之間的矛盾,大大增加了語言的復(fù)雜性和缺陷數(shù)。
舍棄
但是,我們可以看到在C++中,并非所有的高級特性都與C的底層特性相沖突。很多使用C而不喜歡C++的人都表示過他們原意接受
OB,也就是僅僅使用
封裝 。對于
RAII,基本上也持肯定的態(tài)度。或許也會接受
繼承,但也表露出對這種技術(shù)帶來的復(fù)雜性的擔(dān)心。
動多態(tài)是明顯受到排斥的技術(shù)。顯然這是因為動多態(tài)破壞了C的編程模型,使得很多本來簡單的問題復(fù)雜化。不是它不好,或者沒用,是它打破了太多的東西。
因而,我們設(shè)想一下,如果我們?nèi)コ齽佣鄳B(tài)特性,那么是否會消除這類問題呢?我們一步步看。
動多態(tài)的一個基本支撐技術(shù)是
虛函數(shù)。在使用虛函數(shù)的情況下,類的每一次繼承都會產(chǎn)生一個
虛函數(shù)表(vtable),其中存放的是指向虛函數(shù)的指針。這些虛函數(shù)表必須存放在對象體中,也就是和對象的數(shù)據(jù)存放在一起(至少要關(guān)聯(lián)在一起)。因而,對象在內(nèi)存里并不是以連續(xù)的方式存放,而被分割成不同的部分,甚至身首異處(詳見《
Inside C++ Object Model》)。這便造成了前面所說的非pod麻煩。一旦放棄虛函數(shù)和vtable,對象的內(nèi)存布局中,便不會有東西將對象分割開。所有的對象的數(shù)據(jù)存儲都是連續(xù)的,因而都是pod。在這一點上,通過去除vtable,使得語言回歸了C的直觀和簡單。
動多態(tài)的內(nèi)容當然不僅僅是一個虛函數(shù),另一個重要的基石是繼承。當然,我們并不打算放棄繼承,因為它并不直接破壞C的直觀性和簡潔性。不同于虛函數(shù),繼承
不是完全為了動多態(tài)而生的。繼承最初的用途在于代碼復(fù)用。當它被賦予了多態(tài)含義后,才會成為動多態(tài)的基礎(chǔ)。以下的代碼可以有兩種不同的解讀:
class B : public A {};
從
代碼復(fù)用的角度來看,B繼承自A,表示我打算讓B復(fù)用A的所有代碼,并且增加其他功能。而從多態(tài)的角度來看,B是一個A的擴展,B和A之間存在
is-a的
關(guān)系。(B是一個A)。兩者是站在不同層面看待同一個問題。代碼復(fù)用,代表了編碼的觀點,而多態(tài),則代表了業(yè)務(wù)邏輯的觀點。但是,兩者并非實質(zhì)上的一回
事。在很多情況下,基類往往作為繼承類的某種代表,或者接口,這在編碼角度來看并沒有對等的理解。而這種接口作用,則是動多態(tài)的基礎(chǔ)。動多態(tài)通過不同的類
繼承自同一個基類,使它們擁有共同的接口,從而可以使用統(tǒng)一的形式加以操作。作為一個極端,
interface(或者說
抽象基類),僅僅擁有接口函數(shù)(即vtable)而不包含任何數(shù)據(jù)成員。這是純粹的接口。
然而,這里存在一個缺陷。一個接口所代表的是一組類,它將成為這一組類同外界交互的共同界面。但是,使用基類、或者抽象基類作為接口,實質(zhì)上是在使用一個
類型來代表一組類型。打個比方,一群人湊在一起出去旅游,我們稱他們這群人為“旅行團”。我們知道旅行團不是一個人,而是一個不同于“人”的概念。動多態(tài)
里的接口相當于把一個旅行團當作一個人來看待。盡管這只是邏輯上的,或許一個旅行團的很多行為和一個人頗為相似。但是根本上而言,兩者畢竟不是相同層次的
概念。這樣的處理方法往往會帶來了很多弊端。
為了使繼承被賦予的這重作用發(fā)揮作用,還需要一項非常關(guān)鍵的處理:類型轉(zhuǎn)換。請看以下代碼:
void func(A* a);
B b;
func(&b);
最后這行代碼施行了動多態(tài),如果B
override了A的虛函數(shù)的話。很顯然,如果嚴格地從強類型角度而言,&b是不應(yīng)當作為func的實參,因為兩者類型不匹配。但是如果拒絕接
受&b作為實參,那么動多態(tài)將無法進行下去。因此,我們放寬了類型轉(zhuǎn)換的限制:允許繼承類對象的引用或指針隱式地轉(zhuǎn)換成基類的引用或指針。這樣,
形如func(&b);便可以順理成章地成為合法的代碼。
然而,這也是有代價的:
B ba[5];
func(ba);
后面這行函數(shù)調(diào)用實際上是一個極其危險的錯誤。假設(shè)在func()中,將形參a作為一個類型A的數(shù)組對待,那么當我們使用ba作為實參調(diào)用func()的
時候,會將ba作為A的
數(shù)組處理。我們知道,數(shù)組內(nèi)部元素是緊挨著的,第二個元素的位置是第一個元素的基址加上元素的尺寸,以此類推。如果傳遞進來的對象數(shù)組是B類型的,而被作
為A類型處理,那么兩者的元素位置將可能不同步。盡管B繼承自A,但是B的尺寸很有可能大于A,那么從第二個元素起,a[1]的地址并非ba[1]的地
址。于是,當我們以a[1]訪問ba時,實際上很可能在ba[0]的內(nèi)部某個位置讀取,而func()的代碼還以為是在操作ba[1]。這便是C++中的
一個重要的陷阱——對象切割。這種錯誤相當隱蔽,危險性極大。
由于C++試圖保留C的編程模型,因而保留了指針-數(shù)組的等價性。這種等價性體現(xiàn)了數(shù)組的本質(zhì)。這在C中是一項利器,并無任何問題。但在C++中,由于存
在了繼承,以及繼承類的隱式類型轉(zhuǎn)換,使得這種原本滋補的特性成為了一劑毒藥。換句話說,C++所引入的動多態(tài)破壞了C的直觀性。
舍棄之后
從上面的分析來看,動多態(tài)同C的編程模型是不相容的。因而如果希望得到C的直觀性,并且消除C++的缺陷,必須放棄動多態(tài)這個特性。現(xiàn)在來看看放棄之后將會怎樣。
一旦放棄了動多態(tài),也就放棄了虛函數(shù)和vtable。此時,所有的對象都是pod了。那么首當其沖的好處,就是可以進行非侵入的序列化、hash計算等等
操作。由于對象肯定是連續(xù)分布的,可以直接地將對象取出進行編碼、存儲、計算和傳輸,而無需了解對象內(nèi)部的數(shù)據(jù)結(jié)構(gòu)和含義。另外一個重要的問題也會得到解
決,這就是ABI。在C中統(tǒng)一的ABI很自然地存在于語言中。我們可以很容易地用link將兩個不同編譯器編譯的模塊連接起來,而不會發(fā)生問題。但
是,C++中做不到,除非不再使用類而使用純C。目前C++還沒有統(tǒng)一的ABI,即便標準委員會有意建立這樣的規(guī)范,實現(xiàn)起來也絕非易事。但是,如果放棄
動多態(tài)之后,對象的布局便回歸到C的形態(tài),從而使得ABI不再成為一個問題。
另一方面,隨著動多態(tài)的取消,那么繼承的作用被僅僅局限于代碼復(fù)用,不再具有構(gòu)造接口的作用。我們前面已經(jīng)看到,繼承類向基類的隱式轉(zhuǎn)換,是為了使基類能
夠順利地成為繼承類的接口。既然放棄了動多態(tài),那么也就無需基類再承擔(dān)接口的任務(wù)。那么由繼承類向基類的隱式類型轉(zhuǎn)換也可以被禁止:
void func(A* a);
B b;
func(&b); //編譯錯誤,類型不匹配
進而對象切割也不會發(fā)生:
B ba[5];
func(ba); //編譯錯誤,類型不匹配
盡管根據(jù)數(shù)組-指針的等價性,ba可以被隱式地轉(zhuǎn)換為B*,但是B*不再能夠隱式地轉(zhuǎn)換為A*,從而避免了對象的切割。
問題是,如此簡單地將動多態(tài)放棄掉,就如同將水和孩子一起潑掉那樣,實際上放棄了動多態(tài)帶來的好處。實際上并非如此。我們放棄動多態(tài)這個特性,但并不打算放棄它所具有的功能,而是用另一種技術(shù)加以替代。這便是runtime concept(
這里和
這里)。
不同于以類型為基礎(chǔ)的interface,
concept是獨立于類型的系統(tǒng)。concept生來便是為了描述一組類型,因而是接口最理想的實現(xiàn)手段。當concept runtime化之后,便具有了與動多態(tài)相同的功能(很多方面還有所超越)。
runtime
concept同樣需要類似vtable的函數(shù)分派表,但由于它不是類型,這些分派表無需存放在對象內(nèi)部,可以獨立放置(可以同RTTI信息放在一起),
并且只需一份。正是基于這個特性,方才保證了所有對象依然是pod,依然能夠保證對象布局的直觀性。
同樣,runtime concept承擔(dān)了接口的任務(wù),但不象動多態(tài)那樣依賴于繼承和相應(yīng)的隱式類型轉(zhuǎn)換。(通過自動或手動的concept_map)。因而,我們依舊可以禁止基于繼承關(guān)系的隱式類型轉(zhuǎn)換,從而防止對象切割的情況。
一旦使用concept作為多態(tài)的實現(xiàn)手段,反倒促使原本動多態(tài)的一些麻煩得到消除。在動多態(tài)中,必須指定virtual函數(shù)。如此,在一個類中會存在兩
種不同形態(tài)的函數(shù),實現(xiàn)動多態(tài)的虛函數(shù),和無此功能的普通函數(shù)。準確地維護這樣兩種函數(shù),頗有些難度。而且,函數(shù)是虛還是不虛,牽涉到系統(tǒng)的設(shè)計,必須在
最初構(gòu)建時確定,否則以后很難修改。但在放棄動多態(tài),使用concept的情況下,只要一個繼承類中,使用相同的簽名覆蓋基類中的函數(shù),便實現(xiàn)了多態(tài)。當
進行concept_map,即將接口與類綁定時,只會考慮繼承類的函數(shù),而忽略基類中被覆蓋的函數(shù)。于是,只需簡單的覆蓋,便實現(xiàn)了多態(tài)的控制。對于是
否多態(tài)一個函數(shù),即是否改變基類函數(shù)的行為,完全由繼承類控制,在創(chuàng)建基類時不必為此傷神。其結(jié)果就是,我們無需在系統(tǒng)設(shè)計的最初一刻就操心多態(tài)的問題,
而只需根據(jù)實現(xiàn)的需要隨時實現(xiàn)。
其他
存在大量隱式轉(zhuǎn)換也是C++常受人詬病的一個方面,(特別是那些Pascal系的程序員)。隱式轉(zhuǎn)換的目的是帶來方便,使得編碼更加簡潔,減少冗余。同時也使得一些技巧得以施行。但是,隱式轉(zhuǎn)換的副作用也頗為可觀。比如:
void fun(short a);
long a=1248;
fun(a); //頂多一個警告
這種轉(zhuǎn)換存在兩面性:一方面,它可能是合理的,因為盡管a類型long大于short,但很可能存放著short可容納的數(shù)值;但另一方面,a的確存在short無法容納的可能性,這便會造成一個非常隱蔽的bug。
C/C++對此的策略是把問題扔給程序員處理,如果有bug那是程序員的問題。這也算得上合情合理,畢竟有所得必有所失,也符合C/C++的一貫理念。但
終究不是最理想的方式。但是如果象Pascal那樣將類型管得很死,那么語言又會失去靈活性,使得開發(fā)的復(fù)雜性增加。
如果試圖禁止隱式類型轉(zhuǎn)換,那么為了維持函數(shù)使用代碼的簡潔性,函數(shù)必須對所有的類型執(zhí)行重載。這大大增加了函數(shù)實現(xiàn)的負擔(dān),并且重復(fù)的代碼嚴重違背了
DRY原則。
現(xiàn)在或許存在一些途徑,使得在維持絕對強類型的情況下獲得所希望的靈活性。鑰匙可能依然在concept手上。考慮如下的代碼:
void fun(Integers a);
long a=1248;
fun(a);
longlong b=7243218743012;
fun(b);
此處,fun()是一個函數(shù),它的形參是一個concept,代表了所有的整型。這樣,這個函數(shù)便可以接受任何一種整型(或者具有整型行為的類型)。我們
相信,在一般的應(yīng)用下,任何整數(shù)都有完全相同的行為。因此,我們便可以很方便地使用Integers這個接口執(zhí)行對整數(shù)的操作,而無需關(guān)心到底是什么樣的
整數(shù)。
如此,我們便可以在禁止隱式類型轉(zhuǎn)換,不使用函數(shù)重載的情況下,完成這種函數(shù)的編寫。同時可以得到更好的類型安全性。
強制類型轉(zhuǎn)換是非常重要的特性,特別是在底層開發(fā)時。但也是雙刃劍,往往引來很隱蔽的錯誤。強制類型轉(zhuǎn)換很多情況下是無理的,通常都是軟件的設(shè)計問題造成的。但終究還是有一些情況,需要它來處理。
設(shè)想這樣一個場景:兩個一模一樣的類型,但它們分屬不同的函數(shù)。(這種情形盡管不多見,但還是存在的。這往往是混亂設(shè)計的結(jié)果。當然也有合理的情況,比如
來自兩個不同庫的類型)。我現(xiàn)在需要寫一個函數(shù),能夠同時使用這兩個類型。比較安全一些的,可以用函數(shù)重載。但是兩個重載的函數(shù)代碼是一樣的,典型的冗余
代碼。當然也可以針對其中一個結(jié)構(gòu)編寫代碼,然后在使用時,對另一個結(jié)構(gòu)的實例執(zhí)行強制類型轉(zhuǎn)換。但是,強制類型轉(zhuǎn)換畢竟不是件好事。因此,我們也可以構(gòu)
造一個concept,讓它描述這兩個類型。然后在編寫函數(shù)時使用這個concept,當這兩個類型都與concept綁定后,便可以直接使用這兩個類
型,而沒有類型安全和代碼冗余的問題。
(順便提一下,這種方式也可以運用在類型不同的情況下。比如兩個類型不完全相同,但是基本要素都一樣。那么就可以使用
concept_map的適配功能,
將兩個類型統(tǒng)一在一個concept下。這種方式相比oop的Adapter模式,更加簡潔。adapter本身是一個container,它所實現(xiàn)的接
口函數(shù),都必須一一轉(zhuǎn)發(fā)到內(nèi)部的對象,編寫起來相當繁瑣。但在concept_map中,對于那些符合concept描述的函數(shù)無需另行處
理,concept會自動匹配,只需對那些不符合要求的函數(shù)執(zhí)行適配。)
前面說過,指針數(shù)組的等價性體現(xiàn)了一種直觀的編程模型。但是,指針和數(shù)組畢竟還是存在很多差別,比如指針僅僅表達了一組對象在內(nèi)存中的位置,但并未攜帶對象數(shù)量的信息。因而,當數(shù)組退化成指針時,便已經(jīng)失去了數(shù)組的身份:
void func(int* x);
int a[20];
func(a);
這里,在函數(shù)func中已經(jīng)無法將a作為數(shù)組處理,因為無法知道變成int*后的a有多大來避免越界。甚至我們無法把a作為多個對象構(gòu)成的內(nèi)存塊看待,因為我們不知道大小。因此,只有顯式地給出數(shù)組大小,才能使用:
void func(int* x, long size);
但是,在concept的作用下,數(shù)組和指針得以依然保持它們的等價性的情況下,解決數(shù)組退化問題。考慮這樣兩個函數(shù):
void func1(Pointer x);
void func2(Container x);
其中,Pointer是代表指針的concept,而Container則是代表容器的concept。必須注意的是,Pointer是嚴格意義上的指
針,也就是說無法在Pointer上執(zhí)行迭代操作。Pointer只能作為指針使用,只具備dereference的能力(很像java的“指針”,不是
嗎?concept在沒有放棄C的底層特性的情況下也做到了。)。而Container則是專門用來表達容器的concept,其基本的特性便是迭代。在
func1中,無法對形參x執(zhí)行迭代,僅僅將其作為指向一個對象的指針處理,保證其安全性。而對于需要進行迭代操作的func2而言,x則是可以遍歷的。
于是,對于同一個數(shù)組a,兩個函數(shù)分別從不同的角度對其進行處理:
int a[20];
func1(a); //a直接作為指針處理,但不能迭代
func2(a); //a作為容器處理,可以迭代,并且其尺寸信息也一同傳入
此處實際上是利用了concept對類型特性的描述作用,將具有兩重性的數(shù)組類型(數(shù)組a即代表了數(shù)組這個容器,也代表了數(shù)組的起始地址)以不同特征加以
表達,以滿足不同應(yīng)用的需求。數(shù)組仍然可以退化成指針,C的直觀模型得到保留,在很多特殊的場合發(fā)揮作用。但在其他應(yīng)用場景,可以更加安全地使用數(shù)組。
總結(jié)
綜上所述,C++未能真正延續(xù)C的直觀簡潔,主要是由于動多態(tài)的一些基礎(chǔ)設(shè)施破壞了C的編程模型。因而,我們可以通過放棄動多態(tài),及其相關(guān)的一些技術(shù),代
之以更加“和諧”的runtime
concept,使得C++在基本保留C的編程模型的同時,獲得了相比原來更好的軟件工程特性。至此,這種改變后的C++(如果還能稱為C++的話)擁有
如下的主干特性:
1、SP,來自于C。
2、完全pod化。
3、OB。保留了封裝和RAII。盡管也保留了繼承,但其作用僅限于代碼復(fù)用,禁止基于繼承的隱式類型轉(zhuǎn)換。
4、GP,包括static和runtime concept。這是抽象高級特性的核心和基石。
這樣的語言特性實質(zhì)上比現(xiàn)有的C++更加簡潔,但是其能力更加強大。也比C++更易于貼近C的編程模型,以便適應(yīng)底層的開發(fā)。我不能說這樣的變化是否會產(chǎn)生一個更好的語言,但是我相信這些特性有助于構(gòu)造更加均衡統(tǒng)一的語言。