翻书《JavaScript函数式编程指南》

公司建立图书角,买了一本《JavaScript函数式编程指南》,我拿到手,打算读一读。坦诚说,这书不咋地,看不懂,这次艰难做个笔记。

2020-03-25 快速翻完了,希望这是第一次。后面的完全看不懂。

2020-03-26 我打算重新组织结构,采用问答形式,希望能够更清楚、条理。

属性
书名 JavaScript函数式编程指南
作者 美 路易斯 阿泰西奥 欧阳继超翻译
出版社 人民邮电出版社
出版时间 2018-06
ISBN 9787115462046
豆瓣 https://book.douban.com/subject/30283769/

Q:什么是好的代码,是否有标准?如何做到?

A: 有标准:

  • 可拓展性。是否需要不断重构代码来支持额外的功能
  • 易模块化。如果改了一个文件,另一个文件会不会受到影响
  • 可重用性。是否有很多重复的代码
  • 可测性。给函数添加单元测试是否很麻烦
  • 已推理。代码是否非结构化严重难以推理

可以考虑用 函数式编程(Functional Progamming 简称fp)来做到这一点。

FP 概念

Q: 什么是 fp?

A: fp是一种思考问题的方式。如果习惯了基本原则,解决问题会变得很直观。fp是一种强调以函数使用为主的软件开发风格。通过使用函数来控制数据操作,减少副作用,减少对状态的改变

oop 透过封装让代码易懂。fp通过最小化变化让代码易懂

Q: 关于fp能不能举两个实际应用?

A:可以。

【demo1】jsWeb里,你可能会写出这样的代码

1
document.querySelector('#app').innerHTML = '<span>aaa</span>'

注意这里的代码都是写死的,不能动态显示消息,如果想改变信息格式、内容、DOM元素,都得重写,显然不够好。你可能考虑写一个function,提取 #app, span,aaa 这三样东西。

1
printMessage('#app','span','aaa') // 代码就不写了

这样还不够好,更进一步,fp会怎么处理?

1
2
var printMessage = run(addToDOM('#app'),span,echo)
printMessage('aaa')

这样的代码,span,addToDOM,echo 都是函数,看起来用一个较小的函数构建了一个新函数。这个run函数很神奇,把echo返回的结果传递给span,然后span再传递给addToDOM里。

如果不插入dom,改成console打印了,还要打印两遍怎么办?

1
2
var printMessage = run(console.log, repeat(3), h2, echo)
printMessage('repeat aaa')

这样不用重写内部逻辑,直接改改就能用,这个fp是背后的思想。

【demo2】求数组平方。

命令式代码会使用for循环。但for循环不好:循环,很难重用,很难出入到其他操作中。

我们可以对数组使用map方法来完成这件事。如果后续要带动,只需要改动高阶函数里传入的参数。这个例子后续还会用到。

Q: fp有什么特性?有什么特点或者准线,fp要达到什么标准?

A:fp里有几个【基本概念】:

  • 声明式编程
  • 纯函数
  • 引用透明
  • 不可变性

一些以这个为标准。

声明式编程

Q: 什么是声明式编程?

A: 之前数组求平方,for循环是命令式或者过程式——具体明确地告诉计算机如何执行任务,如何循环等,使用了map就是声明式,它关注每一个元素行为,把循环外包给了其他部分。
所以声明式。把程序的描述和求值分离开,关注如何用表达式描述逻辑。

纯函数

Q: 什么是纯函数?
A: 要使用没有副作用和状态变化的函数,也就是纯函数。

Q: 怎么看这个函数纯不纯?定义和标准是什么?举例子。
A:两个标准:

  • 只依赖输入的值,不依靠执行代码时候的隐藏状态或外部状态
  • 不改作用域,不动全局对象

举个例子:

【demo1】这个函数就不纯。

1
2
3
4
var a = 0
funct add(){
return ++a
}

为什么?因为它用了外部的值,还动了作用域之外的值。

【demo2】Date.now() 也不纯,输出不可预测,它依赖时间这个环境。

【demo3】 arr.slice arr.splice

有了副作用就不纯。

Q:纯函数有啥优缺点?

A:优点。可以缓存值。比如多次求 sin(1) 后续复用缓存。

缺点。容易硬解码,比如求这个人是不是大于十八岁。18这个标准怎么传入?

1
2
3
4
var min = 18
var checkAge = age => age > min
// pure
var checkAge = age=> age > 18

但这样硬解码不好。扩展性差,可以用柯里化来解决。先不关注柯里化,先看代码

1
2
var checkAge = min => (age => age>min)
checkAge(18)(20)

Q:哪些行为会有副作用?

A: 很多:

  • 改变全局的值
  • 动了原始值
  • 处理用户输入
  • 抛出不被当前函数捕获的异常
  • 打印console
  • 查询DOM,或者cookie,或者异步请求

看到最后一点是不是皱了眉头?只是用纯函数可能难成大器,但关注的是思想。也左右办法来解决它。

Q: 写纯函数,有木有指导思想?

A: 把握核心

  • 把长函数分离成单一职责的短函数

  • 明确依赖,定义成函数参数,减少副作用的数量

    柯里化是个好办法。

柯里化

Q: 什么是柯里化?

A:给函数传递一部分参数,得到一个新函数。这个新函数对参数进行缓存,可以继续接收剩余的参数。

比如刚才提到的是否满足18岁,避免对外部环境的引用。

1
2
3
var checkAge = min => (age => age > min)
var checkAge18 = checkage(18)
checkAge18(20)

柯里化函数本事是部分应用的函数。灵活利用闭包。

js里,定义一个函数 f(a,b,c),但是只传入a是允许的,bc会设置为 undefined

柯里化要求所有参数明确定义,使用部分参数调用时候,返回新函数,在真正运行之前等待外部提供其余参数。

人不满不发车。

1
2
3
4
5
6
7
8
9
function curry2(fn){
return function(firstArg){
return function(secondArg){
return fn(firstArg, secondArg)
}
}
}
// ...
curry()()()

还有一个经典的案例:

1
2
3
4
5
6
7
function foo(p1,p2){
this.val =p1+p2
}
var bar = foo.bind(null,'p1')
var baz = new bar('p2')

console.log(baz.val)

啥是 Ramda.js 拥有众多可用于连接函数式程序的有用函数,并且对fp编码风格提供支持。用它,可以容易实现参数柯里化、惰性应用和函数组合

Q: 柯里化的优缺点?

A:

1
2
3
4
5
import {curry} from 'lodash'
var match = curry((reg,str) => str.match(reg))
var filter = curry((f, arr) => arr.filter(f));
var haveSpace = match(/\s+/g)
filter(haveSpace)(['a', 'hello world'])

对函数参数的缓存,预加载。

略过一部分,柯里化有啥实际引用?

  • 仿真函数接口。
  • 实现可重用模块化函数模板

神奇的curry函数


Q:什么是引用透明?

A:如果对于相同的输入一直都是一个输出结果,那么就是引用透明。刚才提到的 ++a Date.now 显然就不是引用透明了。

Q: 为啥要追求这种相同输入总是相同输出呢?

A:因为纯,也就意味着因为稳定,方便测试,方便推理。

Q:什么是可置换性?

A:举个例子:

1
2
run(add,add,add)
run(0) //3

中间想变,随便,结果可推到,允许你随便置换。这就是可置换性。

Q:什么是不可变?

A:基本类型String Number啥的是不可变的,但数组对象是可变的。

1
2
var arr = [1,3,2]
arr.sort((a,b)=> b-a)

这个思路不好,它改变了原始值,arr会变。我们追求不可变,比如 map filter 方法,它不动元数据。

Q: 最后再问一次啥是fp?

A:fp是为了创建不可变的程序,通过消除副作用,用纯函数来求值的过程。

Q:fp有啥好处?

A:这种声明式的代码,用到是没有副作用的纯函数,你不用考虑内部实现,专注写业务代码即可。优化时候关注纯函数内部伤实现。

好处如下:

  • 拆解。鼓励拆解,单一职责。体会纯度和引用透明。
  • 流式调用
  • 响应式范式降低事件驱动代码的复杂性

组合函数

Q: 什么是组合函数?

A: 懂了纯函数和柯里化,容易写出这种洋葱代码,一层层包裹:f(h(j(k)))。为了解决函数嵌套的问题,用到了函数组合。我们把代码拉平。变为这种形式 compose(a,b,c) 也可以 compose(a,compose(b,c))

之前提到的run函数:

1
run(addToDOM('#app'),span,echo)

链式调用。

1
2
3
4
5
_.chain(enrollment)
.fileter(student=>student.enrolled>1)
.pluck('grade')
.avaerage()
.value()

那如果优化用户输入、异步请求、fs或数据库基于异步或者事件驱动的交互?

第一次提到RxJS响应式编程。响应式范式的好处:

  • 提高代码抽象级别,专注具体的业务路基
  • 充分利用fp的函数链和组合的优势

学习响应式编程的第一部分就是学习fp,观察者模式

举个例子,用户输入手机号,自动判断是否合规

1
2
3
4
5
6
7
8
9
var valid = false
var ele = $(".a")[0]
ele.onkeup=function(){
// 取值
// 判断非空
// trim
// 判断长度
valid = true
}

命令式编程一开始就很复杂,响应式编程

1
2
3
4
5
6
Rx.Observable.fromEvent(document.querySelector('#xx'), keyup)
.map(input=> input.secElement.value)
.filter(ssn=>ssn!==null&&ssn.length!==0)
.map(ssn=>ssn.replace())
.skipWhile(ssn=>ssn.length!==9)
.subscribe(valid=>{})

这些有啥特点?

都被抽象了。所有的操作都是完全不可变的。

一旦有了fp的思维,自然地会这样写。这也就是 函数式响应式编程FRP。

惰性求值、惰性函数

Q: 什么是惰性?

A:指令式代码,必须按顺序执行。封装ajax。每次都要判断if,比如是否支持 xhr是否生效。惰性,不做相关的判断。判断了赋值。 先判断xhr是否存在,做响应处理 – 把 ajax 直接等于 xhr

高阶JavaScript

本章内容:

  • js适合fp
  • js多范式开发
  • 不可变和变化的对策
  • 高阶函数和一等函数
  • 闭包和作用域
  • 闭包的实际使用

这一周快速过一遍js的内容,重点也会关注js的特性和不足。

fp和oop

js是面向对象的,但不具备java这样的典型继承。es6里有了calss和extends语法糖,但隐藏了js的原型机制的真是行为。

oop大多是命令式的,依赖基于对象的封装,保护自身和继承可变的完整性,通过实例方法来暴露和修改状态。高内聚。

fp,不需要对调用者隐藏数据,使用简单的数据类型。一切不可变,对象也可以用。数据和行为低耦合。

现在思考 fp vs oop:

  • 组合单元
  • 编程风格
  • 数据和行为
  • 状态管理。对象视为不可变的值;主张通过实例方法改变对象
  • 程序流控制。函数与递归;循环与条件
  • 线程安全。可并发编程。难以实现
  • 封装性。一切不可变没必要。需要保护数据的完整性

一般是混合两种。

管理js对象的状态。js高度动态,属性可以随意修改。

把对象视为数值,如何做,java里有final。

es6之后有了const保证常量不可变。对象内部咋办,如何防止篡改,封装。返回对象字面量接口

1
2
3
4
5
6
7
8
9
10
11
function zipCode(code, location){
let _code = code;
let _location = location || ''
return {
code:function(){return _code},
location: function(){return _location},
toString:function(){return _code+'-'+_location}
}
}
const a = zipCode('1','2')
a.toString() // '1-2'

返回新副本是一种不可变的方式。值对象,还不够,还需要处理深层次的问题,Object.freeze可以把 writable 设置false来组织对象状态的改变。

但这个冻结只是浅冻结。只能循环去冻结

Lenses提供了不可变对象,消除了this的依赖

现在来看函数,函数驱动应用程序的变化。这是fp的核心。

高阶函数

Q: 什么是高阶函数?

A: 函数当参数。如果一个参数可以接受其他函数作为参数,就属于高阶函数

Q: 高阶函数举个例子

A: 求数组 [1,2] 的和。

1
2
3
4
var add = (a,b)=> a+b
var math(fun, arr)=> fun(arr[0],arr[1])

math(add,[1,2])
1
[1,2,3].map(i=>i+1)

闭包

Q: 什么是闭包?

A: 拿到你不该拿到的东西。缓存了所在上下文执行环节的词法作用域。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function zipCode(a,b){
let _a =a
let _b = b
return {
a(){
return _a
},
b(){
return _b
}
}
}
const aa = zipCode('11','22')
aa.a() // '11'

zipCode 返回的对象,还能访问作用域之外的变量。本来拿不到,现在拿到了,这就是闭包。

闭包能在函数声明过程中把环境信息和所属函数绑在一起,是基于函数声明文本位置。

能干嘛,用在高阶函数,也可用于事件处理和回调、模拟私有成员变量,还能弥补js的不足。

闭包里包含:

  • 函数的所有参数
  • 外部作用域的所有变量

闭包到底有啥用?

  • 模拟私有变量。用闭包返回一个对象,值暴露一部分细节,变量不公开,让这部分变量私有。也常用来管理全局命名空间。使用IIFE封装内部变量,减少全局引用。很多类库都这么封装,给你想看的,还收口减少全局污染
  • 异步请求。
  • 创建人工块作用域变量。

理解程序的控制流

fp多使用易简单拓扑连接的独立黑盒操作而形成较小结构化控制流,提升程序的抽象层次。

1
a -- b -- c --d

信息在一个操作和下一个操作独立流动。高阶抽象让分支和迭代明显减少,或者被消除。

这里面可没哟 if..else for

这种链式操作,让程序简洁、流程,让代码和数据容易推理。

函数链

在java里,有一堆继承基础List的各种实体List类,比如 ArrayList LinkedList DoublyLinkedList等。。。(真的假的?这么多?)都源自共同的父类,各自添加一些特定的功能。

fp不同,它不创建全新的数据结构类型,而是使用数组普通类型,是加到高阶操作上,这些操作对底层不可见,高阶函数,代替循环。

箭头函数,使用简洁的语法声明一个匿名函数。fp鼓励使用高阶函数和箭头配合。

这里用 lodash来做演示

先看 _.map

1
_.map([1,2,3], s=> s+=1)

这个最简单了。最大的特点,不用再处理循环、作用域问题了,而且是不可变的。

js的数组有 reverse 方法,这个忘记吧,它会改变原数组,lodash提供了一个

1
2
#_(arr).reverse().map(i=>(i+=1))
_.reverse([1,2,3])

然后是 reduce

1
2
[1,2,3].reduce((current,total)=>current+total) // 6
_([1,2,3]).reduce(_.add) // 6

reduce 没法短路,入股想校验,有 _.some _.isUndefined _.isNull 等函数来验证

还有 _.every _.filter

声明式惰性计算函数链。

加入这里有一张Person表, id, firstName, lastName, Country, BirthYear

sql里可能这么写

1
2
3
select p.firstName, p.birthYear from Person p
where p.birthYear > 1903 and p.country IS NOT 'us'
group by p.firsetNaem, p.birthYear
1
2
3
4
5
6
7
8
9
10
11
_.mixin({
'select': _.pluck,
'from': _.chain,
'where': _.filter,
'groupBy':'_.sortByOrder'
})
_.from(persons)
.where(p=>p.birthYear>1900&&p.address.contry!=='us')
.groupBy()
.select()
.value()

递归

Q: 什么是递归?

A: fp里一种替换循环的方式。递归,包含两个主要部分:

  • 终止条件
  • 递归条件

Q: 递归缺点?如何避免?

A: 如果永远无法满足结束条件,会堆栈溢出。递归需要保存大量的调用记录,容易发生爆栈。

可以使用尾递归优化。

使用惰性请求推迟执行。怎么做:避免不必要的计算,使用函数式类库。

Q: 什么是尾调用?

A: 最后一行调用其他函数并返回

Q: 什么是尾递归?

A: 函数运行的最后一步是否调用自身。相关信息会跟随进入下一个栈,不在堆栈上保存,能有效防止堆栈溢出。

1
2
3
4
5
6
sum(5,0)
sum(4,5)
sum(3,9)
sum(2,12)
sum(1,14)
15

Q: 什么是尾递归优化?

A: es6提供了尾调用优化的优化。让递归和迭代的性能更加接近。

答案是浏览器并没实现尾递归优化。浏览器还是创建了5个。没啥不实现,不太好调试错误。

也可以强制开启,兼容性不好 return continue !return #function()

也可以少写递归,用while

看一个普通的递归:求 3!=3*2*1 默认的话可能是这样的代码

1
const f = n => (n==1)?1:(n*(f(n-1)))

最后的的表达式是 n*f(n-1)) ,这个不是尾递归,最后一个步骤一定要是递归,才会在运行时候TCO机制才会把f编程一个循环,如何改?

1
const f = (n,current=1)=> (n===1)?current:f(n-1,n*current)
1
2
3
4
5
6
function sum(arr,acc=0){
if(_.isEmpty(arr)){
return 0
}
return sum(_.rest(arr), acc+_.first(arr)) // 发生在尾部的递归调用
}

如果函数最后一件事是递,运行时候会认为不必保持当前的战阵,因为所有工作已经完成,可以抛弃当前帧,也就是说,递归每次创建新的帧,会回收旧的,不会出现新的叠旧的。

现在的话和循环一样了。性能接近for循环,es6有尾递归优化。


模块化且可重用的代码

本章内容:

  • 函数链和函数管道的比较
  • ramda.js 库
  • 柯里化 部分应用partial application 函数绑定
  • 利用函数式组合构建模块化程序
  • 利用函数式组合增强程序的控制流

方法链和函数管道的比较。

fp把管道视为构建程序的唯一方法。函数返回的类型必须和接收函数的参数类型匹配,接收函数必须声明至少一个参数才能处理上一个函数的返回值。

什么玩意?

跳过一部分,来到柯里化。已整理。

不理解跳过

log4js 一个比console.log 好的日志框架

部分应用和函数绑定

部分应用是把函数的不可变参数子集初始化为固定值 来创建更小元数函数的操作。简单说,如果一个具有五个参数的函数,给三个参数,就会得到一个 两个参数的函数

还是不理解

延迟函数绑定

组合函数管道

Point-free

Q: 什么是 point-free?

A: 把一些对象自带的方法转换成纯函数,不要命名中间变量。

1
const f = str => str.toUpperCase().split(' ')

这个例子,用到了str当中间变量,毫无意义。怎么改,名字尽量使用原名。相当于提取了常用的方法,方便组合。

1
2
3
4
5
var toUpperCase = word => word.toUpperCase()
var split = x => (str => str.split(x))

var f = compose(split(' '), toUpperCase)
f('abc def')

就意味着永远不需要再声明参数了,称为函数的points,这让代码更声明式、更简洁、更加 point-free


组合器是一些高阶函数,可以组合其他函数

比如 compose pipe indetity tap alternation sequence fork

… 懵逼 …

针对复杂应用的设计模式

本章内容:

  • 命令式处理异常方式的问题
  • 使用容器,防止访问无效数据
  • 使用Functor 的实现来做数据转换
  • 利于组合的Monad数据类型
  • 使用Monadic类型来巩固错误处理策略
  • Monadic类型的组合和交错

fp可以把错误处理做的很优雅。

错误处理

在命令式编程中,使用 try-catch 处理异常。因为需要对错误处理进行抽象,代码就不能组合到一起了。

函数式里面不应该抛出异常。fp真的不需要异常处理么,那倒不一定。

范畴与容器

两个范畴之间转换,就用到了函子Functor

如果不用if…else 或者 try ,改用什么? Functor

一个范畴是一个容器,容器里包含值和变形关系。

容器里有 value 和变形关系,那是一个普通容器。

如果容器能作用每一个值,变成能变为另一个容器中去,这个容器叫函子。

容器 ${} 是一个dom的封装,某种意义上是一种容器。

Functor 是函数调用的抽象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 这是一个普通容器,接收一个值挂载到__value 上
var Container = function(x){this.__value=x}

// fp一般约定,函子有一个of方法。新创建一个容器。为了避免像oop
// 为啥不放到 prototype 需要new
Container.of = x => new Container(x)

// 一般约定,函子的标志就是容器具有 map 方法。这个方法把容器里的每一个值映射到另一个容器
Container.prototype.map = function(f){ // f 是变形关系
return Container.of(f(this.__value))
}

Container.of(3) // 第一个容器
.map(x=>x+1) // Container(4)
.map(x => '结果是'+ x)

// redux 核心原理
// 数学思想,一堆书通过映射关系,编程另一堆数

1’38

包裹不安全的值。保证值不会被随意修改,只能通过map来访问,这里的map不仅仅是说数组。

map也可以理解为,内部改变值,只要引用透明,每次相同输入总会相同输出。

Functor 把函数应用到它包裹的值上,再把结果包裹起来。

还有一个 Monad可以简化代码中的错误处理。jQuery 可以看到DOM的Monad

使用Monad函数式处理错误。

。。。这都什么玩意。。。

Maybe Either

IO Monad Monadic链式调用和组合

缘分还没到,不看了。

坚不可摧的代码

  • fp如何改变测试
  • 命令式测试的挑战
  • QUnit测试fp代码
  • JSCheck探索属性测试
  • Blanket测量程序复杂性

单元测试就不说了

命令式代码测试缺点:

  • 很难识别或拆分成简单任务
  • 依赖共享资源,使得测试结果不一致
  • 强行预定义求值的顺序

函数式优化

使用函数式组合子避免重复计算

shortcut fusion

_.chain

实现记忆化

有效利用柯里化和记忆化。

异步事件和数据

Promise

rxjs响应式编程

行吧,快速翻完了。说一下感触:涉及了大量知识,有趣但是不浅显,2020-03-25现在的我学得很简单,希望后面再来吧。

附录

推荐 lodash 构建模块化的函数式链

ramda专门为fp涉及的工具库,有助于创建函数管道,所有数据都是不可变的,无副作用,所有函数都已经自动柯里化,方便柯里化和组合。还包含了lens,来不可变的方式写入 读取对象的属性

RxJS,响应式编程,结合观察者模式、迭代器模式、fp思想,有助于编写异步和基于事件的程序。

log4js 客户端日志记录框架。常用在企业级日志记录。

qunit js单元测试框架

sinon, stub和mock框架。

blanket js代码覆盖率工具

jscheck js测试库,

别人是咋说的

别人说fp性价比不高,投入产出比不高,一般情况下了解浅层部分即可:

轻量级的函数式编程,比如高阶函数,函数组合,闭包,柯里化,偏函数应用,递归,memoization,惰性求值等等,是必须要掌握的

请我喝杯咖啡吧~