[toc]
第一部分 函数式思想
第 1 章 走近函数式
- 函数式思想
- 什么是函数式编程以及为什么要进行函数式编程
- 不变性和纯函数的原则
- 函数式编程技术及其对程序设计的影响
面向对象编程通过封装变化使得代码更易理解;函数式编程通过最小化变化使得代码更易理解。
1.1 函数式编程有用吗?
- JS 缺乏管理状态原生结构,导致其在面向对象编程下,难以应付大型应用。函数式 JS 可以解决这个问题
- 函数式思维方式与面向对象的思维方式完全不同
1.2 什么是函数式编程?
- 一种强调以函数使用为主的软件开发风格
- 目标是:使用函数来抽象作用在数据之上的控制流与操作,从而在系统中消除副作用并减少对状态的改变
1.2.1 函数式编程是声明式编程
- 将程序的描述和求值分离
1.2.2 副作用带来的问题和纯函数
- 函数式编程使用纯函数构建具有不变性的程序
- 纯函数的性质
- 仅取决于提供的输入,而不依赖于任何在函数求值期间或调用间隔时可能变化的隐藏状态和外部状态
- 不会造成超出其作用域的变化,例如修改全局对象或引用传递的参数
1.2.3 引用透明和可置换性
- 引用透明:一个函数对于相同的输入始终产生相同的结果
- 可置换性:可以用纯函数产生的结果替换纯函数
1.2.4 存储不可变的数据
- 创建后不能更改的数据
函数式编程是指为创建不可变的程序,通过消除外部可见的副作用,来对纯函数的声明式的求值的过程
1.3 函数式编程的优点
- 促使将任务分解为简单的函数
- 使用流式的调用链来处理数据
- 通过响应式范式降低事件驱动代码的复杂性
1.3.1 鼓励复杂任务的分解
- 单一职责的功能单元的组合
- 组合要求函数参数数目及参数类型一致
- 高阶函数
compose
1.3.2 使用流式链来处理数据
- 链:一系列函数的调用,共享一个通用的对象返回值
- 函数链式一种惰性计算程序
1.3.3 复杂异步应用中的响应
- 响应式编程可以提高代码的抽象级别,降低异步和事件驱动带啊的复杂度
-
observable
订阅一个数据流,通过组合和链式操作来处理数据
1.4 总结
- 使用纯函数的代码不会更改或破坏全局状态,有助于提高代码的可测试性和可维护性
- 函数式编程采用声明式的风格,易于推理,提高了程序整体可读性;组合和 lambda 表达式使代码更精简
- 函数式编程将代码视为积木,通过一等高阶函数来提高代码的模块化和可重用性
- 利用响应式编程组合函数来降低事件驱动程序的复杂性
第 2 章 高阶 JavaScript
- 为什么说 JS 是适合函数式的编程语言
- JS 语言的多范型开发
- 不可变性和变化的对策
- 理解高阶函数和一等函数
- 闭包和作用域的概念探讨
- 闭包的实际使用
2.1 为什么要使用 JavaScript
- 应用广泛
- 函数式特性
- 积极改进
2.2 函数式与面向对象的程序设计
- 面向对象依赖于使用基于对象的封装来保护其自身和继承的可变状态的完整性,再通过实例方法来暴露或修改状态。对象的数据和其具体的行为以内聚的包裹的形式耦合,因此对象是抽象的核心
- 函数式不需要对调用者隐藏数据,通常使用小而简单的数据类型。数据与行为是松耦合的,使用粗粒度的方法组合,而不是细粒度的实例方法,函数是抽象的主要形式。
- 面向对象通过特定的行为将很多数据类型逻辑地连接在一起;函数式则关注如何在数据类型上通过组合来连接各种操作
- 面向对象的关键是创建继承层次结构,并将方法与数据紧密的绑定;函数式则倾向于通过广义的多态函数交叉应用于不同的数据类型,同时避免
this
-
this
给予了超出方法作用域的实例层级的数据访问能力,从而可能导致副作用 - 本质上,面向对象的继承和函数式中的组合都是为了将新的行为应用于不同的数据类型
函数式 | 面向对象 | |
---|---|---|
组合单元 | 函数 | 对象(类) |
编程风格 | 声明式 | 命令式 |
数据和行为 | 独立且松耦合的纯函数 | 与方法紧耦合的类 |
状态管理 | 将对象视为不可变的值 | 主张通过实例方法改变对象 |
程序流控制 | 函数与递归 | 循环与条件 |
线程安全 | 可并发编程 | 难以实现 |
封装性 | 因为一切不可变,所以没有必要 | 需要保护数据的完整性 |
2.2.1 管理 JavaScript 对象的状态
- 程序的状态:在任一时刻存储在所有对象之中的数据快照
- JS 对象状态安全性很差,对象是高度动态的
2.2.2 将对象视为数值
- 值对象:其相等性不依赖于标识或引用,而只基于其值,一旦声明,其状态可能不会再改变
- 值对象是一种可简单应用于面向对象和函数式编程的轻量级方式。与
const
一起使用以创建具有字符串或狮子类似语义的对象
function coordinate (lat, long) {
let _lat = lat
let _long = long
return {
translate (dx, dy) {
return coordinate(_lat + dx, _long + dy)
},
toString () { // 同纯函数,即该对象的字符串表示
return `(${_lat}, ${_long})`
}
}
}
- 发挥一个新的副本以实现不可变
2.2.3 深冻结可变部分
-
Object.freeze
通过控制writable
实现浅冻结 Object.isFrozen
- 通过递归实现深冻结
2.2.4 使用 Lenses 定位并修改对象图
- Lenses(函数式引用):访问和不可改变地操纵状态数据类型属性的解决方案
2.3 函数
- 函数分为表达式(返回一个值的函数)和语句(不返回值的函数)
- 命令式编程大多由一系列有序的语句组成
- 函数式编程完全依赖表达式
- JS 函数的两个支柱性特征:一等的和高阶的
2.3.1 一等函数
- JS 中的一等:指在语言层面讲函数视为真实的对象
2.3.2 高阶函数
- 高阶函数:可以将函数作为参数传递,也可以返回函数
- 因为函数的一等性和高阶性,JS 函数具有值的行为,即函数就是一个基于输入的尚未求值的不可变的值
2.3.3 函数调用的类型
-
this
的三种指向 - 避免使用
this
2.3.4 函数方法
- 不鼓励
call
和apply
,函数式编程不会依赖于函数的上下文状态
2.4 闭包和作用域
- 闭包是一种能够在函数声明过程中将环境信息与所属函数绑定在一起的数据结构
- 闭包是基于函数声明的文本位置的,也被称为围绕函数定义的静态作用域或词法作用域
- 本质上,闭包就是函数继承而来的作用域
- 闭包包括:函数的所有参数,外部作用域的所有变量
2.4.1 全局作用域
2.4.2 函数作用域
2.4.3 伪块作用域
-
try catch
&with
- es6 的块作用域
let
2.4.4 闭包的实际应用
- 模拟私有变量
- IIFE
- 异步服务端调用
- 回调函数
- 模拟块作用域变量
array.prototype.forEach
2.5 总结
- JS 是一种用途广泛的、具有强大面向对象和函数式编程特性的语言
- 使用不可变的实现方式可以使函数式与面向对象编程很好地结合在一起
- 一等高阶的函数使得 JS 成了函数式编程的中坚力量
- 闭包具有很多实际用途,如信息隐藏、模块化开发,并能够将参数化的行为跨数据类型地应用于粗粒度的函数之上
第二部分 函数式基础
第 3 章 轻数据结构,重操作
- 理解程序的控制流
- 更易理解的代码与数据
- 命令抽象函数 map、reduce 以及 filter
- Lodash.js 及函数链
- 递归的思考
计算过程是计算机中的一种抽象存在,在其演化的过程中,这些过程会去控制另一种被称为数据的抽象存在。
3.1 理解程序的控制流
- 控制流:程序为实现业务目标所要行进的路径
- 命令式程序通过分支和循环控制操作组成
- 声明式程序多使用以简单拓扑连接的独立黑盒操作组合而成的较小结构化控制流;使用函数式开发风格操作数据结构,其实就是将数据与控制流视为一些高级组件的简单连接
3.2 链接方法
- 方法链:能够在一个语句中调用多个方法的面向对象编程模式
3.3 函数链
3.3.1 了解 lambda 表达式
- lambda 表达式(在 JS 中也被称为箭头函数),可以用简洁的语法声明一个匿名函数
- lambda 表达式适用于函数式的函数定义,因为总是返回一个值
- 函数名代表是一种(惰性计算的)可获取值的描述,即函数名指向的是代表着如何计算该数据的箭头函数
3.3.2 用 _.map
做数据变换
- 高阶函数 map(collect)能将一个迭代函数有序地应用于一个数组中的每个元素,并返回一个长度相等的新数组
-
map(f, [e0, e1, e2...]) -> [r0, r1, r2...]
其中,f(en) = rn
3.3.3 用 _.reduce
收集结果
- 高阶函数 reduce 将一个数组中的元素精简为单一的值,由每个元素与一个累积值通过一个函数计算得出
reduce(f, [e0, e1, e2], acc) -> f(f(f(acc, e0), e1), e2)
- 不满足交换律的操作会导致不同的结果
- 会应用到所有元素,不能“短路”
3.3.4 用 _.filter
删除不需要的元素
- 高阶函数 filter(select)能遍历数组中的元素并返回一个新子集数组,其中的元素由谓词函数计算得出的布尔值结果来确定
- filter(p, [d0, d1, d2, ...]) -> [d0, d2, ...]
3.4 代码推理
- 函数式的控制流能够在不需要研究任何内部细节的条件下提供该程序以图的清晰结构
3.4.1 声明式惰性计算函数链
-
_.chain
可以添加一个输入对象的状态,将输入转换为所需的输出的操作链接在一起 -
_.chain
可以创建具有惰性计算能力的复杂程序 - 链中每个函数都以不可变的方式处理上一个函数构建的新数组
- 惰性定义函数链的好处:可读性;可优化,通过数据结构重用等优化来消除不必要的调用等
3.4.2 类 SQL 的数据:函数即数据
- 函数式编程中操作数组与查询语言类似
- 以函数的形式对数据建模,声明式地描述数据的输出是什么,而不是如何得到
3.5 学会递归地思考
3.5.1 什么是递归?
- 递归是一种通过将问题分解为较小的自相似问题来解决问题本身的技术
- 递归包含两个部分:基例(终止条件);递归条件
3.5.2 学会递归地思考
- 递归求和
- 递归调用会在栈中不断堆叠,当算法满足终止条件时,运行时就会展开调用栈并执行,因此所有返回语句都会被执行,递归通过语言运行时的这种机制代替循环
- 通过尾调用优化性能问题
3.5.3 递归定义的数据结构
- 节点是一种包含了当前值、父节点引用以及子节点数组的对象
- 树是包含了一个根节点的递归定义的数据结构
3.6 总结
- 使用 map、reduce、filter 等高阶函数编写高可扩展的代码
- 通过控制链创建控制流与数据变换明确分离的程序
- 声明式的函数式编程能构建出更易理解的程序
- 将高阶抽象映射到 SQL 语句,从而更深刻地认识数据
- 递归能够解决自相似问题,并解析递归定义的数据结构
第 4 章 模块化且可重用的代码
- 函数链与函数管道的比较
- Ramda.js 函数库
- 柯里化、部分应用和函数绑定
- 通过函数式组合构建模块化程序
- 利用函数组合子增强程序的控制流
4.1 方法链与函数管道的比较
- 了解函数作为类型映射的性质是理解如何将函数链接和管道化的关键:方法链接(紧耦合,有限的表现力);函数的管道化(松耦合,灵活)
- 方法链接通过对象的方法紧密连接;管道以函数作为组件,将函数的输入输出松散地连接
4.1.1 方法链接
- (第 3 章的例子)耦合了方法所属的对象(如 lodash 包装的对象),限制了链中可以使用的方法数量(限制了代码的表现力)
4.1.2 函数的管道化
- 管道是松散结合的有向函数序列,一个函数的输出会作为下一个函数的输入
- 被连接的函数必须在元数和类型上相互兼容
4.2 管道函数的兼容条件
- 管道是函数式编程中构建程序的唯一方法
- 函数的输入输出需要满足两个兼容条件
- 类型:函数的返回类型必须与接收函数的参数类型相匹配
- 元素:接收函数必须声明至少一个参数才能处理上一个函数的返回值
4.2.1 函数的类型兼容条件
- 鸭子类型
4.2.2 函数与元数:元组的应用
- 元数(arity):函数接收的参数的数量,也称为函数的长度
- 元组(turple):有限的、有序的元素列表。元组是不可变的结构,将不同类型的元素打包在一起,以便传递到其他函数中
- 元组应用于函数间的数据传输的优点
- 不可变的
- 避免创建临时类型
- 避免创建异构数组
- JS 实现元组
- 元组是减少函数元数的方式之一
4.3 柯里化的函数求值
- 柯里化是一种在所有参数被提供之前,挂起或“延迟”函数执行,将多参函数转换为一元函数序列的技术
curry(f) :: (a, b, c) -> f(a) -> f(b) -> f(c)
- curry 是一种函数到函数的映射
- 柯里化是一种词法作用域(闭包),其返回的函数是一个接收后续参数的简单嵌套函数包装器
- 可以实现两种流行的设计模式:仿真函数接口;实现可重用模块化函数模板
4.3.1 仿真函数工厂
- 从数据库读取、从缓存读取的例子
4.3.2 创建可重用的函数模板
- log4js 创建日志函数模板的例子
- 将多元函数转换为一元函数是柯里化的主要动机
4.4 部分应用和函数绑定
- 部分应用是一种通过将函数的不可变参数子集初始化为固定值来创建更小元数函数的操作
- 和柯里化一样,部分应用也可以用来缩短函数的长度,主要区别在于参数传递的内部机制与控制
- 柯里化在每次分步调用时都会生成嵌套的一元函数,可以完全控制函数求值的时间与方式
- 部分应用将函数的参数与一些预设值绑定(赋值),该函数的闭包中包含了已赋值的参数,在之后的调用中被完全求值
- 部分应用(
_.partial
)和函数绑定(Function.prototype.bind
)的实际用途:核心语言扩展;惰性函数绑定
4.4.1 核心语言扩展
-
_.partial
扩展原型链方法
4.4.2 延迟函数绑定
-
Function.prototype.bind
绑定setTimeout
上下文
4.5 组合函数管道
4.5.1 HTML 部件的组合
4.5.2 函数组合:描述与求值分离
- 函数组合是一种将已被分解的简单任务组织成复杂行为的整体过程
- 函数组合是第二个函数的输入直接映射到第一个函数的输出的新函数,组合后的函数也是输入和输出的引用透明映射
-
compose
的实现 - 组合的概念不限于函数,整个程序都可以由无副作用的纯的程序或模块组合而成(函数、程序、模块都是具有输入输出的可执行单元)
4.5.3 函数式库的组合
- Ramda 的 🌰
- 熟悉函数式词汇,如
head
、pluck
、zip
4.5.4 应对纯的代码和不纯的代码
- 分离纯的行为与不纯的行为,集中处理不纯的行为
4.5.5 point-free 编程
- point-free 指不必声明参数,如
R.compose(first, getName, reverse, sortByGrade, combine)
- 可以提高抽象度;会对错误处理和调试造成影响
4.6 使用函数组合子来管理程序的控制流
- 组合子是一些可以组合其他函数(或组合子),并作为控制逻辑运行的高阶函数
- 组合子通常不声明任何变量,不包含任何业务逻辑,旨在管理函数式程序的流程
- 命令式代码中的
if-else
、for
的替代方案 - 如:
compose
、pipe
、identity
、tap
、alternation
、sequence
、fork
4.6.1 identity(I 组合子)
- 返回与参数同值的函数
identity :: (a) -> a
- 函数数学特性的检验
- 为以函数为参数的更高阶函数提供数据
- 在单元测试的函数组合器控制流中作为简单的函数结果来进行断言
- 函数式地从封装类型中提取数据
4.6.2 tap(K 组合子)
- 将无返回值的函数嵌入函数组合中,而无须创建其他的代码
- 会将所属对象传入函数参数并返回该对象
tap :: (a -> *) -> a -> a
- 该函数接受一个输入对象 a 和一个对 a 执行操作的函数,调用函数并返回 a
- 可以记录调试信息
使用如下
const sayX = x => console.log('x is ' + x);
R.tap(sayX, 100); //=> 100
4.6.3 alt(OR 组合子)
- 在提供函数响应的默认行为时执行简单的条件逻辑,模拟了
if-else
const alt = R.curry((f, g, v) => f(v) || g(v))
4.6.4 seq (S 组合子)
- 用于遍历函数序列
- 以多个函数作为参数并返回一个新的函数,会用相同的值顺序调用所有函数
- 不会返回任何值,如果要将其嵌入函数,可以配合
tap
实现如下
const seq = function (...fnss) {
return function (value) {
fns.forEach(function (fn) {
fn(value)
})
}
}
4.6.4 fork(join)组合子
-
fork
用于需要以两种不同的方式处理单个资源的情况 - 以三个函数作为参数,两个分叉函数的结果最终传递到接受两个参数的 join 函数中
实现如下
const fork = function (join, f, g) {
return function (value) {
return join(f(value), g(value))
}
}
4.7 总结
- 用于连接可重用的、模块化的、组件化程序的函数链与管道
- Ramda.js 是一个功能强大的的函数库,适用于柯里化与组合
- 可以通过部分求值和柯里化来减少函数元数,利用对参数子集的部分求值将函数转化为一元函数
- 可以将任务分解为多个简单的函数,再通过组合来获得整个解决方案
- 以 point-free 的风格编写,并用函数组合子来组织的程序控制流,可解决现实问题
第 5 章 针对复杂应用的设计模式
- 命令式处理异常方式的问题
- 使用容器,以防访问无效数据
- 用 Functor 的实现来做数据转换
- 利于组合的 Monad 数据类型
- 使用 Monadic 类型来巩固错误处理策略
- Monadic 类型的组合与交错
5.1 命令式错误处理的不足
5.1.1 用 try-catch 处理错误
5.1.2 函数式程序不应抛出异常
- 抛出异常的函数存在的问题
- 难以与其他函数组合或链接
- 违反了引用透明性,因为抛出异常导致函数调用出现另一出口,所以不能确保单一的可预测的返回值
- 引起副作用,因为异常会在函数调用之外对堆栈引发不可预料的影响
- 违反非局域性原则,因为恢复异常的代码离开局部栈与环境
- 不能只关注函数的返回值,调用者需要负责声明
catch
块中的异常匹配类型来管理特定的异常 - 当有多个异常条件时会出现嵌套的异常处理块
- 某些边缘情况也需要抛出异常,但应该在一个地方抛出
5.1.3 空值(null)的检查问题
- 跟抛出异常一样带来负担
5.2 一种更好的解决方案——Functor
- 创建一个安全的容器,来存放危险代码
- try-catch 可以看做存放着会抛出异常的函数的保险箱
- 使用函数式数据时解决不纯性的主要手段
5.2.1 包裹不安全的值
- 将值包裹起来是函数式编程的一个基本设计模式,直接保证了值不会被任意篡改
- 只能通过 map 操作来访问该容器中的值,map 也是可以使用 lambda 表达式变换容器内值的途径
- Wrapper 类型
- 取值是调用函数,因此可以在 Wrapper 中处理错误
5.2.2 Functor 定义
- Functor 是一个可以将函数应用到它包裹的值上,并将结果再包裹起来的数据结构
fmap :: (A -> B) -> Wrapper(A) -> Wrapper(B)
- 返回的是同样的类型,因此可以链式调用
fmap
-
Array.prototype.map
、Array.prototype.filter
、compose
都是 Functor - Functor 的约束
- 必须是无副作用的,若映射
R.identity
可以获得上下文中相同的值,即可证明是无副作用的 - 必须是可组合的,即
fmap
函数的组合与分别fmap
函数是一样的
- 必须是无副作用的,若映射
- Functor 本身不需要知道如何处理
null
,使用 Monad 来简化错误处理 - Monad 就是 Functor “伸入”的容器
- jQuery 可以看做 DOM 的 Monad
5.3 使用 Monad 函数式地处理错误
5.3.1 Monad:从控制流到数据流
- Monad 用于创建一个带有一定规则的容器,而 Functor 并不需要了解容器内的值
- 两个概念
- Monda——为 Monadic 操作提供抽象接口
- Monadic 类型——该接口的具体实现
- 接口定义
- Monadic 类型构造函数(类似于 Wrapper 的构造函数)
- unit 函数(of 函数)——可将特定类型的值放入 Monadic 结构中
- bind 函数(类似于 Functor 的 fmap)——可以链式操作
- join 函数——将两层 Monadic 结构合并成一层
5.3.2 使用 Maybe Monad 和 Either Monad 来处理异常
-
Maybe
和Either
用途- 隔离不纯
- 合并判空逻辑
- 避免异常
- 支持函数组合
- 中心化逻辑,用于提供默认值
- 用
Mabye
合并判空-
Maybe
侧重于有效整合null
判断逻辑 - 包含两个具体字类型的空类型:
Just(value)
表示值的容器;Nothing()
表示要么没有值或者失败的附加信息 -
Maybe
显式地抽象对“可空值”(null
、undefined
)的操作,让开发者关注更重要的事
-
- 使用
Either
从故障中恢复-
Either
代表两个逻辑分离的值a
和b
,它们不会同时出现 -
Left(a)
包含一个可能的错误消息或抛出的异常对象;Right(b)
包含一个成功的值
-
5.3.3 使用 IO Monad 与外部资源交互
- JS 与对 DOM 的任何读写操作都会产生副作用
- 从应用的角度把 IO 操作当做不可变的,将 IO 操作提升到 Monadic 的链式调用中,让 Monad 主导数据流
- IO Monad 最重要的好处是将不纯分离
- 使用 Monadic 容器作为返回类型保持了函数的一致和类型的安全,也保留了引用透明性
5.4 Monadic 链式调用及组合
-
showStudent
的 🌰 - 可编程逗号
- 函数组合用于控制程序的流程,Monad 则用于控制数据流
5.5 总结
- 面向对象抛异常的机制让含糊变得不纯,把大部分责任都推到了调用者的尝试——
try-catch
逻辑上 - 把值包裹到容器中的模式是为了构建无副作用的代码,把可能不纯的变化包裹成引用透明的过程
- 使用 Functor 将函数应用到容器中值,这是无副作用地、不可变地访问和修改操作
- Monad 是函数式中用来降低应用复杂度的设计模式,通过这种模式可以将函数编排成安全的数据流程
- 交错的组合函数和 Monadic 类型是非常有弹性而强大的,如
Maybe
、Either
和IO
第三部分 函数式技能提升
第 6 章 坚不可摧的代码
- 函数式编程会如何改变测试方法
- 认识到测试命令式代码的挑战
- 使用 QUnit 测试函数式代码
- JSCheck 探索属性测试
- 使用 Blanket 测试程序的复杂性
6.1 函数式编程对单元测试的影响
- 单元测试、集成测试、验收测试
- 函数式编程是对着重于代码的软件模式,主要影响单元测试的设计
6.2 测试命令代码的困难
6.2.1 难以识别和分解任务
6.2.2 对共享资源的依赖会导致结果不一致
6.2.3 按预定义顺序执行
6.3 测试函数式代码
- 比命令式代码更好测试的本质原因是:引用透明。断言的本质就是验证其满足引用透明性。
6.3.1 把函数当做黑盒子
- 引用透明,测试顺序无关
6.3.2 专注于业务逻辑,而不是控制流
- 组合子和业务逻辑无关
6.3.3 使用 Monadic 式从不纯的代码中分离出纯函数
- 增加了可测范围
6.3.4 mock 外部依赖
- mock 上下文环境,可以设计多个期望的行为
6.4 通过属性测试制定规格说明
- 单元测试可以作为文档,因为它包含函数的运行规范
- 良好的规格说明不应该基于条件,而应该是通用的、普遍的
- 良好的规格没有副作用,也不会上下文做出假设
- 属性测试需要描述函数对某些类型的输入会得到某些类型的输出
6.5 通过代码覆盖率衡量有效性
6.5.1 衡量函数式代码测试的有效性
- 函数式程序更易测试,因为大的任务会被拆解成原子的、可验证的单元
6.5.2 衡量函数式代码的复杂性
- 函数式代码的算法复杂度更低
- 条件和循环带来复杂度,使得代码难以测试
- 圈复杂度用于衡量函数的线性独立路径的数量,从这个概念来验证边界条件,以确保所有可能路径被覆盖
M(程序的复杂性)= E(控制流的边数)- N(节点或块的数量)+ P(有退出点的节点数)
- 函数式编程通过高阶消除循环,用组合替代顺序计算,用柯里化建立更高阶抽象。但成本是性能。
6.6 总结
- 依赖于简单函数抽象的程序是模块化的
- 基于纯函数的模块化代码容易测试,还可以使用更严格的类型测试方法,如基于属性的测试
- 可测试的代码一定是简单的控制流
- 简单的控制流可以降低程序的复杂度
- 降低复杂度的代码更易推理
第 7 章 函数式优化
- 如何识别高性能的函数式代码
- JavaScript 函数执行的内部机制
- 嵌套函数的背景和递归
- 使用惰性求值优化函数调用
- 使用记忆化加速程序执行
- 使用尾递归函数展开递归调用
- 函数式编程不会加快单个函数的执行速度,它的理念是避免重复的函数调用以及延迟调用代码,把求职延迟到必要的时候,使得应用整体加速
7.1 函数执行机制
- JS 编程模型中的上下文堆栈负责管理函数执行以及关闭变量作用域,堆栈始终从全局上下文帧开始,包含所有全局变量
- 每个函数的上下文帧都占用内存,大小取决于局部变量的个数,空帧约 48 字节
- 每一帧包含
- 当前函数的 variableObject 和父执行上下文的 variableObject
- 函数对象的引用
- variableObject 包含 arguments 对象以及所有局部变量和函数
- 函数作用域链指内部函数能访问到外部函数的闭包
- 堆栈的行为由下列规则确定
- JS 是单线程的,意味着执行的同步性
- 有且只有一个全局上下文
- 函数上下文的数量是有限制的
- 每个函数调用会创建一个新的执行上下文,递归调用也如此
7.1.1 柯里化与函数上下文堆栈
- 柯里化形成嵌套结构,使用更多的堆栈
7.1.2 递归的弱点
- 递归受限于浏览器函数堆栈大小
7.2 使用惰性求值推迟执行
- 不同于 Haskell 内置惰性求值,JS 使用的是更主流的及早求值策略
- 惰性求值在依赖的表达式被调用才求值
- 及早求值在表达式绑定变量时求值,也称为贪婪求值
7.2.1 使用函数式组合子避免重复计算
- 如
alt
组合子
7.2.2 使用 shortcut fusion
- shortcut fusion 是一种函数级别的优化,通过合并函数执行,并压缩计算过程中使用的临时数据结构
- 原理是纯函数的引用透明性带来的数学与代数的正确性
- 如:
compose(filter(f), filter(g)) -> filter(x => f(x) && g(x))
7.3 实现需要时调用的策略
- 在面向对象系统中的缓存,依赖于一个全局共享的缓存对象(副作用)
7.3.1 理解记忆化
- 与缓存类似,基于函数的参数创建对应的唯一的键,并将结果值存储到对应的键上
7.3.2 记忆化计算密集型函数
- 两种记忆化的用法
- 通过调用函数对象上的方法
- 通过包裹函数
- 处理多个参数的缓存,遵循两个策略
- 创建一个多维缓存(数组的数组)
- 生成参数组合的唯一字符串密匙
7.3.3 有效利用柯里化与记忆化
- 柯里化可以将函数变为一元函数,便于缓存
7.3.4 通过分解来实现更大程度的记忆化
- 细粒度的代码更易缓存
7.3.5 记忆化递归调用
- 缓存递归子任务的结果,
100! = 100 * 99!
7.4 递归和尾递归优化
- 编译器可以帮助做尾部调用优化(TCO),也称为尾部调用消除,是 ES6 添加的编译器增强功能
- 函数的最后一件事如果是递归的函数调用,运行结束后可以抛弃当前帧,通过将函数的上下文状态作为参数传递给下一个函数调用,使递归不需要依赖当前帧,每次创建一个新的帧并回收旧的帧
-
factorial
尾递归优化
const factorial = n => (n === 1) ? 1 : n * (factorial(n - 1))
// ->
const factorial = (n, current = 1) => (n === 1) ? current : factorial(n - 1, n * current)
- 尾递归函数和循环的有共同点:基例、中间态、结果
- 尾递归性能接近
for
循环
7.5 总结
- 函数式代码可能比命令式代码更慢或更消耗内存
- 可以利用交替组合子以及函数式库来实施延迟策略
- memoization(内部函数级缓存策略)可用于避免重复对潜在费时函数进行求值
- 将程序分解成简单的函数利于扩展和记忆化
- 递归可以通过分解把问题化为更简单的自相似问题,继而充分利用记忆化优化上下文堆栈的使用
- 将函数转换为尾递归形式,可以借助编译器优化消除尾调用
第 8 章 管理异步事件以及数据
- 编写异步代码的挑战
- 通过函数式技术避免嵌套回调
- 使用 Promise 简化异步代码
- 用函数生成器惰性地生成数据
- 响应式编程
- 应用响应式编程来处理事件驱动的代码
8.1 异步代码的挑战
- 在函数之间创建时间依赖关系
- 不可避免地陷入回调金字塔
- 同步和异步代码的不兼容
8.1.1 在函数之间创建时间依赖关系
- 回调函数中的时间依赖关系
- 时间耦合或时间内聚
8.1.2 陷入回调金字塔
- 回调的控制反转机制跟函数式程序设计冲突:函数式任务函数应该彼此独立并期望立即返回结果
- 回调地狱式代码难以读懂,回调仍然保留外部引用
8.1.3 使用持续传递式样
- 持续传递式样
8.2 一等公民 Promise
- 函数式要求
- 使用组合和 point-free 编程
- 将嵌套结构扁平化为更线性的流程
- 抽象时间耦合的概念,不需要关心时间
- 将错误处理整合到单个函数
- Promise 是一种 Monad,适用于需要等待的数据
- 状态:pending、fulfilled、rejeced、settled
- Promise 构造函数接收一个包含异步操作的函数,需要两个回调(可以视为延续)
8.2.1 链接将来的方法
- then(类似于函子的 fmap)对 Promise 中返回的值应用一个操作,并将其关闭并返回 Promise,与
Maybe.map(f)
类似 -
Promise.then(f)
可以用于链接数据转换和添加函数,从而在函数之间抽象时间耦合 - Promise 隐藏异步工作流,但强调 then 的事件概念,某一步做的事情可以被轻松改变(位置透明度)
-
Promise.all
链接线性和并发的 Promies 工作流
8.2.2 组合同步和异步行为
- Promise 可以方便地用于组合
- 🌰
8.3 生成惰性数据
- ES6 的强大功能:可以暂停函数函数执行而不用一次运行完,这带来惰性生成数据的机会
- 利用生成器可以对大量对象集根据规则转换
- 通过
function*
定义,使用关键字yield
退出 - 生成函数的执行上下文可以暂停,并随后恢复
- 生成器被调用时会在内部产生一个迭代器,可以通过
for...of
迭代
8.3.1 生成器与递归
- 可以在生成器中通过
yield*
调用其他生成器,包括自身
8.3.2 迭代器协议
- 生成函数返回符合迭代器协议的
Generator
对象 - 对象实现一个
next()
方法,返回使用yield
关键字 return 的值 - 对象具有以下属性
-
done
:true
/false
迭代器是否到达序列尾部 -
value
: 迭代器返回的值
-
8.4 使用 RxJS 进行函数式和响应式编程
- Web 应用程序的性质变化,受 ajax 和更多用户交互的影响
8.4.1 数据作为 Observable 序列
- Observable 是可用订阅的数据对象
- 响应式编程使用
Rx.Observable
为数据提供统一的名为可观察的流的概念 - 流是随时间发生的有序事件的序列,要提取其值,必须先订阅它
- Rx 以完全相同的方式处理任何类型的数据,因为 Observable 可以将数据转换为流
- Observable 包装或提升任何可观察对象,然后映射和应用不同的函数,最后将其中的值转换为所需的输出
- Observable 是一个 Monad
8.4.2 函数式编程与响应式编程
- Observable 实现了最小 Monadic 接口(map、of、join)以及流操作特有的方法
- 流带来的是声明式的代码和链式计算,因此响应式编程倾向于和函数式编程一起使用,即函数式响应式编程(FRP)
- Promise 解决了函数和异步函数之间的不匹配;Observable 提供的抽象层将事件与函数式联系起来
8.4.3 RxJS 和 Promise
- RxJS 可以将 Promise/A+ 兼容的对象转换为可观察的序列
8.5 总结
- Promise 为回调驱动的设计提供了函数式的解决方案
- Promise 提供链接和组合“未来”函数的可能,抽象出时间依赖代码,并降低复杂性
- 生成器则采用另一种方法来抽象异步代码,即通过惰性迭代器可以 yield 还未准备好的数据
- 函数式响应式编程提升了抽象的层次,这样就可以将事件视为独立的逻辑单元。让开发者更专注于任务,而不是处理复杂的实现细节