王垠:編程的宗派
文/王垠
總是有人喜歡爭論這類問題,到底是“函數式編程”(FP)好,還是“面向對象編程”(OOP)好。既然現在出了兩個幫派,就有人積極地做它們的幫眾,互相唾罵和殘殺。然后呢,又出了一個“好好先生幫”,這個幫的人喜歡說,管它什么范式呢,能解決問題的工具就是好工具!
我個人其實不屬于這三幫人中的任何一個。
面向對象編程(Object-Oriented Programming)
如果你看透了表面現象就會發現,其實“面向對象編程”本身沒有引入很多新東西。所謂“面向對象語言”,其實就是經典的“過程式語言”(比如 Pascal),加上一點點抽象能力。所謂“類”和“對象”,基本是過程式語言里面的記錄(record,或者叫結構,structure),它的本質就是一個從名字到數據的“映射表”(map)。你可以用名字從這個表里面提取相應的數據。比如 point.x,就是用名字'x'從記錄 point 里面提取相應的數據。這比起數組來是一件很方便的事情,因為你不需要記住存放數據的下標。即使你插入了新的數據成員,仍然可以用原來的名字來訪問已有的數據,而不用擔心下標錯位的問題。
所謂“對象思想”(區別于“面向對象”),實際上就是對這種數據訪問方式的進一步抽象。一個經典的例子就是平面點的數據結構。如果你把一個點存儲為:
struct Point { double x; double y; }
那么你用 point.x 和 point.y 可以直接訪問它的X和Y坐標。但你也可以把它存儲為極坐標方式:
struct Point { double r; double angle; }
那么你可以用 point.r 和 point.angle 訪問它的模和角度。可是現在問題來了,如果你的代碼開頭把 Point 定義為第一種 XY 的方式,使用 point.x, point.y 訪問X和Y坐標,可是后來你決定改變 Point 的存儲方式,用極坐標,你卻不想修改已有的含有 point.x 和 point.y 的代碼,怎么辦呢?
這就是“對象思想”的價值,它讓你通過更進一步的“間接”(indirection,或者叫做“抽象”)來改變 point.x 和 point.y 的語義,從而讓使用它們的代碼完全不需要修改。雖然你的實際數據結構里面根本沒有x和y這兩個成員,由于.x 和.y 可以被重新定義,你可以通過改變.x 和.y 的含義來模擬它們。在你使用 point.x 和 point.y 的時候,系統內部其實在運行兩片代碼,它們的作用是從r和 angle 計算出x和y的值。這樣你的代碼就感覺好像x和y是實際存在的成員一樣,而其實它們是被臨時算出來的。在 Python 之類的語言里面,你可以通過定義“property”來直接改變 point.x 和 point.y 的語義。在 Java 里稍微麻煩一些,你需要使用 point.getX ()和 point.getY ()這樣的寫法。然而它們最后的目的其實都是一樣的——它們為數據訪問提供了一層“間接”(抽象)。
這種抽象是非常好的想法,它甚至可以跟量子力學的所謂“不可觀測性”扯上關系。你覺得這個原子里面有 10 個電子?也許它們只是像 point.x 給你的幻覺一樣,也許宇宙里根本就沒有電子這種東西,也許你每次看到所謂的電子,它都是臨時生成出來逗你玩的呢?然而,這就是對象思想的一切。你見過的所謂“面向對象思想”,幾乎無一例外可以從這個想法推廣出來。面向對象語言的絕大部分特性,其實是過程式語言早就提供的。因此我覺得,其實沒有語言可以叫做 “面向對象語言”。就像一個人為一個公司貢獻了一點點代碼,并不足以讓公司以他的名字命名一樣!
“對象思想”,作為數據訪問的抽象,是有一定好處的。然而“面向對象”(多了“面向”兩個字),就是把這種本來良好的思想東拉西扯,牽強附會,發揮過了頭。很多面向對象語言號稱“所有東西都是對象”(Everything is an Object),把所有的函數都放進所謂對象里面,叫做“方法”(method),把普通的函數叫做“靜態方法”(static method)。實際上呢,就像我之前的例子,只有極少需要抽象的時候,需要使用內嵌于對象之內,跟數據緊密結合的“方法”。其他的時候,你其實只是想表達數據之間的變換操作,這些完全可以用普通的函數表達,而且這樣做更加簡單和直接。這種把所有函數放進方法的做法是本末倒置的,因為函數其實并不屬于對象。絕大部分函數是獨立于對象的,它們不能被叫做“方法”。強制把所有函數放進它們本來不屬于的對象里面,把它們全都作為“方法”,導致了面向對象代碼邏輯過度復雜。很簡單的想法,非得繞好多道彎子才能表達清楚。很多時候這就像把自己的頭塞進屁股里面。
這就是為什么我喜歡開玩笑說,面向對象編程就像“地平說”(Flat Earth Theory)。當然你可以說地球是一個平面,對于局部的,小規模的現象,它貌似沒有問題。然而對于通用的,大規模的情況,它卻不是自然,簡單和直接的。直到今天,你仍然可以無止境的尋找證據,扭曲各種物理定律,自圓其說“地球是平的”這個幻覺,然而這會讓你的理論非常復雜,經常需要縫縫補補還難以理解。
面向對象語言不僅有自身的根本性錯誤,而且由于面向對象語言的設計者們常常是半路出家,沒有受到過嚴格的語言理論和設計訓練卻又自命不凡,所以經常搞出另外一些奇葩的東西。比如 JavaScript,每個函數同時又可以作為構造函數(constructor),所以每個函數里面都隱含了一個 this 變量,你嵌套多層對象和函數的時候就發現沒法訪問外層的 this,非得 bind 一下。Python 的變量定義和賦值不分,所以你需要訪問全局變量的時候得用 global 關鍵字,后來又發現如果要訪問“中間層”的變量,沒有辦法了,所以又加了個 nonlocal 關鍵字。Ruby 先后出現過四種類似 lambda 的東西,每個都有自己的怪癖…… 有些人問我為什么有些語言設計成那個樣子,我只能說,很多語言設計者其實根本不知道他們在干什么!
軟件領域就是喜歡制造宗派。“面向對象”當年就是乘火打劫,扯著各種幌子,成為了一種宗派,給很多人洗了腦。很多人至今不知道自己所用的“面向對象語言”里面的很多優點,都是從過程式語言繼承來的。每當發生函數式與面向對象式語言的口水戰,都會有面向對象的幫眾拿出這些過程式語言早就有的優點來進行反駁:“你說面向對象不好,看它能做這個……” 拿別人的優點撐起自己的門面,卻看不到事物實質的優點,這樣的辯論純粹是雞同鴨講,所以我懶得參加。
函數式編程(Functional Programming)
函數式語言一直以來比較低調,直到最近由于并發計算編程瓶頸的出現,以及 Haskell,Scala 之類語言的流行,它忽然變成了一種宗派。有人盲目的相信函數式編程能夠奇跡般的解決并發計算的難題,而看不到實質存在的,獨立于語言的問題。被函數式語言洗腦的幫眾,喜歡否定其它語言的一切,看低其它程序員。特別是有些初學編程的人,儼然把函數式編程當成了一天瘦二十斤的減肥神藥,以為自己從函數式語言入手,就可以對經驗超過他十年以上的老程序員說三道四,仿佛別人不用函數式語言就什么都不懂一樣。
各種“白象”(white elephant)
所謂白象,“white elephant”,是指價格昂貴,卻沒有實際用處的東西。函數式語言里面有很多特殊的功能,它們跟白象的性質類似。
函數式語言的擁鱉們認為這個世界本來應該是“純”(pure)的,不應該有任何“副作用”。他們把一切的“賦值操作”看成低級弱智的作法。他們很在乎所謂尾遞歸,類型推導,fold,currying,maybe type 等等。他們以自己能寫出使用這些特性的代碼為豪。
可是殊不知,那些東西其實除了能自我安慰,制造高人一等的幻覺,并不一定能帶來真正優秀可靠的代碼。大量使用 fold 和 currying 的代碼,寫起來貌似很酷,讀起來卻異常痛苦。很多人根本不明白 fold 的本質,卻老喜歡用它,因為他們覺得那是函數式編程的“精華”,可以顯示自己的聰明。然而他們沒有看到的是,其實 fold 包含的,只不過是在列表(list)上做遞歸的“通用模板”,這個模板需要你填進去三個參數,就可以生成一個新的遞歸函數調用。所以每一個 fold 的調用,本質上都包含了一個在列表上的遞歸函數定義。fold 的問題在于,它定義了一個遞歸函數,卻沒有給它一個一目了然的名字。使用 fold 的結果是,每次看到一個 fold 調用,你都需要重新讀懂它的定義,琢磨它到底是干什么的。而且 fold 調用只顯示了遞歸模板需要的部分,而把遞歸的主體隱藏在了 fold 本身的“框架”里。比起直接寫出整個遞歸定義,這種遮遮掩掩的做法,其實是更難理解的。比如,當你看到這句 Haskell 代碼:
foldr (+) 0 [1,2,3]
你知道它是做什么的嗎?也許你一秒鐘之后就憑經驗琢磨出,它是在對[1,2,3]
里的數字進行求和,本質上相當于sum [1,2,3]
。雖然只花了一秒鐘,可你仍然需要琢磨。如果 fold 里面帶有更復雜的函數,而不是+
,那么你可能一分鐘都琢磨不透。寫起來倒沒有費很大力氣,可為什么我每次讀這段代碼,都需要看到+
和0
這兩個跟自己的意圖毫無關系的東西?萬一有人不小心寫錯了,那里其實不是+
和0
怎么辦?為什么我需要搞清楚+
, 0
, [1,2,3]
的相對位置以及它們的含義?這樣的寫法其實還不如老老實實寫一個遞歸函數,給它一個有意義名字(比如sum
),這樣以后看到這個名字被調用,比如sum [1,2,3]
,你想都不用想就知道它要干什么。定義sum
這樣的名字雖然稍微增加了寫代碼時的工作,卻給讀代碼的時候帶來了方便。為了寫的時候簡潔或者很酷而用 fold,其實增加了讀代碼時的腦力開銷。要知道代碼被讀的次數,要比被寫的次數多很多,所以使用 fold 往往是得不償失的。然而,被函數式編程洗腦的人,卻看不到這一點。他們太在乎顯示給別人看,我也會用 fold!
與 fold 類似的白象,還有 currying,Hindley-Milner 類型推導等特性。看似很酷,但等你仔細推敲才發現,它們帶來的麻煩,比它們解決的問題其實還要多。有些特性聲稱解決的問題,其實根本就不存在。現在我把一些函數式語言的特性,以及它們包含的陷阱簡要列舉一下:
- fold。fold 等“遞歸模板”,相當于把遞歸函數定義插入到調用的敵方,而不給它們名字。這樣導致每次讀代碼都需要理解幾乎整個遞歸函數的定義。
- currying。貌似很酷,可是被部分調用的參數只能從左到右,依次進行。如何安排參數的順序成了問題。大部分時候還不如直接制造一個新的 lambda,在內部調用舊的函數,這樣可以任意的安排參數順序。
- Hindley-Milner 類型推導。為了避免寫參數和返回值的類型,結果給程序員寫代碼增加了很多的限制。為了讓類型推導引擎開心,導致了很多完全合法合理優雅的代碼無法寫出來。其實還不如直接要程序員寫出參數和返回值的類型,這工作量真的不多,而且可以準確的幫助閱讀者理解參數的范圍。
- 代數數據類型(algebraic data type)。所謂“代數數據類型”,其實并不如普通的類型系統(比如 Java 的)通用。很多代數數據類型系統具有所謂 sum type,這種類型其實帶來過多的類型嵌套,不如通用的 union type。盲目崇拜代數數據類型的人,往往是因為盲目的相信“數學是優美的語言”。而其實事實是,數學是一種歷史遺留的,毛病很多的語言。
- 惰性求值(lazy evaluation)。貌似數學上很優雅,但其實有嚴重的邏輯漏洞。因為 bottom(死循環)成為了任何類型的一個元素,所以取每一個值,都可能導致死循環。同時導致代碼性能難以預測,因為求值太懶,所以可能臨時抱佛腳做太多工作,而平時浪費 CPU 的時間。由于到需要的時候才求值,所以在有多個處理器的時候無法有效地利用它們的計算能力。
- 尾遞歸。大部分尾遞歸都相當于循環語句,然而卻不像循環語句一樣具有一目了然的意圖。你需要仔細看代碼的各個分支的返回條件,判斷是否有分支是尾遞歸,然后才能判斷這代碼是個循環。而循環語句從關鍵字(for,while)就知道是一個循環。所以等價于循環的尾遞歸,其實最好還是寫成特殊的循環語句。當然,尾遞歸在另一些情況下是有用的,這些情況不等價于循環。在這種情況下使用循環,經常需要復雜的 break 或者 continue 條件,導致循環不易理解。所以循環和尾遞歸,其實都是有必要的。
純函數
半壺水都喜歡響叮當。很多喜歡自吹為“函數式程序員”的人,往往并不真的理解函數式語言的本質。他們一旦看到過程式語言的寫法就嗤之以鼻。比如以下這個C函數:
int f (int x) { int y = 0; int z = 0; y = 2 * x; z = y + 1; return z / 3; }
很多函數式程序員可能看到那幾個賦值操作就皺起眉頭,然而他們看不到的是,這是一個真正意義上的“純函數”,它在本質上跟 Haskell 之類語言的函數是一樣的,也許還更加優雅一些。
盲目鄙視賦值操作的人,也不理解“數據流”的概念。其實不管是對局部變量賦值還是把它們作為參數傳遞,其實本質上都像是把一個東西放進一個管道,或者把一個電信號放在一根導線上,只不過這個管道或者導線,在不同的語言范式里放置的方向和樣式有一點不同而已!
對數據結構的忽視
函數式語言的幫眾沒有看清楚的另一個重要的,致命的東西,是數據結構的根本性和重要性。數據結構的有些問題是“物理”和“本質”地存在的,不是換個語言或者換個風格就可以奇跡般消失掉的。函數式語言的擁鱉們喜歡盲目的相信和使用列表(list),而沒有看清楚它的本質以及它所帶來的時間復雜度。列表帶來的問題,不僅僅是編程的復雜性。不管你怎么聰明的使用它,很多性能問題是根本沒法解決的,因為列表的拓撲結構根本就不適合用來干有些事情!
從數據結構的角度看,Lisp 所謂的 list 就是一個單向鏈表。你必須從上一個節點才能訪問下一個,而這每一次“間接尋址”,都是需要時間的。在這種數據結構下,很簡單的像 length 或者 append 之類函數,時間復雜度都是O(n)!為了繞過這數據結構的不足,所謂的“Lisp 風格”告訴你,不要反復 append,因為那樣復雜度是O(n2)。如果需要反復把元素加到列表末尾,那么應該先反復 cons,然后再 reverse 一下。很可惜的是,當你同時有遞歸調用,就會發現 cons+reverse 的做法顛來倒去的,非常容易出錯。有時候列表是正的,有時候是反的,有時候一部分是反的…… 這種方式用一次還可以,多幾層遞歸之后,自己都把自己搞糊涂了。好不容易做對了,下次修改可能又會出錯。然而就是有人喜歡顯示自己聰明,喜歡自虐,迎著這類人為制造的“困難”勇往直前 :)
富有諷刺意味的是,半壺水的 Lisp 程序員都喜歡用 list,真正深邃的 Lisp 大師級人物,卻知道什么時候應該使用記錄(結構)或者數組。在 Indiana 大學,我曾經上過一門 Scheme(一種現代 Lisp 方言)編譯器的課程,授課的老師是R. Kent Dybvig,他是世界上最先進的 Scheme 編譯器 Chez Scheme 的作者。我們的課程編譯器的數據結構(包括 AST)都是用 list 表示的。期末的時候,Kent 對我們說:“你們的編譯器已經可以生成跟我的 Chez Scheme 媲美的代碼,然而 Chez Scheme 不止生成高效的目標代碼,它的編譯速度是你們的 700 倍以上。它可以在 5 秒鐘之內編譯它自己!” 然后他透露了一點 Chez Scheme 速度之快的原因。其中一個原因,就是因為 Chez Scheme 的內部數據結構根本不是 list。在編譯一開頭的時候,Chez Scheme 就已經把輸入的代碼轉換成了數組一樣的,固定長度的結構。后來在工業界的經驗教訓也告訴了我,數組比起鏈表,確實在某些時候有大幅度的性能提升。在什么時候該用鏈表,什么時候該用數組,是一門藝術。
副作用的根本價值
對數據結構的忽視,跟純函數式語言盲目排斥副作用的“教義”有很大關系。過度的使用副作用當然是有害的,然而副作用這種東西,其實是根本的,有用的。對于這一點,我喜歡跟人這樣講:在計算機和電子線路最開頭發明的時候,所有的線路都是“純”的,因為邏輯門和導線沒有任何記憶數據的能力。后來有人發明了觸發器(flip-flop),才有了所謂“副作用”。是副作用讓我們可以存儲中間數據,從而不需要把所有數據都通過不同的導線傳輸到需要的地方。沒有副作用的語言,就像一個沒有無線電,沒有光的世界,所有的數據都必須通過實在的導線傳遞,這許多紛繁的電纜,必須被正確的連接和組織,才能達到需要的效果。我們為什么喜歡 WiFi,為什么喜歡 4G 網,這也就是為什么一個語言不應該是“純”的。
副作用也是某些重要的數據結構的重要組成元素。其中一個例子是哈希表。純函數語言的擁護者喜歡盲目的排斥哈希表的價值,說自己可以用純的樹結構來達到一樣的效果。然而事實卻是,這些純的數據結構是不可能達到有副作用的數據結構的性能的。所謂純函數數據結構,因為在每一次“修改”時都需要保留舊的結構,所以往往需要大量的拷貝數據,然后依賴垃圾回收(GC)去消滅這些舊的數據。要知道,內存的分配和釋放都是需要時間和能量的。盲目的依賴 GC,導致了純函數數據結構內存分配和釋放過于頻繁,無法達到有副作用數據結構的性能。要知道,副作用是電子線路和物理支持的高級功能。盲目的相信和使用純函數寫法,其實是在浪費已有的物理支持的操作。
好好先生
很多人避免“函數式 vs 面向對象”的辯論,于是他們成為了“好好先生”。這種人沒有原則的認為,任何能夠解決當前問題的工具就是好工具。也就是這種人,喜歡使用 shell script,喜歡折騰各種 Unix 工具,因為顯然,它們能解決他“手頭的問題”。
然而這種思潮是極其有害的,它的害處其實更勝于投靠函數式或者面向對象。沒有原則的好好先生們忙著“解決問題”,卻不能清晰地看到這些問題為什么存在。他們所謂的問題,往往是由于現有工具的設計失誤。由于他們的“隨和”,他們從來不去思考,如何從根源上消滅這些問題。他們在一堆歷史遺留的垃圾上縫縫補補,妄圖使用設計惡劣的工具建造可靠地軟件系統。當然,這代價是非常大的。不但勞神費力,而且也許根本不能解決問題。
所以每當有人讓我談談“函數式 vs 面向對象”,我都避免說“各有各的好處”,因為那樣的話我會很容易被當成這種毫無原則的好好先生。
符號必須簡單的對世界建模
從上面你已經看出,我既不是一個鐵桿“函數式程序員”,也不是一個鐵桿“面向對象程序員”,我也不是一個愛說“各有各的好處”的好好先生。我是一個有原則的批判性思維者。我不但看透了各種語言的本質,而且看透了它們之間的統一關系。我編程的時候看到的不是表面的語言和程序,而是一個類似電路的東西。我看到數據的流動和交換,我看到效率的瓶頸,而這些都是跟具體的語言和范式無關的。
在我的心目中其實只有一個概念,它叫做“編程”(programming),它不帶有任何附加的限定詞(比如“函數式”或者“面向對象”)。我的老師 Dan Friedman 喜歡把自己的領域稱為“Programming Languages”,也是一樣的原因。因為我們研究的內容,不局限于某一個語言,也不局限于某一類語言,而是所有的語言。在我們的眼里,所有的語言都不過是各個特性的組合。在我們的眼里,最近出現的所謂“新語言”,其實不大可能再有什么真正意義上的創新。我們不喜歡說“發明一個程序語言”,不喜歡使用 “發明”這個詞,因為不管你怎么設計一個語言,所有的特性幾乎都早已存在于現有的語言里面了。我更喜歡使用“設計”這個詞,因為雖然一個語言沒有任何新的特性,它卻有可能在細節上更加優雅。
編程最重要的事情,其實是讓寫出來的符號,能夠簡單地對實際或者想象出來的“世界”進行建模。一個程序員最重要的能力,是直覺地看見符號和現實物體之間的對應關系。不管看起來多么酷的語言或者范式,如果必須繞著彎子才能表達程序員心目中的模型,那么它就不是一個很好的語言或者范式。有些東西本來就是有隨時間變化的“狀態”的,如果你偏要用“純函數式”語言去描述它,當然你就進入了那些 monad 之類的死胡同。最后你不但沒能高效的表達這種副作用,而且讓代碼變得比過程式語言還要難以理解。如果你進入另一個極端,一定要用對象來表達本來很純的數學函數,那么你一樣會把簡單的問題搞復雜。Java 的所謂 design pattern,很多就是制造這種問題的,而沒有解決任何問題。
關于建模的另外一個問題是,你心里想的模型,并不一定是最好的,也不一定非得設計成那個樣子。有些人心里沒有一個清晰簡單的模型,覺得某些語言 “好用”,就因為它們能夠對他那種扭曲紛繁的模型進行建模。所以你就跟這種人說不清楚,為什么這個語言不好,因為顯然這個語言對他是有用的!如何簡化模型,已經超越了語言的范疇,在這里我就不細講了。
我設計 Yin 語言的宗旨,就是讓人們可以用最簡單,最直接的方式來對世界進行建模,并且幫助他們優化和改進模型本身。