读书笔记-Javascript轻量级函数式编程

《JavaScript 轻量级函数式编程》读书笔记
中文版

函数基础

实参和形参

  • arguments:输入的值,实参
  • parameters:函数中的命名变量,形参

参数数量

  • Arity:一个函数声明的形参数量
    可以在运行时通过函数的 length 属性获取
  • 实参数量:arguments.length

ES6 的 ... 操作符

  • 在形参列表,它把实参整合;在实参列表,它把实参展开
  • 函数参数用解构数组/对象
  • 在函数式编程中,我们希望我们的函数在任何情况下都是一元的(Arity 为 1),可以使用对象解构传参实现

解构

声明性代码通常比命令式代码更干净

特定多态:通过不同的输入值让一个函数重载拥有不同的行为(在不同的情况下使函数具有不同的输出)。但是这样的设计的维护成本会比较高

函数输出

  • 使用函数式编程,每个函数必须明确的 return 一个值
  • 我们经常使用提前 return 来控制代码流,但这会造成难以理解,最好不要用 return 来实现流控制,而是使用明确的表达逻辑的方式
  • 纯函数,避免副作用

高阶函数:接受或返回一个甚至多个函数的函数(将其他函数视为值进行操作,一等公民,可作为函数的参数/返回值)

闭包

  • 当一个函数内部存在另一个函数的作用域时,对当前函数进行操作。当内部函数从外部函数引用变量,这被称作闭包
  • 闭包可以记录并且访问它作用域外的变量,甚至当这个函数在不同的作用域被执行
  • 闭包不仅限于获取变量的原始值:它不仅仅是快照,而是直接链接;你可以更新该值,并在下次访问时获取更新后的值
  • 如果你需要设置两个输入,一个你已经知道,另一个需要后面才能知道,可以使用闭包来记录第一个输入值
  • 同样可以通过闭包来记住函数

句法

  • 最好给所有函数都命名,避免匿名函数。-> 堆栈轨迹调用;可靠的自我引用和可读性;更好地理解函数的目的/设计
  • 箭头函数是词法匿名的,所以具有上述缺点,但语法简洁,能简化/优化代码片段中的空间
  • 函数式编程中不要使用 this,因为 this 相当于使用了隐式输入

管理函数的输入

函数式编程者习惯于在重复做同一种事情的地方找到模式,并试着将这些行为转换为逻辑可重用的实用函数

偏函数:在函数调用现场(function call-site),将实参应用(apply)于形参。偏函数是一个减少函数参数个数(arity)的过程

1
2
3
4
5
6
7
8
// fn:被偏应用实参的函数
// presetArgs:提前传入的实参
// laterArgs;调用时传入的实参
function partial(fn, ...presetArgs) {
return function partiallyApplied(...laterArgs) {
return fn(...presetArgs, ...laterArgs)
}
}

bind(..):预设 this 关键字的上下文,以及偏应用实参

将实参顺序颠倒

1
2
3
4
5
function reverseArgs(fn) {
return function argsReversed(...args) {
return fn(...args.reverse())
}
}

从右边开始偏应用实参(右偏应用实参)

这个函数不能保证让一个特定的形参接受特定的被偏应用的值,只能确保将被这些值(一个或几个)当作原函数最右边的实参(一个或几个)传入

1
2
3
4
5
function partialRight(fn, ...presetArgs) {
return reverseArgs(
partial(reverseArgs(fn), ...presetArgs.reverse())
)
}

柯里化:将一个期望接收多个实参的函数拆解成连续的链式函数,每个链式函数接收单一实参并返回另一个接收下一个实参的函数

此函数默认基于如下条件:在拿到原函数期望的全部实参之前,我们能够通过检查将要被柯里化的函数的 length 属性来得知柯里化需要迭代多少次

如果需要用在一个 length 属性不明确的函数上(形参声明包含默认形参值/形参解构/可变参数函数等),需要明确传入 arity 参数来确保 curry(..) 函数的正常运行

1
2
3
4
5
6
7
8
9
10
11
12
function curry(fn, arity = fn.length) {
return (function nextCurried(prevArgs) {
return function curried(nextArg) {
var args = prevArgs.contact([nextArg])
if (args.length >= arity) {
return fn(...args)
} else {
return nextCurried(args)
}
}
})([]) // attention here
}

柯里化和偏应用

  • 都是用来减少函数的参数数量的技术;柯里化是偏应用的一种特殊形式,其参数数量降低为 1
  • 柯里化:每次函数调用传入一个实参,并生成另一个特定性更强的函数,之后我们可以在程序中获取并使用那个新函数
  • 偏应用:预先指定所有将被偏应用的实参,产出一个等待接收剩下所有实参的函数
  • 柯里化和偏应用都使用闭包来保存实参,直到收齐所有实参后再执行原函数
  • 柯里化和偏应用可以将指定分离实参的时机和地方独立开来
  • 当函数只有一个形参时,我们能够比较容易地组合它们

松散柯里化:允许在每次柯里化调用中指定多个实参,允许传入超过 arity 的实参

1
2
3
4
5
6
7
8
9
10
11
12
function looseCurry(fn, arity = fn.length) {
return (function nextCurried(prevArgs) {
return function curried(...nextArgs) {
var args = prevArgs.concat(nextArgs)
if (args.length >= arity) {
return fn(...args)
} else {
return nextCurried(args)
}
}
})([])
}

反柯里化

返回的结果是:如果传入原函数期望数量的实参,则函数的行为和原函数相同;而如果少传了实参,得到的是仍在等待传入更多实参的部分柯里化函数

1
2
3
4
5
6
7
8
9
function uncurry(fn) {
return function uncurried(...args) {
var ret = fn
for (let i = 0; i < args.length; i++) {
ret = ret(args[i])
}
return ret
}
}

单参数函数:包装一个函数调用,确保被包装的函数只接受一个实参。即强制把一个函数处理成单参数函数

应用:例如 map(..) 函数调用时会传入三个实参,而只想接收一个

1
2
3
4
5
6
7
8
9
10
function unary(fn) {
return function onlyOneArg(arg) {
return fn(arg)
}
}

// e.g.
['1', '2', '3'].map(parseFloat) // [1, 2, 3]
['1', '2', '3'].map(parseInt) // [1, NaN, NaN]
['1', '2', '3'].map(unary(parseInt)) // [1, 2, 3]

identity 函数

  • 可以被用作判空的断言
  • 可以将其作为替代一个转换函数的默认函数
1
2
3
function identity(v) {
return v
}

恒定参数

可以用于 Certain API(禁止直接给方法传值,必须传入函数,比如 JS Promise)

1
2
3
4
5
function constant(v) {
return function value() {
return v
}
}

扩展参数:调整一个函数,让它把能接收的单一数组扩展成各自独立的实参

也被称为 apply(..)

1
2
3
4
5
function spreadArgs(fn) {
return function spreadFn(argsArr) {
return fn(...argsArr)
}
}

聚集参数

也被称为 unapply(..)

1
2
3
4
5
function gatherArgs(fn) {
return function gatheredFn(...argsArr) {
return fn(argsArr)
}
}

无论是柯里化还是偏应用,我们都用了上面的许多繁琐的技巧来修正这些形参的顺序,而这些繁琐的代码会把我们自己的代码混淆得一团糟…在第二章介绍了用解构实现命名实参,那么偏应用/柯里化中也可以有对应的实用函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function partialProps(fn, presetArgsObj) {
return function partiallyApplied(laterArgsObj) {
return fn(Object.assign({}, presetArgsObj, laterArgsObj))
}
}

function curryProps(fn, arity = 1) {
return (function nextCurried(prevArgsObj) {
return function curried(nextArgObj = {}) {
var [key] = Object.keys(nextArgObj) // nextArgObj has only one value
var allArgsObj = Object.assign({}, prevArgsObj, { [key]: nextArgObj[key] })
if (Object.keys(allArgsObj).length >= arity) {
return fn(allArgsObj)
} else {
return nextCurried(allArgsObj)
}
}
})({})
}

现在可以想传哪个位置的实参就传哪个了,但是我们需要掌握函数 fn(.. 的函数签名,并且可以定义该函数的行为,使其解构第一个参数时,上面的技术才能起作用。那如果一个函数的形参是各自独立(没有经过形参解构),而且不改变它的函数签名,该怎么办呢

yep,just like spreadArgs(...)

但是存在一个问题,spreadArgs(...) 中参数的顺序是明确的,然而对象属性的顺序不太明确且不可靠…一个最直观的想法是再传入一个类似 ['x', 'y', 'z'] 这样的数组通知我们的实用函数明确的参数顺序,但是这好弱智!下面给出了一种 hack 技巧可以根据解析函数代码本身(由 .toString() 方法可以获得)得到每个单独的命名形参

1
2
3
4
5
6
7
8
9
10
11
12
function spreadArgProps(
fn,
propOrder =
fn.toString()
.replace(/^(?:(?:function.*\(([^]*?)\))|(?:([^\(\)]+?)\s*=>)|(?:\(([^]*?)\)\s*=>))[^]+$/, "$1$2$3")
.split(/\s*,\s*/)
.map(v => v.replace( /[=\s].*$/, "" ))
) {
return function spreadFn(argsObj) {
return fn(...propOrder.map(k => argsObj[k]))
}
}

无形参风格(point-free)/隐性编程(tacit programming):通过移除不必要的形参-实参映射来减少视觉上的干扰,提高代码的可读性和可理解性

使用无形参风格的关键,是找到代码中,有哪些地方的函数直接将其形参作为内部函数调用的实参

取反函数

1
2
3
4
5
function not(predicate) {
return function negated(...args) {
return !predicate(...args)
}
}

when(..) 实用函数:用于表示 if 条件句

1
2
3
4
5
6
7
function when(predicate, fn) {
return function conditional(...args) {
if (predicate(...args)) {
return fn(...args)
}
}
}

组合函数

函数有多种多样的形状和大小。我们能够定义某种组合方式,来让它们成为一种新的组合函数,程序中不同的部分都可以使用这个函数。这种将函数一起使用的过程叫做组合。

compose2(..) 实用函数:自动创建两个函数的组合(从右向左组合)

1
2
3
4
5
function compose2(fn2, fn1) {
return function composed(origValue) {
return fn2(fn1(origValue))
}
}

通用组合函数

这里的顺序依然是从右向左的,可以结合上一章所介绍的 partialRight(..) 实用函数把 compose(..) 的参数右偏,这样就可以先传入一些内层,之后再不断包外层函数(原本在参数列表左侧)

1
2
3
4
5
6
7
8
9
function compose(...fns) {
return function composed(result) {
var list = fns.slice()
while (list.length > 0) {
result = list.pop()(result)
}
return result
}
}

不同的实现

reduce

上面的原始版本 compose(..) 使用一个循环并且饥渴的(立刻)执行计算,将一个调用的结果传递给下一个调用。这不就是 reduce(..)

reduce(..) 循环发生在最后的 composed(..) 运行时,并且每一个中间的 result(..) 将会在下一次调用时作为输入值传递给下一个迭代

1
2
3
4
5
6
7
function compose(...fns) {
return function composed(result) {
return fns.reverse().reduce(function reducer(result, fn) {
return fn(result)
}, result)
}
}

但是,这种实现存在迭代问题是最内层的组合函数(也就是组合中的第一个函数)只能接受一个参数,如果组合中的每一个函数都是一元的,这个方案问题不大。但如果需要给第一个调用传递多参数…

惰性运算:直接返回 reduce(..) 调用的结果,该结果自身就是个函数,不是一个计算过的值。该函数让我们能够传入任意数目的参数,在整个组合过程中,将这些参数传入到第一个函数调用中,然后一次铲除结果给到后面的调用

1
2
3
4
5
6
7
function compose(...fns) {
return fns.reverse().reduce(function reducer(fn1, fn2) {
return function composed(...args) {
return fn2(fn1(...args))
}
})
}

递归

1
2
3
4
5
6
7
8
function compose(...fns) {
var [fn1, fn2, ...rest] = fns.reverse()
var composedFn = function composed(...args) {
return fn2(fn1(...args))
}
if (rest.length === 0) return composedFn
return compose(...rest.reverse(), composedFn)
}

重排序组合:以函数执行的顺序来排列参数

1
2
3
4
5
6
7
8
9
10
11
12
function pipe(...fns) {
return function piped(result) {
var list = fns.slice()
while (list.length > 0) {
result = list.shift()(result)
}
return result
}
}

// 或者,只是将 compose(..) 的参数反转就可以
var pipe = reverseArgs(compose)

抽象:对两个或多个任务公共部分的剥离。通用部分只定义一次,从而避免重复。为了展现每个任务的特殊部分,通用部分需要被参数化

DRY: don’t repeat yourself. 力求能在程序的任何任务中有唯一的定义

我们不是为了隐藏细节而抽象,而是为了通过分离来突出关注点。

函数组合并不是通过 DRY 的原则来节省代码量,而是从怎么做中分离出是什么

减少副作用

副作用

  • 有副作用的函数可读性更低,因为它需要更多的阅读来理解程序(不确定运行一个函数会不会影响其他地方,所以必须要去读函数内部逻辑)
  • 所有决定函数输出的原因应该被设置的直接并明显,函数的使用者能直接看到原因和结果
  • 并不是禁止引用所有自由变量,比如引用函数/ Math.PI 这种 “常量” 引用是可以的。因为它们在整个程序中都不改变,我们不需要担心将它们作为变化的状态追踪它们;同样,他们不会损害程序的可读性,而且它们也不会因为变量以不可预测的方式变化而成为错误的源头
  • 随机数是不纯的
  • IO 是副作用
  • 一系列有副作用的函数可能会在异步调用时顺序出问题(特别是回调!),从而对外部造成奇怪的影响

限制潜在问题

  • 幂等
    • 数学中的幂等:在第一次调用后,如果你将该输出一次又一次地输入到操作中,其输出永远不会改变的操作
    • 编程中的幂等:要求 f(x) 每次调用的结果和第一次调用的结果没有任何改变
    • 在任何可能的情况下通过幂等的操作限制副作用要比不做限制的更新好得多
  • 纯函数:没有副作用的函数,是一种幂等函数
    • 给定相同的输入总是产生相同的输出
    • 具有引用透明性:一个函数调用可以被它的输出值所代替,并且整个程序的行为不会改变
    • 编程中的幂等
    • 一个纯函数可以引用自由变量,只要这些自由变量不是侧因(e.g.闭包)
    • 纯函数和不纯的函数的合成总是产生不纯的函数

减少副作用的目的并不是它们在程序中不能被观察到,而是设计一个程序,让副作用尽可能的少,因为这使代码更容易理解。一个没有观察到的发生的副作用的程序,在这个目标上,并不像一个不能观察它们的程序那么有效

也就是说,对于那些不得不发生的副作用,我们应该尽可能确定程序的任何部分都不能观察到它们,而不仅仅是观察它们

纯化

纯度仅仅需要深入到皮肤,也就是说,函数的纯度是从外部判断的,只要一个函数的使用表现为纯的,它就是纯的

  • 使用词法自由变量导致的副作用:
    • 如果可以选择修改周围的代码,那么可以使用作用域来封装它们
    • 如果无法在容器函数的内部封装修改代码(比如来自第三方库的函数),那么可以创建一个隔离副作用的接口函数:
      1. 捕获受影响的当前状态
      2. 设置初始输入状态
      3. 运行不纯的函数
      4. 捕获副作用状态
      5. 恢复原来的状态
      6. 返回捕获的副作用状态
  • 直接输入值(对象、数组等)的突变导致的副作用:再次创建一个接口函数来替代原始的不纯的函数去交互
  • this 导致的:
    • this 是函数隐式的输入
    • 创建一个接口函数,强制函数使用可预测的 this 上下文

值的不可变性

  • 原始值(number/string/boolean/null/undefined):本身就是不可变的
  • JS 的 boxing 特性:当访问原始数据类型时(特别是 number/string/boolean),在这种情况下,JS 会自动的把它们包装成这个值对应的对象(Number/String/Boolean
  • 注意,字符串也是不可变的,s[1] = 'E' 这种赋值会静默失败

值的不可变性:当需要改变程序中的状态时,我们不能改变已存在的数据,而是必须创建和跟踪一个新的数据(比如数组,就以拷贝代替改变,不改变原数组而是拷贝出来再改变)

常量:一个无法重新赋值的变量
常量与值的本质无关,无论常量承载何值,该变量都不能使用其他的值被进行重新赋值

我们应该担心的,并不是变量是否被重新赋值,而是值是否会发生改变

Object.freeze(..) 只是将对象的顶层设为不可变,基本上相当于使用 const 声明对象的每个属性

  • const arr = [1, 2, 3] 得到的数组依然可变
  • Object.freeze([1, 2, 3]) 的确是不可变的数组

性能

每次都重新复制一个数组,肯定会影响性能。如果只是比较少的状态变化,这样的性能损失可以接受(毕竟减少了 debug 时间!);但如果需要频繁改变状态,那么每次都复制肯定是不行的。

解决办法是可以像 git 版本控制原理那样,用一个链表记录状态的变化过程。Immutable.js 提供了这样的不可变数据结构。

无论世界上接收到的值是否可变,我们都应以它们是不可变的来对待,以此来避免副作用并使函数保持纯度

值不变在代码可读性上的意义,不在于不改变数据,而在于以不可变的眼光看待数据这样的约束。

闭包 vs 对象

一个没有闭包的编程语言可以用对象来模拟闭包;一个没有对象的编程语言可以用闭包来模拟对象
我们可以认为闭包和对象是一样东西的两种表达方式

  • 都可以表达状态集合
  • 也都可以包含函数或者方法
  • 啊哈,封装!

闭包将单个函数与一系列状态结合起来,而对象却在保有相同状态的基础上,允许任意数量的函数来操作这些状态
(当然,我们也可以在闭包返回的单个函数里写个 switch 之类的,来达到和对象的多个函数相同的实现效果)

对象和闭包在可变这点上没有关系,因为我们关心的是的可变性,值可变是值本身的特性,不在于在哪里或者如何被赋值的

同构

两件事物 A 和 B 如果你能够映射(转化)A 到 B 并且能够通过反向映射回 A 那么它们就是同构

如果 JS 有同构的话是怎么样的?它可能是一集合的 JS 代码转化为了另一集合的 JS 代码,并且(重要的是)如果你愿意的话,你可以把转化后的代码转为之前的。

闭包和对象是状态(及其相关功能)的同构表示

区别

  • 闭包的结构是不可变的(所以如果像复制数组这种操作,只能封装更多的闭包);对象默认是完全可变的
  • 闭包通过词法作用域提供“私有”状态;而对象将一切作为公共属性暴露(闭包这种信息隐藏反而不利于跟踪使用状态;同时,闭包具有控制内部变量只能被闭包内部代码操作的特性,这一点比之前讨论的 const 好用
  • 像上一章提到的状态拷贝,在对象上很容易实现;而闭包上就很麻烦,需要额外定义很多拥有提取或拷贝隐藏值权限的函数…
  • 对象在性能上通常比闭包好,因为 JS 对象通常在内存甚至计算角度是更加轻量的

列表操作

非函数式编程的列表处理:

  • forEach(..):有副作用!
  • some(..)/every(..):虽然鼓励使用纯函数,但不可避免地将列表简化为 truefalse 的值,本质上就像搜索和匹配

映射 map(...)

映射:将一个值转换为另一个值(从一个地方映射到另一个新的地方)

1
2
3
4
5
6
7
function map(mapperFn, arr) {
var newList = []
for (let idx = 0; idx < arr.length; idx++) {
newList.push(mapperFn(arr[idx], idx, arr))
}
return newList
}

这里 mapperFn(..) 的参数和原生一致是三个,如果希望只传第一个进去,可以用之前介绍过的 unary(..)

map(..) 使得可以方便的做列表的链式操作

map(..) 的每个转换应该独立与其他的转换,没有从左往右的执行顺序

函子是采用运算函数有效用操作的值
函子在每个单独的值上执行操作函数,函子实用函数创建的新值是所有单个操作函数执行结果的组合

map(..) 函数采用关联值(数组)和映射函数(操作函数),并为数组中的每一个独立元素执行映射函数。最后,它返回由所有新映射值组成的新数组

过滤 filter(..)

filter(..) 中那个返回 true/false 来做决定(每一项在新数组中保留还是剔除)的函数有一个特别的称谓:谓词函数

1
2
3
4
5
6
7
8
9
function filter(predicateFn, arr) {
var newList = []
for (let idx = 0; idx < arr.length; idx++) {
if (predicateFn(arr[idx], idx, arr)) {
newList.push(arr[idx])
}
}
return newList
}

为了解决语义问题(传入的谓词函数命名问题),我们定义

1
2
3
4
5
6
7
8
9
10
var filterFn = filter
function filterOut(predicateFn, arr) {
return filterIn(not(predicateFn), arr)
}

// so...
isOdd(3) // true
isEven(2) // true
filterIn(isOdd, [1, 2, 3, 4, 5]) // [1, 3, 5]
filterOut(isEven, [1, 2, 3, 4, 5]) // [1, 3, 5]

缩减 reduce(..)

= 组合/缩减/折叠 = 将两个值合并成一个值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function reduce(reducerFn, initialValue, arr) {
var acc, startIdx
if (arguments.length == 3) {
acc = initialValue
startIdx = 0
} else if (arr.length > 0) {
acc = arr[0]
startIdx = 1
} else {
throw new Error('Must provide at least one value.')
}
for (let idx = startIdx; idx < arr.length; idx++) {
acc = reducerFn(acc, arr[idx], idx, arr)
}
return acc
}

我们可以将 reduce(..) 认为是将函数从左到右组合(just like pipe(..)),可以这样使用

1
2
3
4
5
var binary = fn => (arg1, arg2) => fn(arg1, arg2)
var pipeReducer = binary(pipe)
var fn = [3, 17, 6, 4]
.map(v => n => v * n)
.reduce(pipeReducer)

JS 还提供了 reduceRight(..)

map 也是 reduce

1
2
3
4
5
var double = v => v * 2

[1, 2, 3, 4, 5].map(double) // [2, 4, 6, 8, 10]

[1, 2, 3, 4, 5].reduce((list, v) => (list.push(double(v)), list), []) // [2, 4, 6, 8, 10]

filter 也是 reduce

1
2
3
4
5
6
7
8
var isOdd = v => v % 2 == 1

[1, 2, 3, 4, 5].filter(isOdd) // [1, 3, 5]

[1, 2, 3, 4, 5].reduce((list, v) => (
isOdd(v) ? list.push(v) : undefined,
list
), []) // [1, 3, 5]

去重

1
2
3
4
5
6
7
8
// 使用 indexOf 筛选时,是从左向右
// 找到的位置与 idx 相等说明该项是第一次出现
var unique =
arr =>
arr.filter(
(v, idx) =>
arr.indexOf(v) === idx
)
1
2
3
4
5
6
7
// 当列表项不能在新列表中找到时,将其插入到新的列表中
var unique =
arr =>
arr.reduce(
(list, v) =>
list.indexOf(v) === -1 ? (list.push(v), list) : list
, [])

扁平化

1
2
3
4
5
6
var flatten = 
arr =>
arr.reduce(
(list, v) =>
list.concat(Array.isArray(v) ? flatten(v) : v)
, [])

flatten(..) 的常用用法之一是当你映射一组元素列表,并将每一项值从原来的值转换为数组(如果不想要返回的二维数组)

Zip

交替选择两个输入只能列表的值,并将得到的值组成子列表

1
2
3
4
5
6
7
8
9
function zip(arr1, arr2) {
var zipped = []
arr1 = arr1.slice()
arr2 = arr2.slice()
while (arr1.length > 0 && arr2.length > 0) {
zipped.push([arr1.shift(), arr2.shift()])
}
return zipped
}

这个实现明显存在一些非函数式编程的思想。这里有一个命令式的 while 循环并且采用 shift()push(..) 改变列表。虽然在纯函数中使用了非纯的行为(通常是为了性能),但只要其产生的副作用完全包含在这个函数内部,这种实现就是安全纯净的

合并

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function mergeLists(arr1, arr2) {
var merged = []
arr1 = arr1.slice()
arr2 = arr2.slice()
while (arr1.length > 0 || arr2.length > 0) {
if (arr1.length > 0) {
merged.push(arr1.shift())
}
if (arr2.length > 0) {
merged.push(arr2.shift())
}
}
return merged
}

编程风格

JS 中通常使用链式调用,而函数式编程喜欢嵌套式的调用,但是用于 compose(..) 都不太合适…

链式组合方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var partialThis = 
(fn, ...presetArgs) =>
// 故意用 function 为了获得 this 绑定
function partiallyApplied(...laterArgs) {
return fn.apply(this, [...presetArgs, ...laterArgs])
}
var composeChainedMethods =
(...fns) =>
result =>
// result 是前一步传入的 this
fns.reduceRight((result, fn) => fn.call(result), result)

composeChainedMethods(
partialThis(Array.prototype.reduce, sum, 0),
partialThis(Array.prototype.map, double),
partialThis(Array.prototype.filter, isOdd)
)
([1, 2, 3, 4, 5]) // 18

独立组合实用函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
var filter = (arr, predicateFn) => arr.filter(predicateFn)
var map = (arr, mapperFn) => arr.map(mapperFn)
var reduce = (arr, reducerFn, initialValue) => arr.reduce(reduceFn, initialValue)

// arr 是第一个,需要右偏一下
compose(
partialRight(reduce, sum, 0),
partialRight(map, double),
partialRight(filter, isOdd)
)
([1, 2, 3, 4, 5]) // 18

// 不过一般函数式编程类库都是直接把 arr 放最后一个了
// 顺便还柯里化一下
var filter = curry(
(predicateFn, arr) =>
arr.filter(predicateFn)
)
var map = curry(
(mapperFn, arr) =>
arr.map(mapperFn)
)
var reduce = curry(
(reducerFn, initialValue, arr) =>
arr.reduce(reducerFn, initialValue)
)

// 完美!
compose(
reduce(sum)(0),
map(double),
filter(idOdd)
)
([1, 2, 3, 4, 5]) // 18

发现上面的定义都很套路!都是派发相应的原生数组方法,那不就可以再写个函数做这件事!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var unboundMethod = 
(methodName, argCount = 2) =>
curry(
(...args) => {
var obj = args.pop()
return obj[methodName](...args)
},
argCount // 第二个参数是 arity
)
var filter = unboundMethod('filter', 2)
var map = unboundMethod('map', 2)
var reduce = unboundMethod('reduce', 3)

// 直到这时才确定 curry 里面的第一个函数的 obj = args.pop()
// 事实上是 this!(也就是 Array Object)
// methodName 被闭包保存起来了
compose(
reduce(sum)(0),
map(double),
filter(idOdd)
)
([1, 2, 3, 4, 5]) // 18

当然咧,也有办法把我们的独立函数(上面定义的 去重 扁平 zip 等等)变成可以链式调用的数组方法(当然不是直接改 Array.prototype !)。一个思路是把独立函数适配成一个缩减函数,然后传给 reduce(..)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 原独立函数的定义
var flatten =
arr =>
arr.reduce(
(list, v) =>
list.concat(Array.isArray(v) ? flatten(v) : v)
, [])

function flattenReducer(list, v) {
return list.concat(
Array.isArray(v) ? v.reduce(flattenReducer, []) : v
)
}

[[1, 2, 3], 4, 5, [6, [7, 8]]]
.reduce(flattenReducer, [])

条件确保实用函数

1
2
3
4
var guard = 
fn =>
arg =>
arg != null ? fn(arg) : arg

链式组合风格:声明式的,很容易看出详尽的执行步骤和顺序;但每一个列表操作都需要循环整个列表,意味着不必要的性能损失

1
2
3
4
5
6
7
someList
.filter(..)
.filter(..)
.map(..)
.map(..)
.map(..)
.reduce(..)

交替独立风格:操作自下而上列出,同样会多次循环列表

1
2
3
4
5
6
7
map(
fn3,
map(
fn2,
map(fn1, someList)
)
)

融合:合并相邻的操作,从而减少列表的迭代次数

一个简单的例子,对于连续的 map(..) 链式,可以很容易想到,把几个内层函数 compose(..) or pipe(..) 起来,从而只需遍历一次列表

递归

递归深谙函数式编程之精髓,最被广泛引证的原因是,在调用栈中,递归把(大部分)显式状态跟踪换为了隐式状态。通常,但问题需要条件分支和回溯计算时,递归非常有用,此外在纯迭代环境中管理这种状态,是相当棘手的;最起码,这些代码是不可或缺且晦涩难懂。但是在堆栈上调用每一级的分支作为其自己的作用域,很明显,这通常会影响到代码的可读性。

正如 Σ 是为运算而声明(符号是数学的声明式语言!),递归是为算法而声明

尾调用

如果一个回调从函数 baz() 转到函数 bar() 的时候,而回调是在函数 baz() 的最底部执行——也就是尾调用——那么 baz() 的堆栈帧就不再需要了。也就意味着,内存可以被回收,或只需简单的执行 bar() 函数

正确的尾调用(PTC):由 ES6 明确规定的尾调用特定形式,只要正确的使用了尾调用就不会发生栈溢出。PTC 长下面这样:函数调用在最后一步,并且必须有返回

1
2
3
4
5
// PTC
return foo(..);

// this is also right
return x ? foo(..) : bar(..);

如果你的递归比较复杂,不是尾递归(比如二分法),就需要想办法重构递归:

更换堆栈

1
2
3
4
5
// 一个不符合 PTC 规范的例子
function sum(num1, ...nums) {
if (nums.length == 0) return num1
return num1 + sum(...nums)
}

重构策略的关键点在于,我们可以通过把置后处理累加改为提前处理,来消除对堆栈的依赖,然后将该部分结果作为参数传递到递归调用。换句话说,我们不用在当前运用函数的堆栈帧中保留 num1+sum(..num1) 的总和,而是把它传递到下一个递归的堆栈帧中,这样就能释放当前递归的堆栈帧。

也就是说,我们可以把 sum 计算的结果作为参数传进 sum:

1
2
3
4
5
function sum(result, num1, ...nums) {
result = result + num1
if (nums.length == 0) return result
return sum(result, ...nums)
}

但是调用者需要在调用时额外传一个 result = 0 进去!通常的处理是再用一个函数包裹它,对外暴露一个接口函数(各种方法实现,平级函数/内部函数/IIFE 包裹等等)。但是这样可读性已经明显降低…

1
2
3
4
5
function sum(num1, num2, ...nums) {
num1 = num1 + num2 // 啊哈,直接把 num1 当成初始的 result
if (nums.length == 0) return num1
return sum(num1, ...nums)
}

另一个例子

1
2
3
4
5
6
7
8
9
10
function maxEven(num1, ...restNums) {
var maxRest = restNums.length > 0 ? maxEven(...restNums) : undefined
return (num1 % 2 != 0 || num1 < maxRest) ? maxRest : num1
}

function maxEven(num1, num2, ...nums) {
num1 = (num1 % 2 == 0 && !(maxEven(num2) > num1)) ? num1 : (num2 % 2 == 0 ? num2 : undefined)
return nums.length == 0 ? num1 : maxEven(num1, ...nums)
}
// 第一次调 maxEven(num2) 不是为了 PTC 优化,而只是想避免重复写 % 逻辑(这里只递归一级就返回了)

后继传递格式

后继传递格式(CPS):组织代码,使得每个函数在其结束时接受另一个执行函数

比如进行相互递归的这个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function fib_(n) {
if (n == 1) return 1
else return fib(n - 2)
}
function fib(n) {
if (n == 0) return 0
else return fib(n - 1) + fib_(n)
}

// CPS
function fib(n, cont = identity) {
if (n <= 1) return cont(n)
return fib(
n - 2,
n2 => fib( // 这个函数接受 fib(n-2) 的结果作为 n2
n - 1,
n1 => cont(n2 + n1) // 这个函数接受 fib(n-1) 的结果作为 n1,得到 n1 n2 后相加传入下个后续函数
)
)
}

弹簧床

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function trampoline(fn) {
return function trampolined(...args) {
var result = fn(...args)
while (typeof result == 'function') {
result = result()
}
return result
}
}

var sum = trampoline(
function sum(num1, num2, ...nums) {
num1 = num1 + num2
if (nums.length == 0) return num1
return () => sum(num1, ...nums)
}
)

异步的函数式

异步编程最为重要的一点是通过抽象时间来简化状态变化的管理。

promise 以时间无关的方式来作为一个单一的值。此外,获取 promise 的返回值是异步的,但却是通过同步的方法来赋值。或者说,promise 给 = 操作符扩展随时间动态赋值的功能,通过可靠的(时间无关)方式。

惰性数据结构 懒操作

observables(RxJS Most)

  • 数组的 map(..) 方法会用当前数组中的每一个值运行一次映射函数,然后放到返回的数组里(积极的数据结构)
  • observable 数组里则是为每一个值运行一次映射函数,无论这个值何时加入,然后把它返回到 observable 里(持续惰性的)

Transducing

transduer 就是可组合的 reducer,也就是可以 compose 一系列 reduce 操作,避免反复遍历列表(而 map filter 等可以转成 reduce)

推导过程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
// 例子
// 这样反复遍历性能很差,而且可读性也很差
words
.map(strUppercase)
.filter(isLongEnough)
.filter(isShortEnough)
.reduce(strConcat, '')

// 把 map/filter 表示为 reduce
function mapReducer(mapperFn) {
return function reducer(list, val) {
return list.concat([mapperFn(val)])
}
}
function filterReducer(predicateFn) {
return function reducer(list, val) {
if (predicateFn(val)) return list.concat([val])
return list
}
}

// 有个共享功能!
function listCombination(list, val) {
return list.concat([val])
}

// 把 listCombination 这个小工具参数化进我们的 reducers
function mapReducer(mapperFn, combinationFn) {
return function reducer(list, val) {
return combinationFn(list, mapperFn(val))
}
}
function filterReducer(predicateFn, combinationFn) {
return function reducer(list, val) {
if (predicateFn(val)) return combinationFn(list, val)
return list
}
}

// curry 一下
var curriedMapReducer = curry(function mapReducer(mapperFn, combinationFn) {
return function reducer(list, val) {
return combinationFn(list, mapperFn(val))
}
})
var curriedFilterReducer = curry(function filterReducer(predicateFn, combinationFn) {
return function reducer(list, val) {
if (predicateFn(val)) return combinationFn(list, val)
return list
}
})

// 接下来是就是奇迹发生

// 让我们看看柯里化没传全参数的样子
// 嗯,它们每个都期望得到一个 listCombination 并产生一个独立的 reducer
var x = curriedMapReducer(strUppercase)
var y = curriedFilterReducer(isLongEnough)
var z = curriedFilterReducer(isShortEnough)

// 但是,如果调用 y(z),会发生什么?
// 我们得到的 reducer 函数内部看起来会像这样
// 当然,这样是错的,因为我们的 z 只想接收一个参数
function reducer(list, val) {
if (isLongEnough(val)) return z(list, val) // here
return list
}

// 但是 y(z(listCombination)) 这样呢
var shortEnoughReducer = z(listCombination) // 正常滴
var longAndShortEnoughReducer = y(shortEnoughReducer) // 为什么叫这个名字?

// 看看它们的内部
// shortEnoughReducer
function reducer(list, val) {
if (isShortEnough(val)) return listCombination(list, val)
return list
}
// longAndShortEnoughReducer
function reducer(list, val) {
if (isLongEnough(val)) return shortEnoughReducer(list, val) // here!!!
return list
}

// 事实是,上面这样写就能运行!而且是正确的!
// 因为 reducer(..) 的形状和 listCombination(..) 是一样的
// 可以理解为,都是“接收一个列表和一个值,返回一个新列表”

// 那就可以这样把所有的 reducers compose 起来了!
// 需要注意的一点是,最终的执行顺序就是 strUppercase isLongEnough isShortEnough
// 因为每个 reducer 里面反转了顺序
var composition = compose(
curriedMapReducer(strUppercase),
curriedFilterReducer(isLongEnough),
curriedFilterReducer(isShortEnough)
)
words.reduce(composition(listCombination), [])

最后的一些讨论:

  • 我们的 listCombination(..) 内部用了纯的 concat(..),但是这样性能肯定不好。是不是可以直接换成性能更好但不纯的 push(..) 呢?答案是可以。因为我们知道 listCombination(..) 只会在 transducing 内部使用,没有违反对外是纯函数这一准则,内部可以为了性能而变得不纯
  • 如果两个“形状”不一样的组合函数呢?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 我们可以“组合”这两个 reduce 吗?
words
.reduce(transducer(listCombination), [])
.reduce(strConcat, '')

// 直接把 strConcat 放进我们的 compose 中是不行的,因为它的“形状”不适用于那个 compose
// 但让我们看看这两个“形状”
function strConcat(str1, str2) {
return str1 + str2
}
function listCombination(list, val) {
list.push(val)
return list
}

// 嗯?在概念上这两个功能是一样的:将两个值组合成一个
// strConcat 也是一个组合函数!
// 我们最终的目标是获得字符串!那就可以直接用 strConcat 代替 listCombination
words.reduce(transducer(strConcat), '')

// BOOM! This is transducing!

Monad

函子(functor):包括一个值和一个用来对构成函子对数据执行操作的类 map 实用函数。

Monad:一个包含一些额外行为的函子。它更像是一种根据不同值的需要而用不同方式实现的接口,每一种实现都是一种不同类型的 Monad。Monad 是一个用更具有声明式的方式围绕一个值来组织行为的方法。

Maybe

如果一个值是非空的,它是 Just(..) 的实例;如果该值是空的,它则是 Nothing() 的实例

Maybe Monad 的价值在于不论我们有 Just(..) 实例还是 Nothing(..) 实例,我们使用的方法都是一样的。Maybe 这个抽象概念的作用是隐式地封装了操作和无操作的二元性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
var Maybe = {
Just,
Nothing,
of: Just
}

function Just(val) {
// 无论 Just 实例拿到的是怎样的一个 val,Just 都不会改变它,所有的方法都会创建一个新的 Monad 实例而不是改变它
return { map, chain, ap, inspect }

function map(fn) {
return Just(fn(val))
}
function chain(fn) { // 也叫 bind/flatMap
return fn(val)
}
function ap(anotherMonad) {
return anotherMonad.map(val)
}
function inspect() {
return `Just(${val})`
}
}
function Nothing() {
return {
map: Nothing,
chain: Nothing,
ap: Nothing,
inspect
}
function inspect() {
return 'Nothing'
}
}

Monad 的核心思想是,它必须对所有值都是有效的,不能对值做任何检查——甚至是空值检查

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 在外部进行空值检查的例子
function isEmpty(val) {
return val === null || val === undefined
}
var safeProp = curry(function safeProp(prop, obj) {
if (isEmpty(obj[prop])) return Maybe.Nothing()
return Maybe.of(obj[prop])
})

Maybe.of(someObj)
.chain(safeProp('something'))
.chain(safeProp('else'))
.chain(safeProp('entirely'))
.map(console.log)

Humble

是一个产生 Maybe Monad 实例的工厂函数,可以加入各种条件判断…