从 Map/Reduce 到 JavaScript 函数式编程

自 ECMAScript 5.1 以来,Array.prototype.mapArray.prototype.reduce 已被引入主流浏览器。这两个函数不仅允许开发者更清晰地描述计算过程,还简化了编写遍历数组循环的工作;尤其是在循环代码实际上是为了将数组映射到一个新数组,或者进行累加、校验和等类似的归约操作时。

Left: using ordinary loop; Right: using map & reduce

左:使用普通循环;右:使用 map 和 reduce

 

Map/Reduce

Map 实际上意味着在不对输出进行结构性更改的情况下对原始数组进行计算。例如,当 map 接收到一个数组时,您可以确保输出将是另一个数组,唯一的区别是其中的元素可能从原始值/类型转换为另一个值/类型。因此,我们可以说上面示例中的 doMap 函数具有以下类型签名

doMap signature

签名显示 [Number] 表示这是一个数字数组。因此,我们现在可以将签名解读为

doMap 是一个函数,它将把一个数字数组转换为一个布尔值数组

另一方面,归约操作意味着我们可以将输入数据类型的结构更改为新的类型。例如,doReduce 的签名是

doReduce signature

这里,[Number]Array 不见了。因此,我们可以看到 mapreduce1 之间的区别。

函数式编程

事实上,mapreduce 的概念比 JavaScript 还要古老,并且广泛应用于其他函数式编程语言,例如 Lisp 或 Haskell2。Douglas Crockford 在其著名文章“JavaScript:世界上最被误解的编程语言”中提到了这一点3

JavaScript 的类 C 语法,包括花括号和笨拙的 for 语句,使其看起来像是一种普通的程序语言。这具有误导性,因为 JavaScript 与 Lisp 或 Scheme 等函数式语言比与 C 或 Java 更相似。

这就是 JavaScript 可以做一些其他正交 OOP 语言无法或不会做的事情的原因之一。例如,在 Java 84 5 之前,如果我们想要做一些 JavaScript 中常见的“回调”操作,我们需要创建一个冗余的“匿名类”。

Button button =
  (Button) findViewById(R.id.button);
button.setOnClickListener(
  new OnClickListener() {
    public void onClick(View v) {
      // do something
    }
  }
);

当然,在 JavaScript 中使用匿名回调还是不使用始终存在争议。当组件不断增长时,我们可能会遇到回调地狱。但是,一等函数可以做很多超出回调的事情。在 Haskell 中,我们只用函数就可以组织整个类似 Quake 的游戏6 的 GUI 程序7。也就是说,我们甚至可以不使用方法继承模板和其他人们通常期望在构建程序时具有的东西8

Frag

Frag,用 Haskell 编写的类似 Quake 的游戏

因此,在 JavaScript 世界中,我们可以遵循类似的模式来构建我们的程序,而不是像程序员在开始解决问题时经常做的那样,匆忙实现自己的“类”和“类系统”9。毕竟,在 JavaScript 中添加一些函数式风格并不会太糟糕,尤其是在 mapreduce 等特性由原生 API 支持的情况下。采用这种方法也意味着我们可以通过组合特性而不是重新定义它们来编写更简洁的代码10。唯一的限制是语言本身仍然不够函数化,因此如果我们玩太多花样可能会遇到麻烦,尽管这可以通过合适的库来解决11

mapreduce 将其他函数作为参数接收并输出它们作为结果。这一点很重要,因为它们以这种方式呈现了在函数式世界中组合计算的基本思想,并且能够灵活且可扩展地将小片段粘合在一起。例如,让我们看一下上面提到的 map 表达式的签名

map signature

您会注意到第二个参数表示一个类型为 Number -> Boolean 的函数。事实上,您可以为它提供任何类型为 a -> b 的函数。这在 JavaScript 世界中可能并不奇怪——我们在日常工作中编写了大量的回调。但是,关键是高阶函数本身也是函数。它们可以组合成更大的函数,直到我们只用一等函数和一些强大的高阶函数(如 idreducecurryuncurryarrowbind12)生成完整的程序。

Map/Reduce 在实践中

由于我们可能会遇到语言限制,因此我们无法完全以函数式风格编写 JavaScript 代码;但是,我们可以借鉴类型和组合的思想来做很多事情。例如,当您以类型的角度思考时,您会发现 map 不仅用于数据处理

map & fold signatures

这将是 Haskell 中 map 和 reduce 的类型签名。我们可以用任何东西替换 ab。那么,如果 a 变成 SQLb 变成 IO x 会怎么样?请记住,我们正在以类型的角度思考,而 IO x 不过是一个普通的类型,比如 IntURL

-- Let's construct queries from SQL statements.
makeQueries strs = map str  prepare conn str
doQuery qrys = foldl (results query  results >> query) (return ()) qrys 
-- Do query and get results.
let stmts = [ "INSERT INTO Articles ('Functional JavaScript')"
            , "INSERT INTO Gecko VALUES ('30.a1')"
            , "DELETE FROM Articles WHERE version='deprecated'"
            ]
main = execute (doQuery (makeQuery stmts))`

(注意:这只是一个简化的 Haskell 示例,仅供演示。它实际上无法执行。)

该示例使用 map 创建了一个 makeQueries 函数,它将 SQL 转换为 IO ()13;这也意味着我们生成了几个可以执行的操作。

makeQueries signature

然后,doQuery 函数(实际上是一个归约操作)将执行查询

doQueries signature

请注意,它的归约操作在特定 Monad 的 bind 函数(>>)的帮助下执行 IO 操作。本文未涵盖此主题,但读者应该将其想象为一种组合函数以逐步执行它们的方式,就像 Promise 所做的那样24

这种技术不仅在 Haskell 中有用,在 JavaScript 中也很有用。我们可以使用此思想以及 Promises 和 ES6 箭头函数来组织类似的计算

  // Use a Promise-based library to do IO.
  var http = require("q-io/http")
     ,noop = new Promise(()=>{})
     ,prepare =
        (str)=> http.read('http://www.example.com/' + str)
                  .then((res)=> res.body.toString())
                  // the 'then' is equal to the '>>'
     ,makeQuery = 
        (strs)=> strs.map((str)=> prepare(str))
     ,doQuery = 
        (qrys)=> qrys.reduce((results, qry)=> results.then(qry), noop)
     ,stmts = [ "articles/FunctionalJavaScript"
              , "blob/c745ef73-ece9-46da-8f66-ebes574789b1"
              , "books/language/Haskell"
              ]
     ,main = doQuery(makeQuery(stmts));

(注意:在 Node.js 中,使用 map/reduce 和 Promise 的类似查询代码不会像 Haskell 版本那样运行,因为我们需要 Lazy Promise14 和 Lazy Evaluation15

我们非常接近我们想要的东西:用函数定义计算,然后组合它们以稍后执行,尽管“稍后”的概念实际上并不正确,因为我们没有在 JavaScript 中进行真正的惰性求值。如果我们使用保持未完成的 Promise 的技巧——一个只有在我们想要时才解析的 resolve 函数——就可以实现这一点。但是,即使这样也很棘手,并且仍然存在一些无法解决的问题。

需要注意的另一件事是,我们的程序不需要可变变量,但一些计算结果会在程序的每个步骤中进行转换和转发。事实上,这仅仅是函数式语言能够保持纯净的一个原因,因此它们可以从优化中受益并消除意外的副作用1617

更多关于函数式编程

Map/reduce 是 JavaScript 中最常见的函数式特性。使用 Promise 等非函数式特性,我们可以使用类似 Monad 的计算控制技巧,或者我们可以使用 ES6 的胖箭头函数轻松定义柯里化函数18等等。此外,还有一些优秀的库提供了不错的函数式特性19 20 21,甚至还有一些领域特定语言 (DSL) 以函数式精神为基础22 23。当然,理解函数式编程的最佳方法是学习一种为此而设计的语言,例如 Haskell、ML 或 OCaml。Scala、F# 和 Erlang 也是不错的选择。


1. 事实上,map 可以用 reduce 实现。对于这种结构,最基本的操作是 reduce
https://github.com/timoxley/functional-javascript-workshop/blob/440da6737f34b4550301ba3a77b2e4b7721e99fd/problems/implement_map_with_reduce/solution.js#L11

2. http://en.wikipedia.org/wiki/Lisp_(programming_language)#Control_structures

3. http://javascript.crockford.com/javascript.html

4. Java 8 现在包含 lambda 函数:https://docs.oracle.com/javase/tutorial/java/javaOO/lambdaexpressions.html

5. C++ 传统上并非函数式语言,但 C++11 引入了 lambda 函数:http://en.wikipedia.org/wiki/C%2B%2B11#Lambda_functions_and_expressions

6. https://www.haskell.org/haskellwiki/Frag

7. Haskell 可以从函数的角度表示数据结构,尽管声明函数和数据类型仍然不是一回事:http://book.realworldhaskell.org/read/data-structures.html

8. 是的,我在作弊:我们在 Haskell 中有 Typeclass、Functor、instance 和类型变量。

9. 对于那些离不开类的人,ES6 将是您的未来:http://wiki.ecmascript.org/doku.php?id=strawman:maximally_minimal_classes

10. 我发现一些“糟糕的函数式代码”可以通过严格遵循一些函数式模式尽可能简洁地重构。最成问题的“函数式”代码发生在编码人员错误地混合两种编程风格时。这可能会以一种使代码更复杂的方式混合两种范式的难题。

11. 当我想在 JavaScript 中获得漂亮的 Monad 和惰性 Promise 时,我总是遇到障碍。但是,如果您不介意“疯狂”的实现,这些都是可行的,我们甚至可以在 JavaScript 中拥有“Monad Transformer”。其他特性,如尾递归优化和真正的惰性求值,如果没有运行时支持是不可能实现的。

12. 函数 arrowbind 在 Haskell 中实际上是 >>>>>=。它们是 Haskell 中使用特定效果组合计算和程序的关键;因此,我们可以拥有状态机、网络、事件处理、IO 语句和异步流程控制。重要的是,这些仍然是普通的函数。

13. 类型 IO () 表示“执行 IO 但不返回任何值”。IO a 表示一些 IO 操作在函数执行后可能会获得值 a,尽管某些操作只获得 ()。例如,从用户输入获取字符串的函数将是:ask:: IO String,而打印字符串的函数是 print:: String -> IO String

14. http://www.jroller.com/vaclav/entry/promises_getting_lazy

15. http://www.haskell.org/haskellwiki/Lazy_evaluation

16. JavaScript 可以使用像 map、set 和 list 这样的结构的库来做到这一点。Facebook 为此创建了一个名为 immutable-js 的不可变数据结构库:https://github.com/facebook/immutable-js

17. 您可以使用 immutable-js 做几乎相同的事情,并说服每个人只使用 letconst 来定义变量。

18. http://wiki.ecmascript.org/doku.php?id=harmony:arrow_function_syntax

19. wu.js:http://fitzgen.github.io/wu.js/

20. Ramda:http://ramdajs.com/ramdocs/docs/

21. transducer.js:http://jlongster.com/Transducers.js–A-JavaScript-Library-for-Transformation-of-Data

22. LiveScript:https://livescript.node.org.cn/

23. Elm:http://elm-lang.org/

24. 不,它们实际上并不相同,但您可以*在 Monad 中实现 Promise*

关于 Greg Weng

更多 Greg Weng 的文章…


5 条评论

  1. Gleb Bahmutov

    我相信从典型的命令式 JavaScript 直接过渡到带有事件流的函数式或反应式编程是一个过于激进的变化。我建议像我在http://bahmutov.calepin.co/journey-from-procedural-to-reactive-javascript-with-stops.html中描述的那样,逐步渐进地进行。

    2015年1月30日 08:22

  2. Kyle Kress

    很棒的概念性文章。在过去的一年中,我一直在进行大量的函数式探索,最近还探索了 Elm,如脚注中所述。我认为最大的内在问题是,目前您几乎必须将这些范式塞进 JavaScript 中——即使这是“可能的”。在 JavaScript 原生的 .map 中,您调用的所有内容的顺序与 Haskell 列表 map 的顺序相反。在 JavaScript 中,它是 array .map (func) 或使用 Array.prototype.map ( array, func ) 甚至 LoDash/Underscore _.map ( array, func )——而在 Haskell 中,它是 map func list,顺序就是这样。这会破坏柯里化和组合性。像 Ramda(也在脚注中提到)这样的库已经解决了这个问题——但问题在于,您目前需要一个库才能在 JavaScript 中理智地使用这些高阶函数。更不用说,在原生环境中,您要么必须在所有原型上定义 map,要么以奇怪的方式在类似 NodeList 的东西上调用 Array.prototype.map,而 NodeList 看起来应该在其自己的 NodeList 原型上具有 map 函数。

    ES6 将尾调用优化作为规范的一部分,并且函数式编程的流行度正在上升,我真的很希望看到 ES7 支持开箱即用的更多高阶函数,并具有组合性,而不是反向参数。在此之前,使用像 Elm 或 PureScript 这样的东西似乎更有意义,它在语言中将类型注释和函数式特性作为“一等公民”,并将其编译为 JavaScript 作为目标。

    2015年2月1日 07:42

  3. Aldo

    没错,JavaScript 有时候在处理某些任务(比如这个)时可能会很痛苦,但程序员不得不面对这种情况,而对于这种情况,map 方法拯救了我们。

    2015年2月2日 08:59

  4. Alex Weber

    很棒的文章!作为一个越来越熟悉函数式编程的人,这篇文章真的很有帮助。ES6 的内容就像是一次教育性的双重加倍奖励。

    我目前唯一的问题是:学习更深入的函数式编程的最佳起点是什么?有什么推荐的 Haskell 教程/书籍吗?

    谢谢!

    2015年2月6日 12:44

  5. Brian m

    如果我必须维护代码,我随时随地都会选择一个漂亮的 for 循环,无论什么语言,它都易读且易于调试!
    我担心 JavaScript 正在变得越来越糟,添加的很少有真正有用的东西,只会增加复杂性,并引入更多错误或对“所有人”的程序员来说过于聪明的代码,他们会误用它。

    尝试加入函数式编程只会让这门语言更令人困惑,它的面向对象编程基础与 Java、C# 或 C++ 相比,至少可以说非常糟糕!

    它唯一的优势是无处不在,但普通感冒也是如此!

    2015年2月7日 15:24

本文评论已关闭。