老是有人喜歡爭論這類問題,究竟是「函數式編程」(FP)好,仍是「面向對象編程」(OOP)好。既然出了兩個幫派,就有人積極地作它們的幫衆,互相唾罵和鄙視。而後呢又出了一個「好好先生幫」,這個幫的人喜歡說,管它什麼範式呢,能解決問題的工具就是好工具!我我的其實不屬於這三幫人中的任何一個。html
若是你看透了表面現象就會發現,其實「面向對象編程」自己沒有引入不少新東西。所謂「面嚮對象語言」,其實就是經典的「過程式語言」(好比Pascal),加上一點抽象能力。所謂「類」和「對象」,基本是過程式語言裏面的記錄(record,或者叫結構,structure),它本質實際上是一個從名字到數據的「映射表」(map)。你能夠用名字從這個表裏面提取相應的數據。好比point.x
,就是用名字x
從記錄point
裏面提取相應的數據。這比起數組來是一件很方便的事情,由於你不須要記住存放數據的下標。即便你插入了新的數據成員,仍然能夠用原來的名字來訪問已有的數據,而不用擔憂下標錯位的問題。python
所謂「對象思想」(區別於「面向對象」),實際上就是對這種數據訪問方式的進一步抽象。一個經典的例子就是平面點的數據結構。若是你把一個點存儲爲:程序員
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
的代碼,怎麼辦呢?shell
這就是「對象思想」的價值,它讓你能夠經過「間接」(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的東西,每一個都有本身的怪癖…… 有些人問我爲何有些語言設計成那個樣子,我只能說,不少語言設計者其實根本不知道本身在幹什麼!併發
軟件領域就是喜歡製造宗派。「面向對象」當年就是乘火打劫,扯着各類幌子,成爲了一種宗派,給不少人洗了腦。到底什麼樣的語言纔算是「面嚮對象語言」?這樣基本的問題至今沒有確切的答案,足以說明所謂面向對象,基本都是扯淡。每當你指出某個OO語言X的弊端,就會有人跟你說,其實X不是「地道的」OO語言,你應該去看看另一個OO語言Y。等你發現Y也有問題,有人又會讓你去看Z…… 直到最後,他們告訴你,只有Smalltalk纔是地道的OO語言。這不是很搞笑嗎,說一個根本沒人用的語言纔是地道的OO語言,這就像在說只有死人的話纔是對的。這就像是一羣政客在踢皮球,推卸責任。等你真正看看Smalltalk才發現,其實面嚮對象語言的根本毛病就是由它而來的,Smalltalk並非很好的語言。不少人至今不知道本身所用的「面嚮對象語言」裏面的不少優勢,都是從過程式語言繼承來的。每當發生函數式與面向對象式語言的口水戰,都會有面向對象的幫衆拿出這些過程式語言早就有的優勢來進行反駁:「你說面向對象很差,看它能作這個……」 拿別人的優勢撐起本身的門面,卻看不到事物實質的優勢,這樣的辯論純粹是雞同鴨講。
函數式語言一直以來比較低調,直到最近因爲併發計算編程瓶頸的出現,以及Haskell,Scala之類語言社區的大力鼓吹,它突然變成了一種宗派。有人盲目的相信函數式編程可以奇蹟般的解決併發計算的難題,而看不到實質存在的,獨立於語言的問題。被函數式語言洗腦的幫衆,喜歡否認其它語言的一切,看低其它程序員。特別是有些初學編程的人,儼然把函數式編程當成了一天瘦二十斤的減肥神藥,覺得本身從函數式語言入手,就能夠對經驗超過他十年以上的老程序員說三道四,彷彿別人不用函數式語言就什麼都不懂同樣。
函數式編程的優勢
函數式編程固然提供了它本身的價值。函數式編程相對於面向對象最大的價值,莫過於對於函數的正確理解。在函數式語言裏面,函數是「一類公民」(first-class)。它們能夠像1, 2, "hello",true,對象…… 之類的「值」同樣,在任意位置誕生,經過變量,參數和數據結構傳遞到其它地方,能夠在任何位置被調用。這些是不少過程式語言和麪向對象語言作不到的事情。不少所謂「面向對象設計模式」(design pattern),都是由於面嚮對象語言沒有first-class function,因此致使了每一個函數必須被包在一個對象裏面才能傳遞到其它地方。
函數式編程的另外一個貢獻,是它們的類型系統。函數式語言對於類型的思惟,每每很是的嚴密。函數式語言的類型系統,每每比面嚮對象語言來得嚴密和簡單不少,它們能夠幫助你對程序進行嚴密的邏輯推理。然而類型系統一是把雙刃劍,若是你對它看得過重,它反而會帶來沒必要要的複雜性和過分工程。這個我在下面講講。
各類「白象」(white elephant)
所謂白象,「white elephant」,是指被人奉爲神聖,價格昂貴,卻沒有實際用處的東西。函數式語言裏面有很好的東西,然而它們裏面有不少多餘的特性,這些特性跟白象的性質相似。
函數式語言的「擁護者」們,每每認爲這個世界原本應該是「純」(pure)的,不該該有任何「反作用」。他們把一切的「賦值操做」當作低級弱智的做法。他們很在意所謂尾遞歸,類型推導,fold,currying,maybe type等等。他們以本身能寫出使用這些特性的代碼爲豪。但是卻不知,那些東西其實除了能自我安慰,製造高人一等的幻覺,並不必定能帶來真正優秀可靠的代碼。
純函數
半壺水都喜歡響叮噹。不少喜歡自吹爲「函數式程序員」的人,每每並不真的理解函數式語言的本質。他們一旦看到過程式語言的寫法就嗤之以鼻。好比如下這個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網,Bluetooth,這也就是爲何一個語言不該該是「純」的。
反作用也是某些重要的數據結構的重要組成元素。其中一個例子是哈希表。純函數語言的擁護者喜歡盲目的排斥哈希表的價值,說本身能夠用純的樹結構來達到同樣的效果。然而事實倒是,這些純的數據結構是不可能達到有反作用的數據結構的性能的。所謂純函數數據結構,由於在每一次「修改」時都須要保留舊的結構,因此每每須要大量的拷貝數據,而後依賴垃圾回收(GC)去消滅這些舊的數據。要知道,內存的分配和釋放都是須要時間和能量的。盲目的依賴GC,致使了純函數數據結構內存分配和釋放過於頻繁,沒法達到有反作用數據結構的性能。要知道,反作用是電子線路和物理支持的高級功能。盲目的相信和使用純函數寫法,實際上是在浪費已有的物理支持的操做。
fold以及其餘
大量使用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類型推導等特性。看似很酷,但等你仔細推敲才發現,它們帶來的麻煩,比它們解決的問題其實還要多。有些特性聲稱解決的問題,其實根本就不存在。如今我把一些函數式語言的特性,以及它們包含的陷阱簡要列舉一下:
currying。貌似很酷,但是被部分調用的參數只能從左到右,依次進行。如何安排參數的順序成了問題。大部分時候還不如直接製造一個新的lambda,在內部調用舊的函數,這樣能夠任意的安排參數順序。
Hindley-Milner類型推導。爲了不寫參數和返回值的類型,結果給程序員寫代碼增長了不少的限制。爲了讓類型推導引擎開心,致使了不少徹底合法合理優雅的代碼沒法寫出來。其實還不如直接要程序員寫出參數和返回值的類型,這工做量真的很少,並且能夠準確的幫助閱讀者理解參數的範圍。HM類型推導的根本問題其實在於它使用unification算法。Unification其實只能表示數學裏的「等價關係」(equivalence relation),而程序語言最重要的關係,subtyping,並非一個等價關係,由於它不具備對稱性(symmetry)。
代數數據類型(algebraic data type)。所謂「代數數據類型」,其實並不如普通的類型系統(好比Java的)通用。不少代數數據類型系統具備所謂sum type,這種類型其實帶來過多的類型嵌套,不如通用的union type。盲目崇拜代數數據類型的人,每每是由於盲目的相信「數學是優美的語言」。而其實事實是,數學是一種歷史遺留的,毛病不少的語言。數學的語言根本沒有通過系統的,全球協做的設計。每每是數學家在黑板上隨便寫個符號,說這個表示XX概念,而後就定下來了。
Tuple。有代數數據類型的的語言裏面常常有一種構造叫作Tuple,好比Haskell裏面能夠寫(1, "hello")
,表示一個類型爲(Int, String)
的結構。這種構造常常被人看得過於高尚,以致於用在超越它能力的地方。其實Tuple就是一個沒有名字的結構(相似C的structure),並且結構裏面的域也沒有名字。臨時使用Tuple貌似很方便,由於不須要定義一個結構類型。然而由於Tuple沒有名字,並且裏面的域無法用名字訪問,一旦裏面的數據多一點就發現很麻煩了。Tuple每每只能經過模式匹配來得到裏面的域,一旦你增長了新的域進去,全部含有這個Tuple的模式匹配代碼都須要改。因此Tuple通常只能用在大小不超過3的狀況下,並且必須確信之後不會增長新的域進去。
惰性求值(lazy evaluation)。貌似數學上很優雅,但其實有嚴重的邏輯漏洞。由於bottom(死循環)成爲了任何類型的一個元素,因此取每個值,均可能致使死循環。同時致使代碼性能難以預測,由於求值太懶,因此可能臨時抱佛腳作太多工做,而平時浪費CPU的時間。因爲到須要的時候才求值,因此在有多個處理器的時候沒法有效地利用它們的計算能力。
尾遞歸。大部分尾遞歸都至關於循環語句,然而卻不像循環語句同樣具備一目瞭然的意圖。你須要仔細看代碼的各個分支的返回條件,判斷是否有分支是尾遞歸,而後才能判斷這代碼是個循環。而循環語句從關鍵字(for,while)就知道是一個循環。因此等價於循環的尾遞歸,其實最好仍是寫成特殊的循環語句。固然,尾遞歸在另外一些狀況下是有用的,這些狀況不等價於循環。在這種狀況下使用循環,常常須要複雜的break或者continue條件,致使循環不易理解。因此循環和尾遞歸,其實都是有必要的。
不少人避免「函數式vs面向對象」的辯論,因而他們成爲了「好好先生」。這種人沒有原則的認爲,任何可以解決當前問題的工具就是好工具。也就是這種人,喜歡使用shell script,喜歡折騰各類Unix工具,由於顯然,它們能解決他「手頭的問題」。
然而這種思潮是極其有害的,它的害處其實更勝於投靠函數式或者面向對象。沒有原則的好好先生們忙着「解決問題」,卻不能清晰地看到這些問題爲何存在。他們所謂的問題,每每是因爲現有工具的設計失誤。因爲他們的「隨和」,他們歷來不去思考,如何從根源上消滅這些問題。他們在一堆歷史遺留的垃圾上縫縫補補,妄圖使用設計惡劣的工具建造可靠地軟件系統。固然,這代價是很是大的。不但勞神費力,並且也許根本不能解決問題。
因此每當有人讓我談談「函數式vs面向對象」,我都避免說「各有各的好處」,由於那樣的話我會很容易被當成這種毫無原則的好好先生。
從上面你已經看出,我既不是一個鐵桿「函數式程序員」,也不是一個鐵桿「面向對象程序員」,我也不是一個愛說「各有各的好處」的好好先生。我是一個有原則的批判性思惟者。我不但看透了各類語言的本質,並且看透了它們之間的統一關係。我編程的時候看到的不是表面的語言和程序,而是一個相似電路的東西。我看到數據的流動和交換,我看到效率的瓶頸,而這些都是跟具體的語言和範式無關的。
在個人心目中其實只有一個概念,它叫作「編程」(programming),它不帶有任何附加的限定詞(好比「函數式」或者「面向對象」)。個人老師Dan Friedman喜歡把本身的領域稱爲「Programming Languages」,也是同樣的緣由。由於咱們研究的內容,不侷限於某一個語言,也不侷限於某一類語言,而是全部的語言。在咱們的眼裏,全部的語言都不過是各個特性的組合。在咱們的眼裏,最近出現的所謂「新語言」,其實不大可能再有什麼真正意義上的創新。咱們不喜歡說「發明一個程序語言」,不喜歡使用「發明」這個詞,由於無論你怎麼設計一個語言,全部的特性幾乎都早已存在於現有的語言裏面了。我更喜歡使用「設計」這個詞,由於雖然一個語言沒有任何新的特性,它卻有可能在細節上更加優雅。
編程最重要的事情,實際上是讓寫出來的符號,可以簡單地對實際或者想象出來的「世界」進行建模。一個程序員最重要的能力,是直覺地看見符號和現實物體之間的對應關係。無論看起來多麼酷的語言或者範式,若是必須繞着彎子才能表達程序員心目中的模型,那麼它就不是一個很好的語言或者範式。有些東西原本就是有隨時間變化的「狀態」的,若是你偏要用「純函數式」語言去描述它,固然你就進入了那些monad之類的死衚衕。最後你不但沒能高效的表達這種反作用,並且讓代碼變得比過程式語言還要難以理解。若是你進入另外一個極端,必定要用對象來表達原本很純的數學函數,那麼你同樣會把簡單的問題搞複雜。Java的所謂design pattern,不少就是製造這種問題的,而沒有解決任何問題。
關於建模的另一個問題是,你內心想的模型,並不必定是最好的,也不必定非得設計成那個樣子。有些人內心沒有一個清晰簡單的模型,以爲某些語言「好用」,就由於它們可以對他那種扭曲紛繁的模型進行建模。因此你就跟這種人說不清楚,爲何這個語言很差,由於顯然這個語言對他是有用的!如何簡化模型,已經超越了語言的範疇,在這裏我就不細講了。
我設計Yin語言的宗旨,就是讓人們能夠用最簡單,最直接的方式來對世界進行建模,而且幫助他們優化和改進模型自己。