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