JS 函数式编程指南

JavaScript 函数式编程

第一章 走进函数式

  1. 例子
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    document.querySelector("#msg").innerHTML = '<h1>hello world</h1>';

    // 用函数封装这段代码
    function printMessage(elementId,format,message){
    document.querySelector(`#${elementId}`).innerHTML = `<${format}>${message}</${format}>`;
    }
    printMessage('msg','h1','hello world');
    // 以上仍然不是一段可复用的代码,用函数式编程如下
    var printMessage = run(addToDom('msg'),h1,echo);
    printMessage('hello world');
    // 将程序分解为多个函数,再将他们组合起来完成一系列的操作.
    // 当需求更改为在控制台打印 3 遍文本信息,就可以改为一下代码
    var printMessage = run(console.log,repeat(3),echo);
    printMessage('hello world');

函数式编程的特征: 声明式编程,纯函数,引用透明,不可变性.

  1. 声明式编程
    目前更主流的是命令式编程和面向对象编程.我们来看一个命令式的例子.假设你需要计算一个数组中所有数的平方.
    1
    2
    3
    4
    5
    var array = [0,1,2,3,4];
    for (let i = 0; i < array.length; i++) {
    array[i] = Math.pow(array[i],2);
    }
    array; // [0,1,4,9,16]

命令式编程是很具体的告诉计算机如何执行某个任务.而声明式编程是将程序的描述和求值分离开来.它关注与如何使用各种表达式来描述程序逻辑.你可以在 SQL 语句中找到声明式编程的例子.
可以将 es6 的 lambda 表达式和箭头函数将循环抽象成函数,减少代码的书写.

1
[0,1,2,3,4].map(num => Math.pow(num,2));  // [0,1,4,9,16]

为什么要去掉代码循环?因为循环是命令控制结构,很难重用,并且很难插入其他操作中.并且要尽量做到无副作用无状态变化,既纯函数.

  1. 副作用带来的问题和纯函数
    函数式编程基于一个前提,既使用纯函数构建具有不变形的程序.考虑以下函数
    1
    2
    3
    4
    var counter = 0;
    function increment(){
    return ++counter;
    }

以上函数不是一个纯函数,它在读取外部资源时会产生副作用.还有一个例子 Date.now,它的输出是不可预见和不一致的.
另一个副作用是通过 this 关键字访问实例数据时,由于 js 语言的特性,它决定了一个函数在运行时的上下文,这往往导致很难去推理代码.
以下行为都可能导致副作用:

- 改变一个全局的变量,属性,或数据结构
- 改变一个函数参数的原始值
- 处理用户输入
- 抛出异常又被当前函数捕获
- 屏幕打印或记录日志
- 查询 html 文档,浏览器数据或访问数据库

以下案例是一个命令式程序,它听过 SSN 号码找到一个学生的记录渲染到页面上.

1
2
3
4
5
6
7
8
9
function showStudent(ssn){
var student = db.get(ssn);
if(student != null){
document.querySelector(`#${elementId}`).innerHTML = `${student.ssn} - ${student.name}`;
}else{
throw new Erroe('student not found!')
}
}
showStudent('444-44-4444');

该函数副作用有: 访问外部数据 db,全局变量 elementId 可能随时会改变,直接修改可外部共享的全局资源 html,抛出的异常会导致整个程序栈回退并结束.
如何使用函数式编程应对这种情况呢? 首先将长函数分离成多个且单一的短函数,其次通过显式的将外部依赖都定义为函数参数来减少副作用.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var find = curry(function(db,id){
var obj = db.get(id);
if(obj === null){
throw new Error('object is not found')
}
return obj;
})
var csv = (student){
return `${student.ssn} - ${student.name}`
}
var append = curry(function(elementId,info){
document.querySelector(elementId).innerHTML = info;
})

// 调用以上函数
var student = run(append('#student'),csv,find(db));
student('444-44-4444');

这个程序仍然有些问题,find 函数有一个检查 null 的分支.当一个函数能够确保有相同的返回值,它使得函数的结果一致且可预测,这就是纯函数的一个特质,引用透明.

  1. 引用透明和可置换性
    如果一个函数对于相同的输入始终产生相同的结果,它就是引用透明的.
    1
    var increment = counter => counter + 1;

它不仅能使代码易于测试,还更容易推理整个程序.

  1. 储存不可变数据
    不可变数据指那些创建后不能更改的数据,js 的所有基本类型本质上不可变,但数组或对象都是可变的.
    1
    2
    3
    4
    5
    6
    7
    var sortDesc = function(arr){
    return arr.sort(function(a,b){
    return b-a;
    })
    }
    var arr = [1,2,3,4]
    sortDesc(arr);// [4,3,2,1]

乍一看这段代码正常,但是 array.sort 函数是有状态的,会导致排序过程中产生副作用,因为原始的引用被修改了.

  1. 总结
    函数式编程是指为创建不可变程序,通过消除外部可见的副作用,来对纯函数的声明式求值过程.

第二章 高阶 JavaScript

  1. 一等函数
    函数是函数式编程的工作单元和中心,函数只有在返回一个有价值的结果(而不是 null和 undefined)时才有意义.同时,我们需要区分表达式(返回一个值的函数)与语句(不返回值得函数).函数式编程完全依赖表达式,无值函数在函数式编程下没有意义.
    在 js 中,任何函数都是 Function 类型的一个实例,函数 length 属性可以获取形参的长度,apply 和 call 可以调用函数并加入上下文,不同的是 apply 函数接收一个参数数组,而 call 接收一系列参数.但函数式编程不建议这样做,因为它永远不会依赖于函数的上下文状态.
1
2
3
4
5
6
7
8
9
10
11
// 创建一个函数,接受一个函数参数,并返回取反其结果的函数
function negate(func){
return function(){
return !(func.apply(null,arguments));
}
}
function isNull(val){
return val === null;
}
var isNotNull = negate(isNull);
isNotNull(null); // false
  1. 闭包和作用域
    在 js 之前闭包只存在于函数式编程语言中,js 是第一个在主流开发中应用闭包的语言.
    闭包是一种能后在函数声明过程中将环境信息和所属函数绑定在一起的数据结构.从本质上讲,闭包就是函数继承而来的作用域.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    function makeAddFunction(amount){
    function add(number){
    // add 函数可以通过词法作用域访问 amount
    return number + amount;
    }
    return add;
    }
    var addTenTo = makeAddFunction(10);
    addTenTo(1); // 11

闭包会在其声明时记住其作用域内的所有变量,并防止他们被垃圾回收机制回收.
js 的作用域分为全局作用域,函数作用域,伪块作用域.

- 全局作用域,window 或 global,会有副作用,尽量避免使用
- 函数作用域,可以嵌套,由内而外向上查找,直到全局作用域,推荐使用.
- 伪块作用域,如 for,while,id,switch 语句,with,try..catch..等.无法从块外部访问
1
2
3
4
5
6
function dowork(){
if(!myVar){
var myVar = 10;
}
console.log(myVar); // 10
}

js 有一个内部机制,将所有变量和函数提取至作用域的顶部.
es6 提供了 let,const 等关键字定义的变量不会进行提升.

  1. 闭包的实际应用

模拟私有变量
js 并没有一个 private 修饰符来限定对象中私有变量和函数的访问,我们可以使用闭包来完成.
闭包还可以管理全局的命名空间,既模块模式,它采用立即执行函数表达式IIFE,在 封装内部变量的同时,有效减少了全局引用.

1
2
3
4
5
6
7
8
9
10
11
12
// 一个模块框架的示例
var myModule = (function myModule(export){
// 给 IIFE 一个名字方便栈追踪
let _myprivateVar = ...;//无法从外部访问这个变量,但对内的方法可以访问.
export.method1 = function(){
...
}
export.method2 = function(){
...
}
return export;
}(myModule || {}));

对象 myModule 在全局作用域创建,之后传递给一个 IIFE 函数表达式并立即执行.由于 js 的函数作用域,变量_myprivateVar 和其他变量都是函数的局部变量,闭包使得返回的对象能够安全的访问模块中的所有内部属性.

异步服务端调用

js 中的函数可以作为回调函数传递给其他函数,假设需要对服务器发起一次请求,并在响应时得到通知,常用的方式就是提供一个回调函数.

1
2
3
4
5
6
getJson('/student',(student) =>{
getJson('/students/grades',
grades => processGrades(grades),
error => console.log(error)),
error => console.log(error)
})

getJson 是一个高阶函数,它接收两个回调作为参数,一个处理成功的函数,一个处理失败的函数.如果需要多次请求很容易进入回调地狱.

第三章 轻数据结构,重操作

  1. 理解程序的控制流
    程序为实现业务目标进行的路径就是控制流.命令式程序需要通过暴露所有的必要步骤才能详细的描述其控制流,这里面通常涉及大量的循环和分支以及各种变量.然而函数式程序多使用简单拓扑链接的黑盒操作组合成较小的程序化控制流,这些链接在一起的操作只是一些能够将状态传递给下一个操作的高阶函数.

    1
    opta().optb().optc()... // 链式结构
  2. 链接方法

    1
    'function programing'.substring(0,10).toLowerCase() + 'is fun';

通过一系列变换后的结果与原字符串毫无引用关系,无副作用.如果用更加函数式的写法如下:

1
concat(toLowerCase(subString('function programing',1,10)),'is fun');

这样虽然跟复合函数式的定义,但是较难阅读,需要一层层剥离外部函数,就行剥离洋葱一样.

  1. 函数链
    面向对象将继承作为代码重用的主要手段,比如在 java 中有继承与基础接口 List 的 ArrayList,LinkedLise 等.
    但在函数式编程中是使用如数组这样的普通类型并施加在一套高阶操作上,通常接收函数作为参数,减少副作用等等.

lambda 表达式,也被称为箭头函数.源自函数式编程,可以用较简介的语法声明一个匿名函数.它总是返回一个值.且能够与 map,reduce 等高阶函数配合使用.我们在接下来用 lodash 函数库来演示,它为了能够替换 underscore 采用了和它一样的 API.

1
2
// 用 map 做数据变换
_.map([1,2,3],v=> 2*v); //2,4,6

我们不需要再写循环的代码,也不用处理奇怪的作用域问题了.由于其不可变,因此输出一个全新的数组.
函数式库可以辅助我们开发,写出纯函数式的代码.
map是一个只会从左到右遍历的操作,对应重右到左遍历必须反转数组,但 js 中的 Array.reverse() 会改变原数组,所以我们可以配合 lodash 中的 reverse 配合 map 进行操作.

1
_([1,2,3]).reverse().map(v => 2*v); // 6,4,2

高阶函数 reduce 将一个数组中的元素精简为一个值,该值是每个元素累计而得.

1
_([1,2,3]).reduce( (memo,v) => memo+v,0 )

除此之外,lodash 还提供了 every,some,filter 等辅助函数.

  1. 代码推理
    函数式编程中每个函数只完成一部分功能,但组合在一起就可以解决很多问题,下面介绍一种能够连接一组函数来构建程序的方法(声明式惰性计算函数链).
    假设需要对一组姓名进行读取,去重,排序等操作,命令式代码如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    var names =['alozno church','Jaskell cjrl','Terjdf','asdfgg']

    function handleName(names){
    var result = []
    for(let i=0;i<names.length;i++){ // 遍历数组
    var n = names[i]
    if(n !== null && n !== undefined){ // 检查是否合法
    var ns = n.replace(/_/,' ').split(' ') // 规范数据
    for(let j=0;j< ns.length;j++){
    var p = ns[j] // 处理数据
    p = p.charAt(0).toUpperCase() + p.slice(1);
    ns[j] = p;
    }
    if (result.indexOf(ns.join(' '))< 0) { // 去除重复元素
    result.push(ns.join(' '))
    }
    }
    }
    result.sort(); // 数组排序
    }

用函数式代码实现如下:

1
2
3
4
5
6
7
_.chain(names)  // 初始化函数链
.filter(isValid) // 去除非法值
.map(s => s.replace(/_/,' ')) // 规范数据
.uniq() // 去重
.map(_.startCase) // 大写首字母
.sort() // 排序
.value(); // 返回封装对象的最终值

对一个对象使用 chain 方法会封装这个对象,并之后的每次方法调用都返回这个封装的对象,当完成计算使用 value()函数取得最终值.使用 chain 链式调用的好处是可以创建具有惰性计算能力的复杂程序,在调用 value()之前并不会真正的执行任何操作. 链中的每个函数都以一种不可变的方式来处理换上一个函数构建的新数组.这有助于过渡到 point-free 编程风格的理解.

类 SQL 的数据:函数即数据.

1
2
select p.firstname,p.birthYear from person where p.birthYear > 1903 and p.country IS Not 'US'
Group By p.firstname,p.birthYear

lodash 支持一种称为 mixins 的函数,可以为核心库拓展新的函数.

1
2
3
4
5
6
_.mixin({
'select' : _.pluck,
'from': _.chain,
'where': _.filter,
'groupBy': _.sortByOrder
})

应用此 mixin 对象后就可以编写类 sql 的程序

1
2
3
4
5
_.from(persons)
.where(p => p.birthYear > 1903 && p.country !== 'US')
.groupBy(['firstname','birthYear'])
.select('firstname','birthYear')
.value();

  1. 递归
    递归是一种通过将问题分解为较小的自相似问题来解决问题本身的技术,递归函数主要包含两方面,一是终止条件,二是递归条件.
    来解决一个简单的问题,对数组中所有的值进行求和.
    1
    2
    3
    4
    5
    6
    7
    //老规矩,先命令式,再函数式.
    var acc = 0;
    for(let i=0;i<nums.length;i++){
    acc += nums[i]
    }
    // 函数式
    _(nums).reduce((acc,current) => acc + current, 0);

递归和迭代是一个硬币的两面,在不可变条件下递归提供了一种更强大的迭代替代方法.纯函数式语言甚至没有标准的循环结构,如 for,while 等,因为所有循环都是递归完成的.

1
2
3
4
5
6
7
8
9
// 递归求和
function sum(arr){
if(_.isEmpty(arr)){ // 终止条件
return 0;
}
return _.first(arr) + sum(_.rest(arr)); // 递归条件
}
sum([]); // 0
sum([1,2,3])// 6

从底层看,递归调用会在栈中不断堆叠,但算法满足终止条件时,运行时会展开调用栈并执行加操作,因此所有返回语句都将被执行,递归就是通过这种机制代替循环.但是注意编译器在处理循环的优化问题是很强大的,比如 es6 带来了尾调用优化,可以使递归和迭代的性能更加接近.

1
2
3
4
5
6
function sum (arr,acc=0){
if(_.isEmpty(arr)){ // 终止条件
return 0;
}
return sum(_.rest(arr),acc+_.first(arr));//发生在尾部的递归调用
}

我们之前已经利用函数式技术解析过一些扁平化数据,比如数组.但这些操作对树形数据结构是无效的.
因为 js 没有内置的树形对象,所以需要基于节点,创建一种简单的数据结构.节点包括当前值,父节点引用,以及子节点数组的对象.
树是包含了一个根节点的递归定义的数据结构.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Tree{
constructor(root){
this._root = root;
}
static map(node,fn,tree = null){
// 使用静态方法避免与 Array.prototype.map 混淆
node.value = fn(node.value);
if(tree === null){
tree = new Tree(node);
}
if(node.hasChildren()){
_.map(node.children,function(child){
Tree.map(child,fn,tree);
})
}
return tree;
}
get root(){
return this._root;
}

}

第四章 模块化且可重用的代码

Unix 的脚本程序的编写如下

1
tr 'A-Z' 'a-z' < words.in | uniq | sort

这行代码对字符进行可一系列的变换,大小写转换,去除排序等.管道操作符 | 用于连接这些命令.

  1. 方法链接函数管道的比较
    在 Haskell 中私有一种符号::来描述函数,如下:
    1
    <function-name> :: <Input*> -> <output>

在函数式编程中,函数是输入和输出类型之间的数学映射.如 isEmpty 函数接收一个字符串并返回一个布尔值,使用该符号表示为:

1
2
3
4
// haskell 描述
isEmpty :: String -> Boolean
// js lambda 描述
const isEmpty = s => !s || !s.trim();

链式调用xxx.xxx().xxx()虽然相比命令式代码提高了可读性,但是它与方法所属对象耦合在一起,只能使用由 Lodash 提供的操作,无法将不同函数库或自定义函数链接在一起.
而管道是松散结合的有向函数序列,一个函数的输出会作为下一个函数的输入.

  1. 管道函数的兼容条件
  • 类型: 函数的返回类型必须与接收函数的参数类型相匹配.
  • 元数: 接收函数必须声明至少一个参数才能处理上一个函数的返回值.函数的参数长度和其复杂度成正比,只有一个单一参数的纯函数是最简单的,建议使用.但如何返回两个不同的值呢,函数式语言通过一个被称为元祖的类型达成.元组是不可变结构,将不同数据类型元素打包在一起,以便传递到其他函数中.如(false,'error message').但 js 并不原生的支持 tuple 类型,在 es6 的解构赋值特性下可以简明的键元祖值映射到变量中.
    1
    2
    3
    [first,last] = [false,'error message'];
    first // false
    last // error message

元祖是减少函数元数的方式之一,但还可以引入函数柯里化来实现降低元数的同时,增强代码模块化和可重用性.

  1. 柯里化的函数求值
    js 允许在确实产生的情况下对常规或非柯里化函数进行调用,js 会将缺少的参数设置为 undefined ,这或许也是 js 并不原生支持柯里化的原因.如果不设置行参,仅仅依靠 arguments 对象问题会更糟糕.
    再看柯里化函数,它要求所有参数都被明确定义,当使用部分参数调用时,它会返回一个新的函数,在真正运行之前等待外部提供剩余参数.柯里化是一种在所有参数提供之前,挂起或延迟函数执行,将多参函数转换为一元函数序列的技术.
    1
    2
    3
    4
    // 具有三个参数的柯里化定义
    curry(f) :: (a,b,c) -> f(a) -> f(b) -> f(c);

    const add = x => y => z => x + y + z;

以上代码表明,curry 是一种从函数到函数的映射,将输入(a,b,c)分解为多个分离的单参数调用.
在纯函数式语言中,柯里化是原生特性,是任何函数定义中的组成部分.由于 js 不支持自动柯里化函数,需要编写一些代码来启用它.

1
2
3
4
5
6
7
8
// 二元参数的手动柯里化
function curry2(fn){
return function(firstArg){
return function(secondArg){
return fn(firstArg,sencondArg);
}
}
}

如上所示,柯里化是一种词法作用域(闭包),其返回的函数只不过是一个接受后续参数的简单嵌套函数包装器.
像 lodash 一样,ramda.js 是一个函数式编程辅助库,之所以使用它是因为它很容易实现参数柯里化,惰性应用,和函数组合.

1
2
3
4
5
6
7
8
9
10
11
const checkType = curry2(function(typeDef,actualType){
if(R.is(typeDef,actualType)){
// 使用 ramda 中 is()检查类型信息
return actualType;
}else{
throw new TypeError('type mismatch')
}
})

checkType(String)('Curry'); // String
checkType(String)(42); // type mismatch

通过 R.curry 或 lodash 的curry 可以对任意数量参数的函数进行自动的柯里化.可以将自动柯里化想象为基于声明参数的数量而人工创建对应嵌套函数作用域的过程.

  1. 部分应用(partial 偏函数)和函数绑定
    部分应用是一种通过将函数的不可变参数子集初始化为固定值来创建更小元数函数的操作.简单说就是,如果存在一个具有五个参数的函数,给出三个参数后就会得到一个具有两个参数的函数.柯里化的函数本质上也是部分应用的函数.他们主要的区别在于参数传递的内部机制和控制.
    1
    2
    3
    4
    5
    6
    //体积计算函数的部分应用
    function volume(l) {
    return (w, h) => {
    return l * w * h
    }
    }

柯里化在每次分布调用时都会生成嵌套的一元函数,在底层函数的最终结果由这些一元函数逐步组合产生,所以可以完全控制函数求值的时间和方式.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// partial 的实现
function partial (){
let fn = this, boundArgs = Array.prototype.slice.call(arguments);
let placeholder = <<partialPlaceholderObj>> // 占位符,lodash 使用下划线对象作为占位符,其他实现使用 undefined 来表示应略过该参数
let bound = function(){
// 使用部分参数创建新函数
let position = 0,length= args.length;
let args = Array(length);
for(let i=0;i< length;i++){
args[i] = boundArgs[i] === placeholder ? arguments[position++]: boundArgs[i]
}
while(positoion < arguments.length){
args.push(arguments[positoion++])
}
return fn.apple(this,args)
}
return bound;
}

部分应用将函数的参数与一些预设值绑定(赋值),从而产生一个拥有更少参数的新函数.该函数的闭包中包含这些已经赋值的参数,在之后的调用中完全被求值.一种类似的 js 原生技术被称为函数绑定,即 Function.prototype.bind()

1
2
3
4
5
6
7
8
_.partial(finc,[params...])
// 创建一个函数,该函数会调用 func,并传入预设的参数,与 _.bind 不同的是,它不会绑定 this.
// 例子
var greet = function(greeting,name){
return greeting + ' ' + name;
}
var sayHelloTo = _.partial(greet,'hello');
sayHelloTo('fred'); // hello fred
  1. 组合函数管道
    我们来看一个例子:

    1
    2
    3
    4
    5
    6
    7
    8
    const str = `we can only see a short
    distance
    three
    `
    const explode = str => str.split(/\s+/);
    const count = arr => arr.length;
    const countWords = R.compose(count,explode);
    countWords(str); // 8

    这段程序有趣的地方在于,直到countWords被调用才会触发求值,用其名称传递的函数 explode 和 count 在组合中是静止的.这种将函数的描述和求值的行为分开正是函数式编程的强大之处.
    我们来看一下 compose 的实现:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    function compose(){
    let args = arguments;
    let start = args.length -1;
    return function(){
    let i = start;
    let result = args[start].apply(this,arguments);
    while(i --){
    result = args[i].call(this,result);
    }
    return result;
    }
    }

    使用 Ramda 这种函数库的好处就是所有函数都已经正确的柯里化,在组合函数管道时更具有通用性.
    我们注意到 compose 函数是从参数最右到最左的顺序,而unix管道符 | 是从左到右执行的.我们可以使用 compose 的镜像函数 pipe 来获得 管道符一样的效果.不必像原来那样正式的声明参数来创建新的函数,函数式鼓励这种风格,它被称为 point-free.
    point-free 使得 js 代码更接近 haskell 和 unix 的理念.柯里化能够灵活地定义一个只差最后一个参数的内联函数,这种编码风格被称为 Tacit 编程.

  2. 使用函数组合子来管理程序的控制流.
    命令式代码能够加 if-else 和 for 语句这样的过程控制机制,而函数式则不能.
    组合器是一些可以组合其他函数和组合子,作为控制逻辑运行的高阶函数.除了 compose 和 pipe,常见的组合子如下:

  • identity,意为身份,特性.它是返回与参数同值得函数.
    identity :: a -> a 它广泛用于函数数学特性的检验
  • tap,意为轻拍.它能够将无返回值的函数嵌入到函数组合中,而无需创建其他代码.
    tap:: (a -> *) -> a -> a该函数接受一个输入对象a 和一个对 a 执行操作的函数,使用提供的对象调用给定的函数,然后在返回该对象.
  • alternation,alt 组合子又叫 OR 组合子,能够在提供函数响应的默认行为时执行简单的条件逻辑.

    1
    2
    3
    4
    5
    const alt = function(func1,func2){
    return function(val){
    return func1(val) || func2(val)
    }
    }
  • sequence,seq 组合子用于遍历函数序列,它以两个或以上的函数作为参数并返回一个新函数,会用相同的值顺序调用这些函数.实现如下:

    1
    2
    3
    4
    5
    6
    const seq = function(){
    const funcs = Array.prototype.slice.call(arguments);
    return function(val){
    funcs.forEach(fn => fn(val))
    }
    }

seq 不会返回任何值,只会一个一个的执行一系列操作.

  • fork(join) 组合子
    fork 用于需要以两中不同的方式处理单个资源的情况,该组合子需要以单个函数作为参数,即以一个 join 函数和两个 fork 函数来处理提供的输入,两个分叉函数的结果传递给join 函数.

第五章 针对复杂应用的设计模式

  1. 命令式错误处理的不足
    在命令式编程中,异常都是通过 try-catch 处理的.将可能出现问题的代码放在 try 代码块中,通过 catch 捕获异常.但是,这样的代码将不能组合或连在一起,这将严重影响代码设计.
    函数式程序不应抛出异常,因为抛出异常会导致难以与其他函数组合,违反了引用透明原则,会引起副作用.

  2. 一种更好的解决方案Functor(函子).
    通常我们在判断 null 和 undefined 时会写啰嗦且重复的的判断代码.函数式以一种完全不同的方法应对软件系统的错误处理,其思想就是创建一个安全的容器来存放危险代码.
    functor 和 map 很类似,它会首先打开容器,应用函数到值,最后把返回的值包裹到一个新的同类型容器中.这种函数类型被称为 functor.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // 用 functor 完成 2 + 3 = 5
    const plus = R.curry((a,b) => a+b);
    const plus3 = plus(3);
    // 将 2 放到warp容器中
    const two = warp(2);
    // 调用 functor 把 plus3 映射到容器上
    const five = two.fmap(plus3); // Warpper(5) 返回一个具有上下文包裹的值
    five.map(R.identity) ; // 5
    // fmap 函数返回同类型的类型,可以链式调用
    two.fmap(plus3).fmap(R.tap(infoLogger)); // 在控制台打印以下信息

    functor 是无副作用且可组合的,其实际目的只是创建一个上下文或一个抽象,以便可以安全的应用操作到值而不改变原始值.
    函子是函数编程中最重要的数据类型,也是基本的运算单位和功能单元.一般约定,函子的标志就是容器拥有 map 方法,该方法将容器中的每一个值映射到另一个容器.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    class Functor{
    constructor(val){
    this.val = val;
    }
    map(f){
    return new Functor(f(this.val))
    }
    }

    (new Functor(2)).map(function(two){
    return two + 3;
    }) // Functor(5)

还有一个更具体化的函数式数据类型 Monad,可以将电话代码中的错误处理,更流畅的进行函数组合,其实 Monad 就是 functor “伸入” 的容器.我们曾经写个这样的 jQuery 代码$("#student").fadeIn(3000).text(student.fullname()).jQuery 可以很安全的将 fadeIn 和 text 行为应用到 DOM 上,如果 student 的 id 不存在,方法会应用到空的 jQuery 对象上且什么也不发生,也不会抛出任何异常.Monad 在于安全的传送错误,这样代码才有较好的容错性.

  1. Monad 函数式的出路错误
    我们来了解一下 functor 的局限性,当把两个 warp 包裹函数组合在一起的时候需要用两次 R.identity 函数来提取值,如果层数再多的话,monad 是更好的解决方案.

假设有一个函数half: number -> number,

1
Warpper(2).fmap(half); // warpper(1)

functor只管应用到值并将结果包裹起来,并不能加额外的逻辑,如果想限制 half 只应用到偶数,而输入是一个奇数该怎么办.

1
2
3
const isEven = n => Number.isFinite(n) && (n%2 == 0);
const half = (val) => isEven(val) ? Wrap(val/2) : empty();
// half 如果是一个奇数则返回一个空的容器

Monad 用于创建一个带有一定规则的容器,而 Functor 不需要了解其容器内的值.使用 Monadic 类型需要了解以下定义:

  • 创建 Monadic 类型(类似于 Warpper的构造函数)
  • unit 函数,可将特点类型的值放入 Monadic 结构中,类似于 empty 函数.
  • bind 函数,可以链式操作,functor的 fmap
  • join 函数,将两层 monadic 结构合并为一层.用于逐层扁平化嵌套结构,无需多次提取.
    Monad 函数的fmap 函数也叫 flatmap 函数,在大多数函数库里 flatmap 叫做 chain .

下面来看丰富的 Monad 实例,maybe,Either 和 IO.
函数式编程通常使用 maybe 和 either 来隔离不纯,合并判空逻辑,避免异常,支持函数组合,中心化逻辑提供默认值.
简单说 maybe 函子的 map 方法里设置了空值检查.
Either 一般用来提供默认值.either 函子内部有两个值,left 和 right,right 是正常情况的值.left 是 right 不存在时的默认值.总之就是 right 有值用 right,否则用 left.

1
2
3
4
5
var addOne = function(x){
return x + 1;
}
either.of(5,6).map(addOne); // either(5,7)
either.of(1,null).map(addTOne); // either(2,null)

Either 另一个用途就是代替 try…catch,使用 left 表示错误.

1
2
3
function parseJson(json){
return Either.of(null,JSON.parse(json))
}

第六章 可测试的函数式

第七章 函数式优化

  1. 函数执行机制
    js 中,每个函数调用都会在函数上下文堆栈中创建记录(帧),它负责管理函数执行以及关闭变量作用域.
    全局的上下文帧永远在堆栈的底部,函数体声明的变量越多,就需要越大的堆栈帧.
    函数柯里化过度使用会导致其占有大量的堆栈空间,进而导致程序运行速度显著降低.
    递归也会导致堆栈的溢出.因为递归时函数调用自己也会创建新的函数上下文,如果你见过range error: Maximum call stack exceeded or too much recursion就知道是递归出问题了.堆栈大小跟硬件也有关系.
    既然大量函数推入堆栈会增加程序的内存占用,为什么不避免不必要的调用呢?

  2. 使用惰性求值推迟执行
    函数式语言 Haskell 内置了惰性函数求值,惰性求值的方法有很多,但目的都是尽可能的推迟求值,直到依赖的表达式被调用.
    但是 js 使用的是更主流的函数求值策略 - 及早求值,它会在表达式绑定到变量时求值,不管结果是否用到,也称贪婪求值.

2.1 使用函数式组合子避免重复计算.alt 组合子类似于 || 运算,先计算 func1 如果返回值为 假,在调用 func2.这是避免不必要计算的简单方法,还有一个更强大的方法 memoization.
2.2 函数式编程的 shortcut fusion(意为: 捷径 融合),是一种函数级别的优化,它通过合并函数执行,并压缩计算过程中使用的临时数据结构有效降低内存占用.之所以可以这样做事因为函数式编程引用透明带来的数学和代数的正确性.
比如 compose(map(f),map(g))可以由 map(compose(f,g))完全代替.

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
const square = x => Math.pow(x,2)
const isEven = x => x%2 === 0
const numbers = _.range(200)

const result = _.chain(numbers)
.map(square)
.filter(isEven)
.take(3) // 仅处理前三个
.value() // [0,4,16]

// map 和 filter 可以通过 compose 融合在一起
```

2.3 记忆化 memorization
加快程序执行的方法之一就是避免计算重复值,在传统的面向对象中,通过将函数结果赋予给唯一的键值对并持久化到缓存中.
而在函数式中记忆化是一种很好的方式.它基于函数的参数创建与之对应的唯一的键,将结果存储到键上,当再次遇到相同的参数的函数时,立即返回储存的结果.
给 FUnction 添加记忆化
```js
Function.prototype.memoized = function(){
let Key = JSON.stringify(arguments);//将参数字符串化以获取当前函数调用的键值
this._chace = this.cache || {}; // 为当前函数实例创建一个内部缓存
this._chace[key] = this._chace[key] || this.apply(this,arguments)// 先试图读取缓存,通过输入判断是否计算过,找到就离开返回,没找到这开始计算
return this._chace[key]
}

Function.prototype.memoize = function(){
// 激活函数记忆化
let fn = this;
if(fn.length === 0 || fn.length>1){
return fn; // 只尝试记忆化一元函数
}
return function(){
return fn.memoized.apply(fn,arguments)
}
}

设计多个参数的函数即使是纯函数也很难缓存,因为复杂度增加了,柯里化是解决方案之一.
递归和尾递归优化,es6 添加的尾部调用消除,可以再递归调用时不依赖当前帧,创建一个新的帧并回收旧的帧.