為什么說 JavaScript 不擅長函數式編程
漫長的幾年當中社區里討論 JavaScript 和函數式編程的聲音很多, 我無法的詳細的去追蹤, 也不是本文重點. 鼓吹 JavaScript 函數式編程的大的聲音, 我的印象里主要是兩次.
第一輪是經常能看到社區當中引用的 Douglas Crockford 在 2001 年寫的文章. 后來常用的 Underscore 也繼承了類似的思路來發揮函數式編程的一些好處. JavaScript 設計之初借鑒了 Scheme 的一些策略, 將函數作為一等公民, 支持被靈活傳遞使用. 以及有詞法作用域以及閉包這些函數式編程的基礎性結構. 這些賦予了 JavaScript 極大的靈活度通過函數來模擬各種需求.
The World's Most Misunderstood Programming Language
JavaScript's C-like syntax, including curly braces and the clunky for statement, makes it appear to be an ordinary procedural language. This is misleading because JavaScript has more in common with functional languages like Lisp or Scheme than with C or Java. It has arrays instead of lists and objects instead of property lists. Functions are first class. It has closures. You get lambdas without having to balance all those parens.
第二輪是 React 觸發到大量對于函數式編程的思考, 同期發生的還有 Elm 的 FRP 方案在社區引起巨大反響, 以及 Om 社區反饋到 React 社區一些技術和概念. 當中重要的概念有純函數和不可變數據. 在 React 的渲染模型當中的, Store updates 和 Component rendering 兩個過程需要隔離副作用以保證自由地復用, 而不可變數據則通過結構共享提供了性能優化的方案.
這些觀點, 給人的感覺是 JavaScript 很適合函數式編程, 比如自帶的數組操作方法常常能串聯出比較漂亮的寫法, 而且 React 在社區就算不能通吃, 但是已經取得了如此廣泛的影響, 讓大量的開發者接受了 reducer 純函數這樣的觀念, 并在組件抽象上用于很多函數式編程的手法, 逐漸構建了強大的技術棧. 最終, 通過這些來驗證 JavaScript 在函數式編程使用上的成功, 某種程度上算是自圓其說了, 而且也做出了成績.
但是這種理解從不同的角度觀察, 還是存在問題的. 我從比較早就接觸到了 CoffeeScript 以及深刻影響到它的語言: Haskell. 到現在, 我有三年多 CoffeeScript 開發的經驗, 一年的 ClojureScript 小項目的經驗, 以及勉強入門的 Haskell 學習經驗. 站在 JavaScript 之外, 看到的情況跟在 JavaScript 社區內部看到的并不一樣.
首先 Wiki 上的定義, 可以看兩點, 1) 用數學函數類似的表達式來定義計算過程, 而不是用匯編那樣指令來描述計算, 2) 函數結果嚴格依賴于它的輸入, 其他的影響結果的因素比如可變狀態, 是要消除掉的:
Functional programming
In computer science, functional programming is a programming paradigm—a style of building the structure and elements of computer programs—that treats computation as the evaluation of mathematical functions and avoids changing-state and mutable data. It is a declarative programming paradigm, which means programming is done with expressions[1] or declarations[2] instead of statements. In functional code, the output value of a function depends only on the arguments that are input to the function, so calling a function f twice with the same value for an argument x will produce the same result f(x) each time. Eliminating side effects, i.e. changes in state that do not depend on the function inputs, can make it much easier to understand and predict the behavior of a program, which is one of the key motivations for the development of functional programming.
如果詳細看 Wiki 會發現信息量非常大, 涉及到的編程語言有幾十種, 而且還有"純函數語言"的分類把某些語言劃分出來, 而 JavaScript 被劃分到了非函數式編程語言的一類里邊叫做 "Functional programming in non-functional languages". 我接觸過的函數式語言, 大致分類是 ML 系(Standard ML, OCaml, Haskell), Scheme 系(Racket, Guile, Chicken), CommonLisp 系(CommonLisp, EmacsLisp), Erlang 系(Erlang, Elixir), 還有特立獨行的 Scala, Shen 之類的. 當你去了解函數式編程的時候, JavaScript 其實根本沒有位置.
函數式編程的深度廣度挺復雜, 特別是在后端, 知名的例子比如 非死book 用 Haskell 解決垃圾郵件過濾的性能問題, 或者 Clojure 作者的數據庫 Datomic 的整體設計. 我作為前端開發者, 很難說出具體的細節來. 但顯然不是前端用級聯寫法組合函數以及寫寫高階函數那么簡單.
所以換個角度來看待一些方面:
- JavaScript 能做函數式編程嗎? 能.
- JavaScript 滿足函數式編程所有的約束嗎, 或者說大部分? 基本沒法約束 JavaScript.
- JavaScript 函數式編程用得巧妙嗎, 有效嗎? 看情況, 不一定.
這篇文章是為了明確說明 JavaScript 在函數式編程方面支持太少. 我不能從具體 Haskell 代碼去解釋, 那么換個辦法, 按照概念來對比來看 JavaScript 做了什么. 下面的概念我參照一篇文章上的, 以 Clojure 還有 Haskell 為參照. 由于 Haskell 是函數式編程圈子里教科書式的語言, 基本上概念就是遵照 Haskell 羅列的: On Functional Programming
- Functions as first-class values, 函數一等公民, 可以把函數作為參數傳遞, 從而構造出高階函數各種用法. 這個用法各種語言都支持了: Lua 支持, Python 似乎也支持, Java 也開始支持了, 我會的語言少都舉不出來不支持傳函數的流行語言.
- Pure functions, 純函數. 可以寫, 但也有很大區別. JavaScript 沒限制, 從而不能預判函數純或者不純. Clojure 遵循 Lisp 風格的約定, 帶副作用的函數一般用 `f!` 這種嘆號結尾的寫法命名, 而編譯器沒有約束. Haskell 是嚴格約束的, 出了名的 IO Monad 就是因為遵循純函數導致副作用難以直接用數學函數表達出來, 最終精心設計出一個概念.
- Referential transparency, 引用透明, 所以表達式可以被其運算結果完全替換掉, 也就是要求控制甚至避免副作用.
- Controlled effects, 受控的副作用, 主要手段是隔離. JavaScript 需要人為地去隔離, 語言層面完全沒有限制. Clojure 也需要人為隔離, 就像前面說的 `f!` 那樣的約定, 同時規定了數據不可變, 再加上作者有意在語言中強調控制副作用, 實際上副作用少得多. Haskell 通過類型系統限定, 不隔離副作用無法通過編譯的.
- Everything is an expression, 一切皆是表達式. JavaScript 做不到, 導致設計 DSL 時候很頭疼, 倒是 CoffeeScript 做到了. Clojure 繼承了 Lisp, 很明顯一切皆是表達式. Haskell 代碼里都是函數, 除了類型聲明和語法糖部分, 也是一切皆是表達式.
- No loops, 換句話說, 不能用 for/while, 因為這兩個寫法當中的 `i++` 依賴可變數據. JavaScript 經常使用 for/while. Clojure 當中的循環基本上用尾遞歸實現, 同時也提供了 doseq 之類的 Macro 讓循環過程很好寫. Haskell 就是完全尾遞歸的寫法了.
- Immutable values. JavaScript 默認可變, 僅有的手段用 `Object.free` 可以強行鎖定對象或者 const 鎖定變量本身, 另外就是 immutable-js 那樣的共享結構的不可變數據作為類庫來實現. Clojure 是把不可變數據和結構共享作為語言的基礎, 專門設計了 Atom 類型用于模擬共享的可變狀態, 也不排除某些場景和宿主語言的互操作還是會有可變數據. Haskell 默認就是不可變數據, 也有 IORef 相關的代碼可以模擬可變狀態, 但在教程里幾乎看不到.
- Algebraic Datatypes, 代數類型系統. JavaScript 沒有靜態類型系統, TypeScript 有類型, 但和代數類型還不一樣. Clojure 沒有靜態類型系統, 就算有而只是很基礎的類型檢查, 或者用 Specs 做詳細運行時檢查. Haskell 有強大的代數類型系統, 即便是副作用也被涵蓋在類型系統當中.
- Product types. Haskell 通過代數類型系統支持.
- No Null. JavaScript 當中有 undefined 和 null. Clojure 當中只有 nil. Haskell 里沒有 null 也沒有 nil, 而是用了 Maybe Monad 這樣的概念, 通過類型系統進行了抽象和限制. null 的問題很深, 網上找解釋吧, 我還沒理解清楚, 只了解到滿足了方便卻造成了意料之外的復雜度.
- A function always returns a value, 函數永遠都有返回值, 類似一切皆是表達式那個問題. 比如 Haskell 里會有的叫做 Unit 的 `()` 空的值. 這個有點費解...
- Currying, 柯理化. JavaScript 和 Clojure 也能模擬, 而在 Haskell 當中是默認行為.
- Lexical scoping, 詞法作用域. 三者都支持.
- Closures, 閉包, 都支持.
- Pattern matching, 模式匹配. 類似解構賦值之類的在 JavaScript 和 Clojure 當中通過語法糖也算有這個功能, 但是跟 Haskell 以及 Elixir 當中的用法對比起來差距很大. 比如說 Haskell 甚至能定義 `let 1 + 1 = 3` 來覆蓋 `+` 的行為, 雖然是奇葩的現象, 但這就是一個定義的 pattern, 在 JavaScript 和 Clojure 都沒有這種情況.
- Lazy evaluation, 惰性計算. JavaScript 是嚴格求值的, 不支持惰性計算. Clojure 支持 Lazy, 然而由于 Clojure 又允許了一些副作用, 實際上某些特殊場景會需要手動 force 代碼執行, 也就是說不完美. Haskell 采用惰性計算. 惰性計算就是說代碼里的表達式被真正使用來才會真正執行, 否則就像是個 thunk, 繼續以表達式存儲著. 我印象里 Elm 社區說過, 對于圖形界面來說 Lazy 反而是多余的.
大致做個總結, 就是 Haskell 當中的類型系統, 不可變數據, 控制副作用, 在 Clojure 當中只是做了不可變數據, 同時稍微控制了一下副作用, 而這些概念在 JavaScript 當中很少有支持. 這樣的結果, JavaScript 寫出來的代碼幾乎都是不符合函數式編程的限制得.
不可變數據對程序的直接影響就是 for/while 沒法寫了. 可以想象一下, 如果你代碼當中不讓寫可變數據, 這會是多大的影響, 會極大地影響了代碼編寫和開發的習慣的. 因為我們通常需要可變的狀態來完成通信, 而且還要以 for/while 作為結構來構造程序, 拋開可變狀態大學里學的內容很多都用不了了. 思維方式的轉變, 是個不小的挑戰.
同時也要注意, 函數式編程用的說法是"隔離副作用", 而不是說"去掉"副作用. 比如在 Clojure 當中, 要用共享可變狀態的場景, 就要明確聲明數據類型是 Atom, 更新數據用到的函數也不一樣, 結果是實際使用當中會很有意識地去思考哪些地方直接用尾遞歸就寫完了, 哪些迫不得已要使用 Atom 類型, 這種把可變狀態明確區分來的意識在 Clojure 當中經常有. 還有就是比如 IO 這樣的副作用, Clojure 當中雖然限制, 但是很松散, 即便寫了編譯器也不會說什么. Haskell 類型系統強制要求隔離好副作用, 不過我覺得對于大部分開發者來說這樣既復雜又多此一舉.
與之形成鮮明對比, JavaScript 設計時完全不在乎這些約定, 即便是模仿了 Scheme, 當年 PLT Scheme 那樣的語言, 本身也沒有限制好數據 immutable(目前 Racket 數據支持 mutable 和 immutable 兩種形態, 也是神奇), 也只用了 `f!` 寫法來標明副作用, 到了 JavaScript 連副作用都不標記. 結果說來說去, JavaScript 真正和函數式編程搭上的, 也就是閉包和函數一等公民嘛.
而且原本在函數式編程當中, 返回結果只是和參數改變有關, 每個數值又是引用透明的, 即便要做大量的抽象也能放心去做, 不擔心出錯. 到了 JavaScript 當中, 由于函數可以混用可變數據, 另外加上 this 指針的用法, 經過高階函數抽象之后, 整個代碼可能會變得難以預測, 這樣函數式編程的可靠性就無法得到保障了. JavaScript 確實算是學到了函數式編程的技巧具備了靈活性, 但是卻很難達到 Clojure 那樣的可靠性, 甚至某些情況說不準因為函數抽象而引發更加麻煩的局面.
所以, 我的結論就是, JavaScript 學了幾招厲害的, 確實能干點厲害的事情, 但是, 距離把功夫練好還差太遠.
這篇文章我主要是吐槽 JavaScript 宣傳函數式編程在誤導人. 我很多時間在跟進著 Clojure 社區, 對于 Haskell 我只能在邊上圍觀, 我能看到的就是函數式編程水真的很深, 我的文章當中很可能有不準確的地方, 看到的話請評論指出.
來自:https://zhuanlan.zhihu.com/p/24076438