2.2.2 基于工具和标准的再加工
1. 不可变数据结构
不可变数据结构(Immutable Structures)包含了两个特性:不可变性和持久性。
不可变性意味着数据结构一旦创建就不能被修改了;持久性是指当尝试改变不可变数据结构时,总会返回一个建立在旧数据基础上的新数据结构。
不可变数据结构意味着程序每一步运行时都不会对入参和程序过程中引入的其他值造成影响。虽然我们关注的是最终结果,但入参或其他值的改变会造成并发和并行的不可控、函数执行的非幂等(或者看起来非幂等)、程序执行次序对过程影响大等情况。这明显违背了函数式想减少影响的初衷。
基础类型的数据,比如String,数据结构本身是不可变的,但按地址存储的JavaScript对象则显然不具备不可变性。
JavaScript使用者们常使用Lodash或其他库中对于对象Object的cloneDeep方法,维持对象数据的持久性,还会通过Immutable.js这类工具维持数据的不可变性。数组方法中的map等操作也可以用来在最浅的一层克隆产生新的数组对象。
2. 尾调用优化和CPS
早期的浏览器引擎是不支持尾调用优化(Tail-call Optimization)的,所以当我们计算经典的斐波那契数列或进行其他一些递归操作时,非惰性的方法计算可能会触发堆栈调用超限的提醒。
如果每次递归尾部返回的内容都是一个待计算的表达式,那么运行时的内存栈中会一直压入等待计算的变量和环境,这就是产生超限的根本原因。而如果我们使用Continuation编程风格(Continuation Programming Style, CPS)将程序写法变为返回新的递归方法,函数的调用就可以等价替换为返回的结果。此时若运行环境支持优化,则立即释放被替换的函数负载。
这个过程我们可以参考代码清单2-6进行理解。
代码清单2-6 JavaScript阶乘计算
// 一直将外层调用保存在内存栈中 function factorial(n){ if (n <= 1) { return 1; } else { return n * factorial(n - 1); } } factorial(1000000); // 返回函数调用,开启尾递归优化 function factorial(n, acc){ 'use strict' if (n <= 1) { return 1 } else { return factorial(n - 1, n * acc) } } factorial(1000000, 1);
跨越时间的尾递归给我们带来诸多迭代上的可能性,比如长时间的进程监控、轮询操作等。实际操作时,即便环境不原生支持,我们也随时都可以重新发起轮询。这也是使用CPS的另一个好处——迭代的中间结果是一个独立的运算过程。
此时大家可以看到,在比其他语言灵活的JavaScript语言下,编写代码的方式和思路可以影响状态和过程的运行,同时也会受到外部环境的支持和优化。
3. 运算符重载
二元运算符表示运算可以通过定义两个参数的函数方法来执行,比如把加法、求属性值的点运算改为运算函数、“_.get”的方法进行处理。新的函数方法还可以加强原来的运算符能力,比如加法函数中增加精度处理、求属性值函数中处理“undefined.a”这种运行时错误。
不过我们还希望使用一元运算符等符号扩展出更方便的语法,比如求点值,这样就可以早一点使用“?.”这种新的受保护访问运算符,也可以使用其他语言中“|”和“|>”这种管道操作符。这涉及语言设计之初的考量:与其他的语法逻辑是否会产生冲突、是否符合语言的设定风格等。类似操作符的作用可以参考代码清单2-7。
代码清单2-7 Elixir管道操作符
// 使用Elixir管道操作符后写法上的变化 func1(func2(func3(new_function(base_function())))) base_function() |> new_function() |> func3() |> func2() |> func1() // 运行示例 iex> "Danger keep out" |> String.upcase() |> String.split() ["DANGER", "KEEP", "OUT"]
语法层面的支持最终靠语言标准的建议和引进,以及对语言代码的重新解析来实现,比如Babel和TypeScript对“?.”的支持。