读书笔记-Javascript轻量级函数式编程
《JavaScript 轻量级函数式编程》读书笔记
中文版
函数基础
实参和形参
- arguments:输入的值,实参
- parameters:函数中的命名变量,形参
参数数量
- Arity:一个函数声明的形参数量
可以在运行时通过函数的length
属性获取 - 实参数量:
arguments.length
ES6 的 ...
操作符
- 在形参列表,它把实参整合;在实参列表,它把实参展开
- 函数参数用解构数组/对象
- 在函数式编程中,我们希望我们的函数在任何情况下都是一元的(Arity 为 1),可以使用对象解构传参实现
解构
声明性代码通常比命令式代码更干净
特定多态:通过不同的输入值让一个函数重载拥有不同的行为(在不同的情况下使函数具有不同的输出)。但是这样的设计的维护成本会比较高
函数输出
- 使用函数式编程,每个函数必须明确的
return
一个值 - 我们经常使用提前
return
来控制代码流,但这会造成难以理解,最好不要用return
来实现流控制,而是使用明确的表达逻辑的方式 - 纯函数,避免副作用
高阶函数:接受或返回一个甚至多个函数的函数(将其他函数视为值进行操作,一等公民,可作为函数的参数/返回值)
闭包
- 当一个函数内部存在另一个函数的作用域时,对当前函数进行操作。当内部函数从外部函数引用变量,这被称作闭包
- 闭包可以记录并且访问它作用域外的变量,甚至当这个函数在不同的作用域被执行
- 闭包不仅限于获取变量的原始值:它不仅仅是快照,而是直接链接;你可以更新该值,并在下次访问时获取更新后的值
- 如果你需要设置两个输入,一个你已经知道,另一个需要后面才能知道,可以使用闭包来记录第一个输入值
- 同样可以通过闭包来记住函数
句法
- 最好给所有函数都命名,避免匿名函数。-> 堆栈轨迹调用;可靠的自我引用和可读性;更好地理解函数的目的/设计
- 箭头函数是词法匿名的,所以具有上述缺点,但语法简洁,能简化/优化代码片段中的空间
- 函数式编程中不要使用
this
,因为this
相当于使用了隐式输入
管理函数的输入
函数式编程者习惯于在重复做同一种事情的地方找到模式,并试着将这些行为转换为逻辑可重用的实用函数
偏函数:在函数调用现场(function call-site),将实参应用(apply)于形参。偏函数是一个减少函数参数个数(arity)的过程
1 | // fn:被偏应用实参的函数 |
bind(..)
:预设 this
关键字的上下文,以及偏应用实参
将实参顺序颠倒
1 | function reverseArgs(fn) { |
从右边开始偏应用实参(右偏应用实参)
这个函数不能保证让一个特定的形参接受特定的被偏应用的值,只能确保将被这些值(一个或几个)当作原函数最右边的实参(一个或几个)传入
1 | function partialRight(fn, ...presetArgs) { |
柯里化:将一个期望接收多个实参的函数拆解成连续的链式函数,每个链式函数接收单一实参并返回另一个接收下一个实参的函数
此函数默认基于如下条件:在拿到原函数期望的全部实参之前,我们能够通过检查将要被柯里化的函数的 length
属性来得知柯里化需要迭代多少次
如果需要用在一个 length
属性不明确的函数上(形参声明包含默认形参值/形参解构/可变参数函数等),需要明确传入 arity
参数来确保 curry(..)
函数的正常运行
1 | function curry(fn, arity = fn.length) { |
柯里化和偏应用
- 都是用来减少函数的参数数量的技术;柯里化是偏应用的一种特殊形式,其参数数量降低为 1
- 柯里化:每次函数调用传入一个实参,并生成另一个特定性更强的函数,之后我们可以在程序中获取并使用那个新函数
- 偏应用:预先指定所有将被偏应用的实参,产出一个等待接收剩下所有实参的函数
- 柯里化和偏应用都使用闭包来保存实参,直到收齐所有实参后再执行原函数
- 柯里化和偏应用可以将指定分离实参的时机和地方独立开来
- 当函数只有一个形参时,我们能够比较容易地组合它们
松散柯里化:允许在每次柯里化调用中指定多个实参,允许传入超过 arity 的实参
1 | function looseCurry(fn, arity = fn.length) { |
反柯里化
返回的结果是:如果传入原函数期望数量的实参,则函数的行为和原函数相同;而如果少传了实参,得到的是仍在等待传入更多实参的部分柯里化函数
1 | function uncurry(fn) { |
单参数函数:包装一个函数调用,确保被包装的函数只接受一个实参。即强制把一个函数处理成单参数函数
应用:例如 map(..)
函数调用时会传入三个实参,而只想接收一个
1 | function unary(fn) { |
identity 函数
- 可以被用作判空的断言
- 可以将其作为替代一个转换函数的默认函数
1 | function identity(v) { |
恒定参数
可以用于 Certain API(禁止直接给方法传值,必须传入函数,比如 JS Promise)
1 | function constant(v) { |
扩展参数:调整一个函数,让它把能接收的单一数组扩展成各自独立的实参
也被称为 apply(..)
1 | function spreadArgs(fn) { |
聚集参数
也被称为 unapply(..)
1 | function gatherArgs(fn) { |
无论是柯里化还是偏应用,我们都用了上面的许多繁琐的技巧来修正这些形参的顺序,而这些繁琐的代码会把我们自己的代码混淆得一团糟…在第二章介绍了用解构实现命名实参,那么偏应用/柯里化中也可以有对应的实用函数
1 | function partialProps(fn, presetArgsObj) { |
现在可以想传哪个位置的实参就传哪个了,但是我们需要掌握函数 fn(..
的函数签名,并且可以定义该函数的行为,使其解构第一个参数时,上面的技术才能起作用。那如果一个函数的形参是各自独立(没有经过形参解构),而且不改变它的函数签名,该怎么办呢
yep,just like spreadArgs(...)
但是存在一个问题,spreadArgs(...)
中参数的顺序是明确的,然而对象属性的顺序不太明确且不可靠…一个最直观的想法是再传入一个类似 ['x', 'y', 'z']
这样的数组通知我们的实用函数明确的参数顺序,但是这好弱智!下面给出了一种 hack 技巧可以根据解析函数代码本身(由 .toString()
方法可以获得)得到每个单独的命名形参
1 | function spreadArgProps( |
无形参风格(point-free)/隐性编程(tacit programming):通过移除不必要的形参-实参映射来减少视觉上的干扰,提高代码的可读性和可理解性
使用无形参风格的关键,是找到代码中,有哪些地方的函数直接将其形参作为内部函数调用的实参
取反函数
1 | function not(predicate) { |
when(..)
实用函数:用于表示 if
条件句
1 | function when(predicate, fn) { |
组合函数
函数有多种多样的形状和大小。我们能够定义某种组合方式,来让它们成为一种新的组合函数,程序中不同的部分都可以使用这个函数。这种将函数一起使用的过程叫做组合。
compose2(..)
实用函数:自动创建两个函数的组合(从右向左组合)
1 | function compose2(fn2, fn1) { |
通用组合函数
这里的顺序依然是从右向左的,可以结合上一章所介绍的 partialRight(..)
实用函数把 compose(..)
的参数右偏,这样就可以先传入一些内层,之后再不断包外层函数(原本在参数列表左侧)
1 | function compose(...fns) { |
不同的实现
reduce
上面的原始版本 compose(..)
使用一个循环并且饥渴的(立刻)执行计算,将一个调用的结果传递给下一个调用。这不就是 reduce(..)
!
reduce(..)
循环发生在最后的 composed(..)
运行时,并且每一个中间的 result(..)
将会在下一次调用时作为输入值传递给下一个迭代
1 | function compose(...fns) { |
但是,这种实现存在迭代问题是最内层的组合函数(也就是组合中的第一个函数)只能接受一个参数,如果组合中的每一个函数都是一元的,这个方案问题不大。但如果需要给第一个调用传递多参数…
惰性运算:直接返回
reduce(..)
调用的结果,该结果自身就是个函数,不是一个计算过的值。该函数让我们能够传入任意数目的参数,在整个组合过程中,将这些参数传入到第一个函数调用中,然后一次铲除结果给到后面的调用
1 | function compose(...fns) { |
递归
1 | function compose(...fns) { |
重排序组合:以函数执行的顺序来排列参数
1 | function pipe(...fns) { |
抽象:对两个或多个任务公共部分的剥离。通用部分只定义一次,从而避免重复。为了展现每个任务的特殊部分,通用部分需要被参数化
DRY: don’t repeat yourself. 力求能在程序的任何任务中有唯一的定义
我们不是为了隐藏细节而抽象,而是为了通过分离来突出关注点。
函数组合并不是通过 DRY 的原则来节省代码量,而是从怎么做中分离出是什么
减少副作用
副作用
- 有副作用的函数可读性更低,因为它需要更多的阅读来理解程序(不确定运行一个函数会不会影响其他地方,所以必须要去读函数内部逻辑)
- 所有决定函数输出的原因应该被设置的直接并明显,函数的使用者能直接看到原因和结果
- 并不是禁止引用所有自由变量,比如引用函数/
Math.PI
这种 “常量” 引用是可以的。因为它们在整个程序中都不改变,我们不需要担心将它们作为变化的状态追踪它们;同样,他们不会损害程序的可读性,而且它们也不会因为变量以不可预测的方式变化而成为错误的源头 - 随机数是不纯的
- IO 是副作用
- 一系列有副作用的函数可能会在异步调用时顺序出问题(特别是回调!),从而对外部造成奇怪的影响
限制潜在问题
- 幂等
- 数学中的幂等:在第一次调用后,如果你将该输出一次又一次地输入到操作中,其输出永远不会改变的操作
- 编程中的幂等:要求
f(x)
每次调用的结果和第一次调用的结果没有任何改变 - 在任何可能的情况下通过幂等的操作限制副作用要比不做限制的更新好得多
- 纯函数:没有副作用的函数,是一种幂等函数
- 给定相同的输入总是产生相同的输出
- 具有引用透明性:一个函数调用可以被它的输出值所代替,并且整个程序的行为不会改变
- 编程中的幂等
- 一个纯函数可以引用自由变量,只要这些自由变量不是侧因(e.g.闭包)
- 纯函数和不纯的函数的合成总是产生不纯的函数
减少副作用的目的并不是它们在程序中不能被观察到,而是设计一个程序,让副作用尽可能的少,因为这使代码更容易理解。一个没有观察到的发生的副作用的程序,在这个目标上,并不像一个不能观察它们的程序那么有效
也就是说,对于那些不得不发生的副作用,我们应该尽可能确定程序的任何部分都不能观察到它们,而不仅仅是不观察它们
纯化
纯度仅仅需要深入到皮肤,也就是说,函数的纯度是从外部判断的,只要一个函数的使用表现为纯的,它就是纯的
- 使用词法自由变量导致的副作用:
- 如果可以选择修改周围的代码,那么可以使用作用域来封装它们
- 如果无法在容器函数的内部封装修改代码(比如来自第三方库的函数),那么可以创建一个隔离副作用的接口函数:
- 捕获受影响的当前状态
- 设置初始输入状态
- 运行不纯的函数
- 捕获副作用状态
- 恢复原来的状态
- 返回捕获的副作用状态
- 直接输入值(对象、数组等)的突变导致的副作用:再次创建一个接口函数来替代原始的不纯的函数去交互
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(..)
:虽然鼓励使用纯函数,但不可避免地将列表简化为true
或false
的值,本质上就像搜索和匹配
映射 map(...)
映射:将一个值转换为另一个值(从一个地方映射到另一个新的地方)
1 | function map(mapperFn, arr) { |
这里 mapperFn(..)
的参数和原生一致是三个,如果希望只传第一个进去,可以用之前介绍过的 unary(..)
map(..)
使得可以方便的做列表的链式操作
map(..)
的每个转换应该独立与其他的转换,没有从左往右的执行顺序
函子是采用运算函数有效用操作的值
函子在每个单独的值上执行操作函数,函子实用函数创建的新值是所有单个操作函数执行结果的组合
map(..)
函数采用关联值(数组)和映射函数(操作函数),并为数组中的每一个独立元素执行映射函数。最后,它返回由所有新映射值组成的新数组
过滤 filter(..)
filter(..)
中那个返回 true/false
来做决定(每一项在新数组中保留还是剔除)的函数有一个特别的称谓:谓词函数
1 | function filter(predicateFn, arr) { |
为了解决语义问题(传入的谓词函数命名问题),我们定义
1 | var filterFn = filter |
缩减 reduce(..)
= 组合/缩减/折叠 = 将两个值合并成一个值
1 | function reduce(reducerFn, initialValue, arr) { |
我们可以将 reduce(..)
认为是将函数从左到右组合(just like pipe(..)
),可以这样使用
1 | var binary = fn => (arg1, arg2) => fn(arg1, arg2) |
JS 还提供了 reduceRight(..)
map 也是 reduce
1 | var double = v => v * 2 |
filter 也是 reduce
1 | var isOdd = v => v % 2 == 1 |
去重
1 | // 使用 indexOf 筛选时,是从左向右 |
1 | // 当列表项不能在新列表中找到时,将其插入到新的列表中 |
扁平化
1 | var flatten = |
flatten(..)
的常用用法之一是当你映射一组元素列表,并将每一项值从原来的值转换为数组(如果不想要返回的二维数组)
Zip
交替选择两个输入只能列表的值,并将得到的值组成子列表
1 | function zip(arr1, arr2) { |
这个实现明显存在一些非函数式编程的思想。这里有一个命令式的
while
循环并且采用shift()
和push(..)
改变列表。虽然在纯函数中使用了非纯的行为(通常是为了性能),但只要其产生的副作用完全包含在这个函数内部,这种实现就是安全纯净的
合并
1 | function mergeLists(arr1, arr2) { |
编程风格
JS 中通常使用链式调用,而函数式编程喜欢嵌套式的调用,但是用于 compose(..)
都不太合适…
链式组合方法
1 | var partialThis = |
独立组合实用函数
1 | var filter = (arr, predicateFn) => arr.filter(predicateFn) |
发现上面的定义都很套路!都是派发相应的原生数组方法,那不就可以再写个函数做这件事!
1 | var unboundMethod = |
当然咧,也有办法把我们的独立函数(上面定义的 去重 扁平 zip 等等)变成可以链式调用的数组方法(当然不是直接改 Array.prototype
!)。一个思路是把独立函数适配成一个缩减函数,然后传给 reduce(..)
1 | // 原独立函数的定义 |
条件确保实用函数
1 | var guard = |
链式组合风格:声明式的,很容易看出详尽的执行步骤和顺序;但每一个列表操作都需要循环整个列表,意味着不必要的性能损失
1 | someList |
交替独立风格:操作自下而上列出,同样会多次循环列表
1 | map( |
融合:合并相邻的操作,从而减少列表的迭代次数
一个简单的例子,对于连续的 map(..)
链式,可以很容易想到,把几个内层函数 compose(..)
or pipe(..)
起来,从而只需遍历一次列表
递归
递归深谙函数式编程之精髓,最被广泛引证的原因是,在调用栈中,递归把(大部分)显式状态跟踪换为了隐式状态。通常,但问题需要条件分支和回溯计算时,递归非常有用,此外在纯迭代环境中管理这种状态,是相当棘手的;最起码,这些代码是不可或缺且晦涩难懂。但是在堆栈上调用每一级的分支作为其自己的作用域,很明显,这通常会影响到代码的可读性。
正如 Σ 是为运算而声明(符号是数学的声明式语言!),递归是为算法而声明
尾调用
如果一个回调从函数 baz()
转到函数 bar()
的时候,而回调是在函数 baz()
的最底部执行——也就是尾调用——那么 baz()
的堆栈帧就不再需要了。也就意味着,内存可以被回收,或只需简单的执行 bar()
函数
正确的尾调用(PTC):由 ES6 明确规定的尾调用特定形式,只要正确的使用了尾调用就不会发生栈溢出。PTC 长下面这样:函数调用在最后一步,并且必须有返回
1 | // PTC |
如果你的递归比较复杂,不是尾递归(比如二分法),就需要想办法重构递归:
更换堆栈
1 | // 一个不符合 PTC 规范的例子 |
重构策略的关键点在于,我们可以通过把置后处理累加改为提前处理,来消除对堆栈的依赖,然后将该部分结果作为参数传递到递归调用。换句话说,我们不用在当前运用函数的堆栈帧中保留
num1+sum(..num1)
的总和,而是把它传递到下一个递归的堆栈帧中,这样就能释放当前递归的堆栈帧。
也就是说,我们可以把 sum 计算的结果作为参数传进 sum:
1 | function sum(result, num1, ...nums) { |
但是调用者需要在调用时额外传一个 result = 0
进去!通常的处理是再用一个函数包裹它,对外暴露一个接口函数(各种方法实现,平级函数/内部函数/IIFE 包裹等等)。但是这样可读性已经明显降低…
1 | function sum(num1, num2, ...nums) { |
另一个例子
1 | function maxEven(num1, ...restNums) { |
后继传递格式
后继传递格式(CPS):组织代码,使得每个函数在其结束时接受另一个执行函数
比如进行相互递归的这个例子
1 | function fib_(n) { |
弹簧床
1 | function trampoline(fn) { |
异步的函数式
异步编程最为重要的一点是通过抽象时间来简化状态变化的管理。
promise 以时间无关的方式来作为一个单一的值。此外,获取 promise 的返回值是异步的,但却是通过同步的方法来赋值。或者说,promise 给 =
操作符扩展随时间动态赋值的功能,通过可靠的(时间无关)方式。
惰性数据结构 懒操作
observables(RxJS Most)
- 数组的
map(..)
方法会用当前数组中的每一个值运行一次映射函数,然后放到返回的数组里(积极的数据结构) - observable 数组里则是为每一个值运行一次映射函数,无论这个值何时加入,然后把它返回到 observable 里(持续惰性的)
Transducing
transduer 就是可组合的 reducer,也就是可以 compose 一系列 reduce 操作,避免反复遍历列表(而 map filter 等可以转成 reduce)
推导过程如下:
1 | // 例子 |
最后的一些讨论:
- 我们的
listCombination(..)
内部用了纯的concat(..)
,但是这样性能肯定不好。是不是可以直接换成性能更好但不纯的push(..)
呢?答案是可以。因为我们知道listCombination(..)
只会在 transducing 内部使用,没有违反对外是纯函数这一准则,内部可以为了性能而变得不纯 - 如果两个“形状”不一样的组合函数呢?
1 | // 我们可以“组合”这两个 reduce 吗? |
Monad
函子(functor):包括一个值和一个用来对构成函子对数据执行操作的类 map 实用函数。
Monad:一个包含一些额外行为的函子。它更像是一种根据不同值的需要而用不同方式实现的接口,每一种实现都是一种不同类型的 Monad。Monad 是一个用更具有声明式的方式围绕一个值来组织行为的方法。
Maybe
如果一个值是非空的,它是 Just(..)
的实例;如果该值是空的,它则是 Nothing()
的实例
Maybe Monad 的价值在于不论我们有 Just(..)
实例还是 Nothing(..)
实例,我们使用的方法都是一样的。Maybe 这个抽象概念的作用是隐式地封装了操作和无操作的二元性
1 | var Maybe = { |
Monad 的核心思想是,它必须对所有值都是有效的,不能对值做任何检查——甚至是空值检查
1 | // 在外部进行空值检查的例子 |
Humble
是一个产生 Maybe Monad 实例的工厂函数,可以加入各种条件判断…