最近在看MFC的代碼,雖然這破玩意,老朽已經很熟悉了得不能再熟悉了,但是這些破代碼自由其獨有的吸引力,不說別的,單單理解起來就非常容易,比之什么boost代碼容易看多了,單步調試什么的,都非常方便,堆棧上一查看,層次調用一目了然。一次又一次地虐這些曾經虐過老朽的代碼,也是人生快事一件。平心而論,mfc的代碼還是寫得挺不錯的,中規中矩,再加上過去九十年代之初,16位的windows系統,那個時候的面向對象的c++的風靡一時,完全采用標準c++,能做成這樣,實屬難能可貴,也在情理之內。并且,后面壓上com之后,mfc也不崩盤,采用內嵌類實現的com的方式,也很有意思。然后,從mfc中也能學到不少windows gui的使用方式還有各種其他雜七雜八東西,雖然win32已經沒落。但是里面的技術還是挺吸引人,可以消遣也不錯。當然,對于新人,mfc不建議再碰了,mfc真是沒飯吃的意思。你想想,一個gui框架,沒有template可用的情況下,而逆天c++11的lambda作為匿名functor,更加不必提了,只有虛函數和繼承可用,也沒有exception,能搞成mfc這副摸樣,的而且確是精品。其實,后來的巨硬也有救贖,看看人家用template做出來的專為com打造的atl又是什么樣子呢,然后建構在atl的windows thunk基礎上開發的wtl又是怎樣的小巧玲瓏。巨硬在template上的使用還是很厲害的,atl將template和多繼承用的真是漂亮。人家幾十年前就將template和多繼承用得如此出神入化,反觀國內,一大批C with class又或者狗粉一再叫囂template滾出c++,多繼承太復雜了,運算符重載不透明,心智負擔,隱式類型轉換問題太多,virtual是罪惡之源萬惡之首,構造函數析構函數背著馬猿做了太多事情,exception對代碼沖擊太大,打斷代碼正常流行,時時刻刻都好像隱藏著不定時炸彈。依本座看來,C++中一大批能夠顯著減少重復代碼,帶來類型安全的拔高抽象層次的好東西,對于這些C語言衛道士而言,都是混亂之物。其實,c語言就是一塊廢柴,抽象層次太低,可以做文章的地方太少了。
就以構造函數和類型轉換operator為例,來看看怎么用于C的char *itoa(int value, char *str, int radix)。
itoa的參數之所以還需要str入參,那是因為C語言中缺乏返回數組的語言元素,所以調用者要提供一個字符數組作為緩沖用于存放結果,但是這個多出來str參數真是沒必要啊,因為語言能力的欠缺,所以只好把這個負擔壓到猿猴身上。也有些itoa的實現沒有這個累贅str的入參,而是內部static的字符數組,用于存放結果并返回給上層。這個版本就只有兩個入參了,但是也太不安全了,別提多線程了。假如,有一個函數fn(char* str1, char* str2),然后,這樣調用fn(itoa(num1),itoa(num2)),畫面太美了。另外,那個有多余str參數版本的itoa也好不到哪里去,要勞心費神準備兩塊字符數組,然后還要保證參數傳遞的時候不要一樣。反正C語言的粉絲整天很喜歡寫這些重復代碼,并且美其名曰掌控一切細節的快感。
請看構造函數和類型轉換operator怎么解決。太easy了。
struct ToString
{
char text[28];
int length;
ToString(int n)
{
//轉換字符串,結果存放于text中
}
operator const char*()
{
return text;
}
};
并且,這里的ToString還可以安全的用之于printf里面呢,因為它本身就是字符串的化身。為什么是ToString,因為它不僅僅要它ToString int,還有double,bool,char,……
不好意思,扯遠了,只是想說,框架或者函數庫的表現能力也要取決于語言本身的表達能力。就好像C#就可以做出linq那樣爽快的框架,java再怎么拼命也搗鼓不出來一個一半好用的linq,C++也不行,但是C++可以搗鼓類似于haskell中的map,filter,fold等, 并結合linq的后綴表達方式。就好比下面這樣
vector<int> nums = {...}
Range(nums).Map(_1 * _1).Filter(_1 % 2).CopyTo(dest); // 用了boost中的lambda表達法,因為的確很簡潔,沒法控制。對于復雜情況,當然要用C++11原生的lambda
勉勉強強差可滿足吧。如果C++的lambda參數可以自動推導就好了,不過也沒什么,主要是ide下用得爽。用泛型lambda也能將就。
所以,回過頭來,再看看mfc(沒飯吃),就可以了解其各種隱痛了。真的,以90年代的眼光來看,mfc真是做到極致了。mfc不可能走win32下窗口函數C語言那樣的消息發送消息反應的正路(邪路)吧。窗口函數這一套,在90年代面向對象盛行的時代,絕對不能被忍受,只是到了前幾年,才被發現其價值原來不菲,這是解耦合砍繼承樹的好手法,老朽在前幾年也跟風吹捧窗口函數的那一套。平心而論,smalltalk的這一套消息發送的動態語言,確實是很強有力的抽象手段,我不管目標對象能否反應該消息,閉著眼睛就可以給你發送消息,你能反應就反應,不能反應就拉倒,或者調用缺省的反應方式,就好像DefWindowProc(職責鏈模式?),又或者是拋出異常,怎么做都可以。一下子就解開了調用者和目標對象的類型耦合關系。面向對象中,消息發送和消息反應才是核心,什么封裝繼承多態,那是另一套抽象方式,雖然坊間說這也是面向對象的基本要素,但是不是,當然,這或許也只是個人觀點。
或許,從某種意義上講,C++/java/C#一類的成員函數調用形式,其實也算消息發送吧。比如,str.length(),就是給對象str發送length的消息,然后str收到length消息,作出反應,執行操作,返回里面字符串的長度。靠,這明明就是直接的函數調用,搞什么消息發送的說辭來強辯,顛倒是非黑白,指鹿為馬。可不是嗎?編譯器知道str是字符串類型,知道length成員函數,馬上就生成了高效的函數調用方式。在這里,沒有任何動態多態的可能,發生就發生了,一經調用,動作立馬就行動,沒有任何商量的余地。耦合,這里出現強耦合,調用者和str綁在一塊了,假如以后出現更高效率更有彈性的string的代替品了,可是沒法用在這里了,因為這里str.length()的綁定很緊很緊。
人家消息發送就不一樣了,動態的,可以動態替換對象,替換方法,彈性足足。并且,消息發送的模式下,對象收到消息,要判斷消息,解析消息,找到消息的執行函數,最后才終于執行任務。這么多間接層,每一層都可以做很多很多文章。比如,在消息到達對象之前做文章,就可以搞消息隊列,把消息和參數暫存起來,這個時候,什么actor模式就大放異彩,至于undo,redo,更加是小菜一碟。然后呢,再給對象安裝消息解析器,把消息和消息參數轉換成其他類型消息。比如原本對象不能反應這條消息,但是對消息參數稍加修飾,然后在發送給對象,這不就是適配器模式。總之,可操作可挖掘的空間太大了,遠遠不止23條。
但是,封裝繼承多態就一無是處了嗎?不是的,最起碼一點,編譯期間可以報錯。因為的確有很多時候,我們明明就知道對象的類型,明明就知道對象不可能是其他類型,比如字符串,比如復數,比如數組這些玩意,無論如何,它們都不需要動態消息的能力。我們就知道手上的對象就是字符串就是復數,不可能是別的,并且,我們就是要明確地調用length函數。我們就是要編譯器幫忙檢查這里潛在的語法類型錯誤,比如對復數對象調用length函數,編譯器馬上就不高興了。并且,一切都是確定的,所以編譯器也能生成高效的代碼,高效的不能再高效了。對此,消息發送的面向對象就做不到了,不管是什么對象,int,string,complex種種,都來個消息發送。這樣一來,靜態類型檢查和高效的代碼,就木有了。
考察一下,面向對象有等級之分,一步一步,有進化的階梯。每進化一次,就多了一層間接,類型耦合就降低,就進一步超越編譯器的限制,當然,也意味著編譯器幫忙檢查類型錯誤生成高效代碼就弱了一分。事情往往就是,有所得必有所失。少即是多,多即是少。因此,可推得少即是少,多即是多。少始終是少,多始終是多。
一切,還是要從C語言說起,C語言中,沒有class,沒有函數重載。函數名是什么,最后就是什么。在這種條件下,代碼多了,每個新的函數名字要考究半天,一不小心,要么函數名字就會很長,要么函數名字短了要沖突或者不好理解。但是好處是,最后生成目標代碼時,什么函數名字就是什么名字,所見即所得,沒有異常,不會搗鬼,于是其他各種語言都可以高高興興開開心心調用。猿猴觀碼,也很清晰。C++也是在這里賺了第一桶金。其實,這么苛刻的條件下,最考究猿猴的代碼架構能力,架構稍微不好,最后都勢必提早崩掉,前期就可以過濾很多垃圾架構。
然后就是C with class了,開始在函數名字上面做文章了。同一個函數名字依對象類型,開始擁有靜態多態能力了。比如,str.length(),這是對字符串求長度。數組的變量,nums.length(),對數組求長度。同一個length的名字,用在不同的對象上,就有不同的意義。這如何做到呢,最初,cfront(第一版C++編譯器)的處理方式是,可以說是語法糖,就是在名字和調用形式上做文章,比如,str.length(),變成,string_length(&str),array_length(&nums)。別小看這點小把戲語法糖,這真是有力的抽象手法。不說別的,就說起名字吧,可以統一length了,無須費思量string_length,list_length了。然后,對象統一調用方式,str.length(),list.length(),函數確定這種吃力不討好的事情就交給編譯器去做好啦,解放部分腦細胞。這,的確很好,但是,全局函數是開放式的,而對象的成員函數是封閉的,一旦class定義完畢,成員函數的數量也就定死了。猿猴最講究東西的可擴展性,不管成員函數多么方便多么抽象有力,就擴展性而言,就差了一大截,其他優勢都彌補不了。語義上看,擴展成員函數的語法完全與原生的一樣,增加一個簡單的語法形式來擴充,但是多年下來,標準委員會都不務正業,哎。顯然,編譯器的類型檢查能力和生成的代碼性能,沒有任何減少,但是,猿猴看代碼,不能再所見所得了,必須根據對象類型,才能確定最終的目標函數。就這么點小改進,當時C++馬上就展示其驚人的吸引力。假如,C++只能留在這一層,相信到今天為止,可以吸引到更多的c粉。可是,C++開始叛變。
C++的函數重載,還有操作符重載,外加隱式類型轉換和隱式構造函數,還有const,volatile修飾,當然,代碼可以寫得更加簡潔,編譯器可以做的事情也更多啦,但是函數的調用再也不明確了。部分專注于底層的猿猴的弱小的抽象能力把控不住了,不少人在這里玩不動了。此外,命名修飾把最終函數名字搞得亂七八糟,二進制的通用性也要開始廢了。導致C++的dll不能像C那樣到處通吃。像是狗語言就禁止函數重載這個功能。大家好像很非難C++的操作符重載,但是haskell還能自定義新的操作符呢。雖然在這里,編譯器還能生成高效代碼,但是,各種奇奇怪怪類型轉換的規則,編譯器也偶爾表現出奇,甚至匪夷所思,雖然一切都在情理之內。
其實,不考慮什么動態能力,單單是這里的靜多態,基于對象(俗稱ADT)的抽象模式,就可以應付70%以上的代碼了。想想以前沒有靜多態的C日子是怎么過的。
此時,開始兵分兩路,C++一方面是動多態發展,表現為繼承,多繼承,虛繼承,虛函數,純虛函數,rtti(廢物,半殘品),到此為止了,止步不前;另一方面是繼續加強靜多態,王者,template,一直在加強,模板偏特化,template template,varidiac tempalte,consexpr, auto,concept,……,背負著各種指責在前進,就是在前進。C++企圖以靜態能力的強悍變態恐怖,不惜榨干靜態上的一點點可為空間,累死編譯器,罔顧邊際效應的越來越少,企圖彌補其動態上的種種不足。這也是可行的,畢竟haskell都可以做到。template的話題太龐大了,我們言歸正傳,面向對象。
下面就是被指責得太多的C++多繼承,虛函數,RTTI,脆弱的虛函數表,等,這些說法,也都很有道理,確是實情,兼之C++沒有反射,沒有垃圾回收,用上面這些破玩意搗鼓,硬著頭皮做設計做框架,本來就先天能力嚴重不足,還要考慮內存管理這個大敵(循環引用可不是吹的),更有exception在旁虎視眈眈,隨時給予致命一擊。更要命的是,多繼承,虛函數,虛繼承,這些本來就殺敵八百自傷一千,嚴重擾亂class的內存布局,你知道vector里面隨隨便便插入元素,對于非pod的元素,不僅僅是移動內存,騰出新位置來給新對象安營扎寨,還要一次又一次地對被移動的對象執行析構拷貝構造。沒有這些奇奇怪怪的內存布局,vector的實現應該會清爽很多。稍微想想,這實在太考究猿猴的設計能力,其難度不亞于沒有任何多態特性的C語言了。可以這么說,繼承樹一旦出現虛繼承這個怪胎,整體架構就有大問題,毫無疑問,iostream也不例外。不過,如果沒有那么多的動態要求,好比gui框架的變態需求,嚴格以接口作為耦合對象,輔以function,也即是委托,又可以應付多起碼15%的局面。其實,必須要用到virtual函數的時候,將virtual函數hi起來,那種感覺非常清爽,很多人談virtual色變,大可不必。C#和java還加上垃圾回收和反射,這個比例可以放大很多。在這種層次下,接口最大的問題是,就好像成員函數,是封閉的。一個class定義完畢,其能支持的interface的數量也就定死了,不能再有任何修改。interface可以說是一個class的對外的開放功能,現實世界中,一種東西的對外功能并不是一開始就定死了的,其功能也在后來慢慢挖掘。但是,C++/java/C#的接口就不是這樣,class定義完畢,就沒有任何潛力可言了。明明看到某些class的能力可以實現某些接口,甚至函數簽名都一樣,對不起,誰讓你當初不實現這個接口。對此,各種動態語言閃亮登場,或mixing或鴨子類型。接口還有另一尷尬之處,比如,鳥實現了會飛的接口,鴨子企鵝也繼承了鳥,自然也就繼承了會飛的接口,沒辦法不繼承。面對著一個需要IFlyable參數的函數,我們順利的傳一只企鵝進去,然后企鵝再里面始終飛不起來,就算企鵝在被要求飛的時候,拋出異常,也不過自欺欺人。這種悲劇,就好像有些人很會裝逼,最后一定會壞事。搞出接口這種破事,就是為了讓編譯器做類型檢查的。又有人說,bird應當分為兩類,會飛的和不會飛的,這的確能解決飛行的尷尬。但是,有很多鳥具備捉蟲蟲的能力,然后又有那么一小撮鳥不會捉蟲只會捉魚,難道又要依據捉蟲能力再劃分出鳥類。于是鳥類的繼承樹越長越高,畫面越來越美。這分明就是語言能力的不足,把問題交給猿猴了。請謹記,接口就是一個強有力的契約,既然實現了一個接口,就說明有能力做好相關的事情。再說,既然interface這么重要,于是我們再設計class的時候,就自然而然把精力放在interface這個對外交流媒介的手段之上了,而忽視了class本身的推敲。class最重要的事情就是全心全意做好獨立完整最小化的事情,其他什么對外交互不要理會。一個class如果能夠完整的封裝一個清晰的概念,后面不管怎么重構,都可以保留下來。但是,interface會分散這種設計。接口的悲劇就在于企圖頂多以90分的能力去干一百分的事情,并且還以為自己可以做得好,硬上強干,罔顧自身的極限。往往做了90%的工作量,事情恰恰就壞在剩下來的10%上。
于是,狗語言走上另一條邪路,鴨子類型。只要class,不,是struct,這種獨特關鍵字的品味,只要某個struct能夠完全實現某個interface的所有函數,就默認其實現了這個接口。并且,狗語言還禁止了繼承,代之以“組合”這個高大上的名詞了,但是,細究一下語義和內存布局(忽略虛函數表指針),你媽的,不就是一個沒有virtual繼承的弱多繼承嗎?顯式的繼承消失了,隱式的繼承還存在的,好了,還不讓你畫出繼承樹關系圖,高高興興對外宣稱沒有繼承了,沒有繼承并不表示繼承的問題木有存在。但是,因為狗語言的成員函數方法可以定義在class,不,struct外面,其擴展性就非常好了,對于一個interface,有哪些方法,本struct不存在,就地給它定義出來,然后,struct就輕松的實現了該接口,即使原來的struct不支持該接口,以后也有辦法讓它支持,很好很強大。之所以能做到這一點,那是因為狗語言的虛函數表是動態生成的。小心的使用接口各種名字,部分人應該狗語言用起來會相當愉快。可是,你媽,不同接口的函數名字不能一樣啊,或者說,同一個函數的名字不能出現在不同接口中。不過,這個問題并不難,不就是不一樣的名字嗎,c語言中此等大風大浪猿猴誰沒有見識過。對于狗語言,不想做太多評斷,只是,其擴展性確實不錯,非侵入式的成員函數和非侵入式的接口,理應能更好地應付接口實現這種多態方式,只是,編譯器在上面所做的類型約束想必會不如后者,重構什么的,想必不會很方便。自由上去了,約束自然也下來了。聽起來挺美,但是內里也有些地方要推敲,反正老朽不喜歡,以后也不大會用上,當然,給money自然會用,給money不搞c++都沒問題。老朽還是比較喜歡虛函數的接口,更何況c++通過奇技淫巧也能非侵入式的給class添加接口。在靜態語言中搞這種鴨子類型的動態語言接口,顯得有點不倫不類。
然后就是com接口的面向對象,完全舍去編譯器對接口類型的約束,自然能換來更大的自由。由于com的語言通用性目標,所以搞得有點復雜,但是com背后的理念也挺純潔。老朽猜測com好似是要在靜態語言上搭建出一個類似于動態語言的運行平臺,外加語言通用性。其契約很明確,操作對象前時,必須先查詢到對象支持的接口,進而調用接口的函數。這里有意思的地方在于面對著一個com對象,你居然沒有辦法知道到它究竟實現了多少接口。
最后就是消息發送了,其能力之強大,誰用誰知道。原則上講,可以看成對象擁有的虛函數表的方法無窮多,又可以把每一條消息看成一個接口,那么,對象可能就實現了無窮多的接口。你說,面對著這樣對象,還有什么做不出來呢。真用上消息發送這種隱藏無數間接層,就沒有什么軟件問題解決不了的。任何軟件問題不就是通過引入間接層來解決的嘛。現在用上消息發送這種怪物,就問你怕不怕。沒有免費午餐,自然要付出類型安全的危險和性能上的損失。