自 ECMAScript 5.1 以来,Array.prototype.map
和 Array.prototype.reduce
已被引入主流浏览器。这两个函数不仅允许开发者更清晰地描述计算过程,还简化了编写遍历数组循环的工作;尤其是在循环代码实际上是为了将数组映射到一个新数组,或者进行累加、校验和等类似的归约操作时。
Map/Reduce
Map 实际上意味着在不对输出进行结构性更改的情况下对原始数组进行计算。例如,当 map
接收到一个数组时,您可以确保输出将是另一个数组,唯一的区别是其中的元素可能从原始值/类型转换为另一个值/类型。因此,我们可以说上面示例中的 doMap
函数具有以下类型签名
签名显示 [Number]
表示这是一个数字数组。因此,我们现在可以将签名解读为
doMap
是一个函数,它将把一个数字数组转换为一个布尔值数组
另一方面,归约操作意味着我们可以将输入数据类型的结构更改为新的类型。例如,doReduce
的签名是
这里,[Number]
的 Array
不见了。因此,我们可以看到 map
和 reduce
1 之间的区别。
函数式编程
事实上,map
和 reduce
的概念比 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,用 Haskell 编写的类似 Quake 的游戏
因此,在 JavaScript 世界中,我们可以遵循类似的模式来构建我们的程序,而不是像程序员在开始解决问题时经常做的那样,匆忙实现自己的“类”和“类系统”9。毕竟,在 JavaScript 中添加一些函数式风格并不会太糟糕,尤其是在 map
和 reduce
等特性由原生 API 支持的情况下。采用这种方法也意味着我们可以通过组合特性而不是重新定义它们来编写更简洁的代码10。唯一的限制是语言本身仍然不够函数化,因此如果我们玩太多花样可能会遇到麻烦,尽管这可以通过合适的库来解决11。
map
和 reduce
将其他函数作为参数接收并输出它们作为结果。这一点很重要,因为它们以这种方式呈现了在函数式世界中组合计算的基本思想,并且能够灵活且可扩展地将小片段粘合在一起。例如,让我们看一下上面提到的 map
表达式的签名
您会注意到第二个参数表示一个类型为 Number -> Boolean
的函数。事实上,您可以为它提供任何类型为 a -> b
的函数。这在 JavaScript 世界中可能并不奇怪——我们在日常工作中编写了大量的回调。但是,关键是高阶函数本身也是函数。它们可以组合成更大的函数,直到我们只用一等函数和一些强大的高阶函数(如 id
、reduce
、curry
、uncurry
、arrow
和 bind
12)生成完整的程序。
Map/Reduce 在实践中
由于我们可能会遇到语言限制,因此我们无法完全以函数式风格编写 JavaScript 代码;但是,我们可以借鉴类型和组合的思想来做很多事情。例如,当您以类型的角度思考时,您会发现 map
不仅用于数据处理
这将是 Haskell 中 map 和 reduce 的类型签名。我们可以用任何东西替换 a
和 b
。那么,如果 a
变成 SQL
,b
变成 IO x
会怎么样?请记住,我们正在以类型的角度思考,而 IO x
不过是一个普通的类型,比如 Int
或 URL
-- 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;这也意味着我们生成了几个可以执行的操作。
然后,doQuery
函数(实际上是一个归约操作)将执行查询
请注意,它的归约操作在特定 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. 函数 arrow
和 bind
在 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 做几乎相同的事情,并说服每个人只使用 let
和 const
来定义变量。↩
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*↩
5 条评论