为什么说 JavaScript 不擅长函数式编程

MableApplet 3年前
   <p><img src="https://simg.open-open.com/show/63b61c3c2e5fefb5e5a7774270bdc2b7.png"></p>    <p>漫长的几年当中社区里讨论 JavaScript 和函数式编程的声音很多, 我无法的详细的去追踪, 也不是本文重点. 鼓吹 JavaScript 函数式编程的大的声音, 我的印象里主要是两次.</p>    <p>第一轮是经常能看到社区当中引用的 Douglas Crockford 在 2001 年写的文章. 后来常用的 Underscore 也继承了类似的思路来发挥函数式编程的一些好处. JavaScript 设计之初借鉴了 Scheme 的一些策略, 将函数作为一等公民, 支持被灵活传递使用. 以及有词法作用域以及闭包这些函数式编程的基础性结构. 这些赋予了 JavaScript 极大的灵活度通过函数来模拟各种需求.</p>    <p>The World's Most Misunderstood Programming Language</p>    <p>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.</p>    <p>第二轮是 React 触发到大量对于函数式编程的思考, 同期发生的还有 Elm 的 FRP 方案在社区引起巨大反响, 以及 Om 社区反馈到 React 社区一些技术和概念. 当中重要的概念有纯函数和不可变数据. 在 React 的渲染模型当中的, Store updates 和 Component rendering 两个过程需要隔离副作用以保证自由地复用, 而不可变数据则通过结构共享提供了性能优化的方案. </p>    <p>这些观点, 给人的感觉是 JavaScript 很适合函数式编程, 比如自带的数组操作方法常常能串联出比较漂亮的写法, 而且 React 在社区就算不能通吃, 但是已经取得了如此广泛的影响, 让大量的开发者接受了 reducer 纯函数这样的观念, 并在组件抽象上用于很多函数式编程的手法, 逐渐构建了强大的技术栈. 最终, 通过这些来验证 JavaScript 在函数式编程使用上的成功, 某种程度上算是自圆其说了, 而且也做出了成绩.</p>    <p>但是这种理解从不同的角度观察, 还是存在问题的. 我从比较早就接触到了 CoffeeScript 以及深刻影响到它的语言: Haskell. 到现在, 我有三年多 CoffeeScript 开发的经验, 一年的 ClojureScript 小项目的经验, 以及勉强入门的 Haskell 学习经验. 站在 JavaScript 之外, 看到的情况跟在 JavaScript 社区内部看到的并不一样.</p>    <p>首先 Wiki 上的定义, 可以看两点, 1) 用数学函数类似的表达式来定义计算过程, 而不是用汇编那样指令来描述计算, 2) 函数结果严格依赖于它的输入, 其他的影响结果的因素比如可变状态, 是要消除掉的:</p>    <p>Functional programming</p>    <p>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.</p>    <p>如果详细看 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 其实根本没有位置.</p>    <p>函数式编程的深度广度挺复杂, 特别是在后端, 知名的例子比如 非死book 用 Haskell 解决垃圾邮件过滤的性能问题, 或者 Clojure 作者的数据库 Datomic 的整体设计. 我作为前端开发者, 很难说出具体的细节来. 但显然不是前端用级联写法组合函数以及写写高阶函数那么简单.</p>    <p>所以换个角度来看待一些方面:</p>    <ul>     <li>JavaScript 能做函数式编程吗? 能.</li>     <li>JavaScript 满足函数式编程所有的约束吗, 或者说大部分? 基本没法约束 JavaScript.</li>     <li>JavaScript 函数式编程用得巧妙吗, 有效吗? 看情况, 不一定.</li>    </ul>    <p>这篇文章是为了明确说明 JavaScript 在函数式编程方面支持太少. 我不能从具体 Haskell 代码去解释, 那么换个办法, 按照概念来对比来看 JavaScript 做了什么. 下面的概念我参照一篇文章上的, 以 Clojure 还有 Haskell 为参照. 由于 Haskell 是函数式编程圈子里教科书式的语言, 基本上概念就是遵照 Haskell 罗列的: On Functional Programming</p>    <ul>     <li>Functions as first-class values, 函数一等公民, 可以把函数作为参数传递, 从而构造出高阶函数各种用法. 这个用法各种语言都支持了: Lua 支持, Python 似乎也支持, Java 也开始支持了, 我会的语言少都举不出来不支持传函数的流行语言.</li>     <li>Pure functions, 纯函数. 可以写, 但也有很大区别. JavaScript 没限制, 从而不能预判函数纯或者不纯. Clojure 遵循 Lisp 风格的约定, 带副作用的函数一般用 `f!` 这种叹号结尾的写法命名, 而编译器没有约束. Haskell 是严格约束的, 出了名的 IO Monad 就是因为遵循纯函数导致副作用难以直接用数学函数表达出来, 最终精心设计出一个概念.</li>     <li>Referential transparency, 引用透明, 所以表达式可以被其运算结果完全替换掉, 也就是要求控制甚至避免副作用.</li>     <li>Controlled effects, 受控的副作用, 主要手段是隔离. JavaScript 需要人为地去隔离, 语言层面完全没有限制. Clojure 也需要人为隔离, 就像前面说的 `f!` 那样的约定, 同时规定了数据不可变, 再加上作者有意在语言中强调控制副作用, 实际上副作用少得多. Haskell 通过类型系统限定, 不隔离副作用无法通过编译的.</li>     <li>Everything is an expression, 一切皆是表达式. JavaScript 做不到, 导致设计 DSL 时候很头疼, 倒是 CoffeeScript 做到了. Clojure 继承了 Lisp, 很明显一切皆是表达式. Haskell 代码里都是函数, 除了类型声明和语法糖部分, 也是一切皆是表达式.</li>     <li>No loops, 换句话说, 不能用 for/while, 因为这两个写法当中的 `i++` 依赖可变数据. JavaScript 经常使用 for/while. Clojure 当中的循环基本上用尾递归实现, 同时也提供了 doseq 之类的 Macro 让循环过程很好写. Haskell 就是完全尾递归的写法了.</li>     <li>Immutable values. JavaScript 默认可变, 仅有的手段用 `Object.free` 可以强行锁定对象或者 const 锁定变量本身, 另外就是 immutable-js 那样的共享结构的不可变数据作为类库来实现. Clojure 是把不可变数据和结构共享作为语言的基础, 专门设计了 Atom 类型用于模拟共享的可变状态, 也不排除某些场景和宿主语言的互操作还是会有可变数据. Haskell 默认就是不可变数据, 也有 IORef 相关的代码可以模拟可变状态, 但在教程里几乎看不到.</li>     <li>Algebraic Datatypes, 代数类型系统. JavaScript 没有静态类型系统, TypeScript 有类型, 但和代数类型还不一样. Clojure 没有静态类型系统, 就算有而只是很基础的类型检查, 或者用 Specs 做详细运行时检查. Haskell 有强大的代数类型系统, 即便是副作用也被涵盖在类型系统当中.</li>     <li>Product types. Haskell 通过代数类型系统支持.</li>     <li>No Null. JavaScript 当中有 undefined 和 null. Clojure 当中只有 nil. Haskell 里没有 null 也没有 nil, 而是用了 Maybe Monad 这样的概念, 通过类型系统进行了抽象和限制. null 的问题很深, 网上找解释吧, 我还没理解清楚, 只了解到满足了方便却造成了意料之外的复杂度.</li>     <li>A function always returns a value, 函数永远都有返回值, 类似一切皆是表达式那个问题. 比如 Haskell 里会有的叫做 Unit 的 `()` 空的值. 这个有点费解...</li>     <li>Currying, 柯理化. JavaScript 和 Clojure 也能模拟, 而在 Haskell 当中是默认行为.</li>     <li>Lexical scoping, 词法作用域. 三者都支持.</li>     <li>Closures, 闭包, 都支持.</li>     <li>Pattern matching, 模式匹配. 类似解构赋值之类的在 JavaScript 和 Clojure 当中通过语法糖也算有这个功能, 但是跟 Haskell 以及 Elixir 当中的用法对比起来差距很大. 比如说 Haskell 甚至能定义 `let 1 + 1 = 3` 来覆盖 `+` 的行为, 虽然是奇葩的现象, 但这就是一个定义的 pattern, 在 JavaScript 和 Clojure 都没有这种情况.</li>     <li>Lazy evaluation, 惰性计算. JavaScript 是严格求值的, 不支持惰性计算. Clojure 支持 Lazy, 然而由于 Clojure 又允许了一些副作用, 实际上某些特殊场景会需要手动 force 代码执行, 也就是说不完美. Haskell 采用惰性计算. 惰性计算就是说代码里的表达式被真正使用来才会真正执行, 否则就像是个 thunk, 继续以表达式存储着. 我印象里 Elm 社区说过, 对于图形界面来说 Lazy 反而是多余的.</li>    </ul>    <p>大致做个总结, 就是 Haskell 当中的类型系统, 不可变数据, 控制副作用, 在 Clojure 当中只是做了不可变数据, 同时稍微控制了一下副作用, 而这些概念在 JavaScript 当中很少有支持. 这样的结果, JavaScript 写出来的代码几乎都是不符合函数式编程的限制得.</p>    <p>不可变数据对程序的直接影响就是 for/while 没法写了. 可以想象一下, 如果你代码当中不让写可变数据, 这会是多大的影响, 会极大地影响了代码编写和开发的习惯的. 因为我们通常需要可变的状态来完成通信, 而且还要以 for/while 作为结构来构造程序, 抛开可变状态大学里学的内容很多都用不了了. 思维方式的转变, 是个不小的挑战.</p>    <p>同时也要注意, 函数式编程用的说法是"隔离副作用", 而不是说"去掉"副作用. 比如在 Clojure 当中, 要用共享可变状态的场景, 就要明确声明数据类型是 Atom, 更新数据用到的函数也不一样, 结果是实际使用当中会很有意识地去思考哪些地方直接用尾递归就写完了, 哪些迫不得已要使用 Atom 类型, 这种把可变状态明确区分来的意识在 Clojure 当中经常有. 还有就是比如 IO 这样的副作用, Clojure 当中虽然限制, 但是很松散, 即便写了编译器也不会说什么. Haskell 类型系统强制要求隔离好副作用, 不过我觉得对于大部分开发者来说这样既复杂又多此一举.</p>    <p>与之形成鲜明对比, JavaScript 设计时完全不在乎这些约定, 即便是模仿了 Scheme, 当年 PLT Scheme 那样的语言, 本身也没有限制好数据 immutable(目前 Racket 数据支持 mutable 和 immutable 两种形态, 也是神奇), 也只用了 `f!` 写法来标明副作用, 到了 JavaScript 连副作用都不标记. 结果说来说去, JavaScript 真正和函数式编程搭上的, 也就是闭包和函数一等公民嘛.</p>    <p>而且原本在函数式编程当中, 返回结果只是和参数改变有关, 每个数值又是引用透明的, 即便要做大量的抽象也能放心去做, 不担心出错. 到了 JavaScript 当中, 由于函数可以混用可变数据, 另外加上 this 指针的用法, 经过高阶函数抽象之后, 整个代码可能会变得难以预测, 这样函数式编程的可靠性就无法得到保障了. JavaScript 确实算是学到了函数式编程的技巧具备了灵活性, 但是却很难达到 Clojure 那样的可靠性, 甚至某些情况说不准因为函数抽象而引发更加麻烦的局面.</p>    <p>所以, 我的结论就是, JavaScript 学了几招厉害的, 确实能干点厉害的事情, 但是, 距离把功夫练好还差太远.</p>    <p>这篇文章我主要是吐槽 JavaScript 宣传函数式编程在误导人. 我很多时间在跟进着 Clojure 社区, 对于 Haskell 我只能在边上围观, 我能看到的就是函数式编程水真的很深, 我的文章当中很可能有不准确的地方, 看到的话请评论指出.</p>    <p> </p>    <p>来自:https://zhuanlan.zhihu.com/p/24076438</p>    <p> </p>