Javascript 函数式编程

220 阅读22分钟

前言

函数式编程是种编程方式,它将电脑运算视为函数的计算。函数编程语言最重要的基础是λ演算(lambda calculus),而且λ演算的函数可以接受函数当作输入(参数)和输出(返回值)

一、什么是函数式编程

"函数式编程(Functional Programming, 简称:FP)"是一种"编程范式"(Programming Paradigm),也就是如何编写程序的方法论。 它属于"结构化编程"的一种,主要思想是把运算过程尽量写成一系列嵌套的函数调用,用来描述数据(函数之间的映射)。

编程范式的区别

我们比较常见的编程范式包括:面向过程编程、面向对象编程、函数式编程:

  • 面向过程的思维方式:把现实世界的事物一步一步实现,完成想要实现的功能;
  • 面向对象的思维方式:把现实世界中的事物抽象成程序世界的类和对象,通过封装、继承、和多态来演示事物和事件的联系;
  • 函数式编程的思维方式:把现实世界的事物和事物直接的联系(对运算过程进行抽象)抽象到程序世界。

二、函数是"第一等公民"

当一门编程语言的函数可以被当作变量一样用时,则称这门语言拥有头等函数(First-class Function)。

2.1 函数就是一个普通对象

在Javascript中函数就是一个普通对象,我们可以把函数存储到变量/数组中,他还可以作为另一个函数的参数和返回值,甚至我们可以在程序运行的时候通过 new Function()来构造一个新函数。

2.1.1 把函数赋值给变量
  • 把一个匿名函数赋值给一个变量,然后我们在这个变量后面加上一对圆括号 () 来调用这个函数

即使你的函数有自己的函数名称,你仍然可以用这个变量名去调用它。给函数命名只会方便你调试,但不会影响我们调用它。

// 匿名函数
const fn = function() {
   console.log("fn");
}

// 具名函数
const fooName = function test() {
  console.log('fn--name')
}

// 将函数赋值给对象方法
const fooController = {
  foo,
  controllerName: fooName
}
2.1.2 传递一个函数作为参数
function sayHello() {
  return 'Hello'
}

function greeting(fn, name) {
  console.log(fn(), name)
}

// 传递 `sayHello` 作为 `greeting` 函数的参数
greeting(sayHello, 'World!');
2.1.3 返回一个函数
function sayHello() {
   return function() {
      console.log("Hello!");
   }
}

2.2 高阶函数

高阶函数(Higher-Order Function):一个返回另外一个函数的函数被称为高阶函数。

2.2.1 高阶函数的意义

函数式编程的核心是对运算过程进行抽象,而抽象可以帮我们屏蔽细节,只需关心于我们的目标结果;而高阶函数是用来实现对通用问题对抽象。 我们来看一个例子,如果我们要实现打印数组中的偶数:

const arr = [1,2,3,4,5]

// 面向过程的实现方式
for (let i = 0; i < arr.length; i++) {
  if(arr[i] % 2 === 0) {
    console.log(arr[i])
  }
}

// 函数抽象
function forEach(arr, fn) {
  for (let i = 0; i < arr.length; i++) {
    fn.call(fn, arr[i])
  }
}

// 高阶函数实现方式-调用
forEach(arr, item => {
  console.log(item)
})

由以上例子可以看出,高阶函数的抽象过程可以帮助我们屏蔽实现细节, 关注实现目标。同时高阶函数所具有的灵活性,可以轻易实现复用。

2.3 闭包

闭包(Closure):一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包。通俗理解就是,当在一个作用域中调用外部函数并执行其内部函数时,其内部函数可以访问外部函数的作用域,这一过程称为闭包。

下面改造一下上面的sayHello函数

 function sayHello(fn) {
   const say = 'hello'
   
   return function() {
     fn.call(fn, say)
   }
 }
 
 const print = sayHello((say) => {
    console.log(say)
 })
 
 print()

执行结果:

  • 执行sayHello: closure-out.png
  • 执行print:

closure-in.png 由运行结果可以看出,闭包的实质:函数在执行时会被放到执行栈上,当函数执行完毕后,会从执行栈上移除;但是堆上的作用域成员因为被外部引用不能释放,因此内部函数在执行时依然可以访问外部函数成员。

三、函数式编程中的函数

函数式编程中的函数指的不是程序中的函数(方法),而是指的一种映射关系: x -> f(联系、映射) -> y, y = f(x); 它实际上类似一种数学函数,例如:y = cos(x)。函数式编程中称为纯函数

3.1. 纯函数的概念

相同的输入始终会得到相同的输出,而且没有任何可观察的副作用.

3.1.1. 相同的输入始终会得到相同的输出
  • 不依赖外部状态(无状态): 函数的的运行结果不依赖全局变量,this 指针,IO 操作等。

一个简单例子:

const arr = [1,2,3,4,5,6]

// 调用slice
console.log(arr.slice(0,3)) => [ 1, 2, 3 ]
console.log(arr.slice(0,3)) => [ 1, 2, 3 ]

//调用splice
console.log(arr.splice(0,3)) => [ 1, 2, 3 ]
console.log(arr.splice(0,3)) => [ 4, 5, 6 ]
console.log(arr.splice(0,3)) => []

由打印结果可以看出:

  • slice会返回数组中的指定部分,多次调用不会改变原数组,符合相同输入始终具有相同输出的特性,所以它是纯数组;
  • splice会返回数组中的指定部分的同时,对数组进行操作,会改变原数组,所以它是不纯函数;
3.1.2. 纯函数没有任何可观察的副作用
  • 没有副作用:不修改全局变量,不修改入参。

以下例子printPersonAge依赖于全局变量,changePersonName会修改全局变量,这样就会产生你预料之外的结果,这就是副作用

const person = {
  name: '韩梅梅'
}

const printPersonAge = (_age) => {
  console.log('--person--', person)
  console.log(`${person.name} is ${_age} years old!`)
}
const changePersonName = (_person, _name) => _person.name = _name

printPersonAge(18) // => 
changePersonName(person, '李雷')
printPersonAge(18) 

纯函数实现方式:

const person = {
  name: '韩梅梅'
}

const printPersonAge = (user, age) => {
  console.log('--person--', person)
  console.log(`${user.name} is ${age} years old!`)
}
const changePersonName = (user, name) => ({ ...user, name })

const newPerson = changePersonName(person, '李雷')

printPersonAge(person, 18)
printPersonAge(newPerson, 18)
printPersonAge(person, 18)

3.2. 数学函数(纯函数)的意义
  • 可缓存性(Cacheable)

因为纯函数对相同的输入始终有相同的结果,所以可以把纯函数的结果缓存起来: 例如:

const _ = require('lodash')

const add = (a, b, c) => {
  console.log('--init--add')
  return a + b + c
}

// 
const getAddWidthMemory = _.memoize(add, function() {
  return JSON.stringify(arguments)
})

console.log(getAdd(1, 2, 3))
console.log(getAdd(1, 3, 3))
console.log(getAdd(1, 4, 5))
console.log(getAdd(1, 2, 3))

// ======== console result start========== //
--init--add
6
--init--add
7
--init--add
10
6
// ======== console result end========== //

实现机制:通过上例可以看出,lodash库提供了一个memoize方法,该方法接收两个函数作为变量,并返回一个带缓存功能的新函数;

其原理实现如下:

function memoize(func, resolver) {
  if (typeof func != 'function' || (resolver != null && typeof resolver != 'function')) {
    throw new TypeError(FUNC_ERROR_TEXT);
  }
  var memoized = function() {
    var args = arguments,
        key = resolver ? resolver.apply(resolver, args) : JSON.stringify(args),
        cache = memoized.cache;

    if (cache.has(key)) {
      return cache.get(key);
    }
    var result = func.apply(func, args);
    memoized.cache = cache.set(key, result) || cache;
    return result;
  };
  memoized.cache = new Map();
  return memoized;
}
  • 可测试性(Testable):这个意义在实际项目开发中意义非常大,由于纯函数对于相同的输入永远会返回相同的结果,因此我们可以轻松断言函数的执行结果,同时也可以保证函数的优化不会影响其他代码的执行。这十分符合测试驱动开发 TDD(Test-Driven Development ) 的思想,这样产生的代码往往健壮性更强。

  • 合理性(Reasonable):使用纯函数意味着你的函数中不存在指向不明的 this,不存在对全局变量的引用,不存在对参数的修改,这些共享状态往往是绝大多数 bug 的源头

  • 并行代码(Parallel Code): 纯函数不需要访问共享的内存数据,所以在并行环境下可以运行任意多个纯函数

四、柯里化

实现一个多个数字相加的纯函数:

// 普通纯函数
const add = (a, b, c) => (a + b + c)

// 柯里化
const curryAdd = a => ((b, c) => a + b + c)
const curryUnitAdd = a => (b => (c => a + b + c))

console.log(add(1, 2, 3)) // 6
console.log(curryAdd(1)(2, 3)) // 6
console.log(curryUnitAdd(1)(2)(3)) // 6

柯里化(Currying): 是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数且返回结果的新函数的技术。

4.1. loadsh中的curry函数

4.1.1. _.curry(func)
  • 功能:创建一个函数,该函数接收一个或者多个func的参数,如果func所需要的参数都被提供则执行func并返回执行结果。否则继续返回该函数并等待接收剩余参数
  • 参数:需要柯里化的参数
  • 返回:柯里化后的参数
4.1.2. 柯里化的简单实现

柯里化函数的常用场景:

f(a,b,c) --_.curry(f) --> fn(a,b,c)
f(a,b,c) --_.curry(f) --> fn(a)(b,c)
f(a,b,c) --_.curry(f) --> fn(a,b)(c)
f(a,b,c) --_.curry(f) --> fn(a)(b)(c)

简单实现:

function curry(func) {
      return function curryFn(...arg) {
       
        if(func.length > arg.length) {
          return function () {
            return curryFn.apply(this, arg.concat(Array.prototype.slice.apply(arguments)))
          }
        }

        return func.apply(this, arg)
      }
    }
4.1.3. 柯里化的应用

通常,我们在实践中使用柯里化都是为了把某个函数变得单值化,这样可以增加函数的多样性,使其适用性更强:

案例1: 实现一个全局替换的功能:
// 柯里化
const replace = _.curry((_regx, _replace, _str) => _str.replace(_regx, _replace))
const replaceSapce = replace(/\s/g)
const replaceSpaceWidthComma = replaceSapce(',')

// 应用
const r = replaceSpaceWidthComma('hello world!')

console.log('+++r++', r)
案例2: 实现一个过滤数组中过滤筛选的功能
// 实现一个筛选数组中的带空格的字符串
const match = _.curry((_regx, _str) => _str.match(_regx))
const filter = _.curry((fn, array) => array.filter(fn))

const matchSpace = match(/\s+/g)

const filterSpace = filter(matchSpace)

// 返回数组中带空格的字符串
console.log(filterSpace(['Hello_Javascript', 'Hello world', 'Hello   Curry']))

通过上面两个案例可以看出,通过函数柯里化, 我们可以给一个函数传递较少的参数并且得到一个已经记住某些固定参数的新函数。同时,多元函数可以转换成产生很多新的一元函数,这些一元函数可以在各种场合进行使用。 必要时可以进行组合,产生功能更强大的函数。

五、函数组合

如果一个函数要经过多个函数处理才能得到最终值,这个时候可以把中间过程的函数合并成一个函数, 我们把这一过程称为: 函数组合

  • 函数就像是数据管道,函数组合就是把这些管道连接起来,让数据穿过多个管道形成最终结果
  • 函数组合默认是从右到左执行

5.1. 为什么会出现函数组合

组合看起来像是在饲养函数。你就是饲养员,选择两个有特点又遭你喜欢的函数,让它们结合,产下一个崭新的函数。组合和嵌套的用法如下:

// 获取数组的首个元素在转换成大写字母
const array = ['hello', 'world', '!']

const compose = (f,g) => ((x) => f(g(x)))
const first = array => array.slice(0, 1).join()
const toUpper = str => str.toUpperCase()

// 函数嵌套
const result = toUpper(first(array))

// 函数组合
const composeFn = compose(toUpper, first)
const result = composeFn(array)
  • 由上例可以看出,由纯函数和柯里化,我们很容易写出函数嵌套,我们称之为洋葱代码(h(g(f(x))));
  • 而通过函数组合,创建了一个从右到左的数据流,first处理完输入值array后,将处理结果流转到toUpper,最后输出最终结果。这样做的可读性远远高于嵌套一大堆的函数调用。

5.2. lodash中的组合函数

lodash 中的组合函数flow() 或者flowRight他们都可以组合多个函数:

  • flow() 是从左往右运行
function flowLeft(...args) {
 return x => args.reduce((total, fn) => fn(total), x)
}
  • flowRight()是从右往左运行,更符合函数组合的应用理念
function flowRight(...args) {
  return x => args.reverse().reduce((total, fn) => fn(total), x)
}

不同使用场景,如下:

const _ = require('lodash)

const array = ['hello', 'javascript', 'world']

const reverse = array => array.reverse()
const first = array => array.slice(0, 1).join()
const toUpper = str => str.toUpperCase()

const flow = _.flow(reverse, first, toUpper)
const flowRight = _.flowRight(toUpper, first, reverse)

console.log(flow(array), flowRight(array))

虽然lodash 提供了从左到右执行到flow函数,也提供了从右到左执行的flowRight函数。但是出于从右到左执行更能够反应数学上的含义,所以flowRight更符合函数组合的理念。

除此之外,函数组合还有一个特性-- 结合律(associativity)。

5.3. 函数组合的结合律

// 结合律(associativity)
var associative = compose(f, compose(g, h)) == compose(compose(f, g), h);
// true

符合结合律意味着不管你是把 g 和 h 分到一组,还是把 f 和 g 分到一组都不重要。所以,如果我们想把数组翻转然后首个元素变为大写,可以这么写:

_.flowRight(toUpper, _.flowRight(first, reverse)) 
//或者
_.flowRight(_.flowRight(toUpper, first), reverse)

因为如何为 compose 的调用分组不重要,所以结果都是一样的。这也让我们有能力写一个可变的组合(variadic compose),用法如下:

// 前面的例子中我们必须要写两个组合才行,但既然组合是符合结合律的,我们就可以只写一个,
// 而且想传给它多少个函数就传给它多少个,然后让它自己决定如何分组。

const lastUpper = _.flowRight(toUpper, first, reverse)

lastUpper(['one', 'two', 'three'])
//=> 'THREE'


const lastLower = _.flowRight(toLower, toUpper, first, reverse)

loudLastUpper(['one', 'two', 'Three'])
//=> 'three'

运用结合律能为我们带来强大的灵活性,结合律的一大好处是任何一个函数分组都可以被拆开来,然后再以它们自己的组合方式打包在一起。让我们来重构重构前面的例子:

const last = _.flowRight(first, reverse)
const firstUpper = _.flowRight(toUpper, first)

// 或者
const lastLower = _.flowRight(toLower, reverse)

// ...

关于如何组合,并没有标准的答案--通常来说,最佳实践是让组合可重用。

5.4. 调试组合函数

当组合函数没有得到我们想的到的结果时,我们可以使用下面这个实用的,但是不纯的trace 函数来追踪代码执行函数。

/** 
* 函数组合调试
* NEVER  GIVE UP => never-give-up
* ================================
* 常规api实现
* str.split(' ') -> array.map => toLowerCase -> join('-')
* ================================
* lodash api实现
* _.split(str, ' ') -> _.map(collect, _.toLower) -> _.join(array, '-')
*/

// 组合函数不接受管道函数有多个入参,所以借助函数柯里化实现函数改造, 传参先后根据组合函数调用顺序 
const split = _.curry((sep, str) =>_.split(str, sep))
const map =  _.curry((fn, array) => _.map(array, fn))
const join = _.curry((sep, array) => _.join(array, sep))

// 调试日志
const trace = _.curry((tag, x)=> {
 console.log(tag, x);
 return x;
});

// 函数组合
const compose = _.flowRight(trace('join--'), join('-'), trace('map--'), map(_.toLower), trace('split--'), split(' '))
// 执行
compose('NEVER GIVE UP')

// 输出
// => split-- [ 'NEVER', 'GIVE', 'UP' ]
// => map-- [ 'never', 'give', 'up' ]
// => join-- never-give-up

上例中,为了实现多元函数的组合函数,我们需要借助函数柯里化来进一步实现一元函数组合,会显得有些麻烦,所以lodash提供了用户实现函数是编程的模块:FP模块

5.5. Loadsh中的FP模块

  • lodash/fp: lodash的fp模块提供了实用的函数式编程友好的方法;同时提供了不可变auto-curried iteratee-first data-list的方法:自动柯里化函数优先数据滞后

改造上例:

/** 
 * lodash: 数据优先,函数滞后
 * _.map(['a', 'b', 'c'], _.toUpper)
 * _.map(['a', 'b', 'c'])
 * 
 * _.split('NEVER GIVE UP', ' ')
 * 
 * ================================
 * lodash/fp: 函数优先,数据滞后,自动柯里化
 * 
 * fp.map(fp.toUpper, ['a', 'b', 'c'])
 * fp.map(fp.toUpper)(['a', 'b', 'c'])
 * 
 * fp.split(' ', 'NEVER GIVE UP')
 * fp.split(' ')('NEVER GIVE UP')
 *
 */
 
// lodash/fp 实现方式
const composeFp = fp.flowRight(trace('join--'), fp.join('-'), trace('map--'), fp.map(_.toLower), trace('split--'), fp.split(' '))
composeFp('NEVER GIVE UP')
 
// 输出
// => split-- [ 'NEVER', 'GIVE', 'UP' ]
// => map-- [ 'never', 'give', 'up' ]
// => join-- never-give-up

5.6. PointFree

PointFree: 我们可以把数据处理的过程定义成与数据无关的合成运算,不需要利用到代表数据的那个参数,只要把简单的运算步骤合成在一期,在使用这种模式之前,我们需要定义一些辅助的基本运算函数。他有以下要点:

  • 不需要指明处理数据
  • 只需要合成运算过程
  • 需要定义一些辅助的基本运算函数
// 非 pointfree,因为提到了数据:word
const wordLower = word => word.toLowerCase().replace(/\s+/ig, '_')

// pointfree: 只关心合成过程,不关心入参,一种函数组合的风格
const pointWordLower = fp.flowRight(fp.replace(/\s+/ig, '_'), fp.toLower)

pointfree 模式能够帮助我们减少不必要的命名,让代码保持简洁和通用。对函数式代码来说,pointfree 是非常好的石蕊试验,因为它能告诉我们一个函数是否是接受输入返回输出的小函数。比如,while 循环是不能组合的。不过你也要警惕,pointfree 就像是一把双刃剑,有时候也能混淆视听。并非所有的函数式代码都是 pointfree 的,不过这没关系。可以使用它的时候就使用,不能使用的时候就用普通函数。

六、容器(Container)

容器:包含值和值的变形关系(这个关系就是函数)

假设有个函数,可以接收一个来自用户输入的数字字符串。我们需要对其预处理一下,去除多余空格,将其转换为数字并加1,然后结果相乘,最后将值转换为字符串。 我们可以用PointFree模式这样写:

// 工具函数
const trim = x => _.trim(x)
const parseInt = _.curry((radix, x) => _.parseInt(x, radix))
const addX = x => x + 1
const multiplyX = x => x * x
const fromCharCode = x => _.toString(x)

// 函数组合
const composeResult = _.flowRight(fromCharCode, multiplyX, addX, parseInt(10), trim)
const composeResult_1 = composeResult('2')
// 输出
console.log(composeResult_1, typeof composeResult_1)

// => 9 string

由上例可以看出,函数组合可以便利的实现我们想要的功能,但是可能为了组合出这一个函数,需要定义比较多的只会使用一次的工具函数。

所以,我们还可以这样写:

const composeResult =
  (str) => [str]
    .map(x => _.trim(x))
    .map(x => _.parseInt(x))
    .map(x => x + 1)
    .map(x => x * x)
    .map(x => _.toString(x))

// 输出
composeResult('2')
// ['9'] Object

借助map的方法,我们不用定义中间变量的名称,也不用关心中间变量的变化,而且每一步都十分清晰。

我们将原本的字符串变量 str 放在数组中变成了 [str],这里就像放在一个容器里一样。

更近一步:

class Container {
  constructor(value) {
    this._value = value
  }

  map(fn) {
    return new Container(fn(this._value))
  }
}

const composeResult = new Container('2')
    .map(x => _.trim(x))
    .map(x => _.parseInt(x))
    .map(x => x + 1)
    .map(x => x * x)
    .map(x => _.toString(x))
    
console.log(composeResult, typeof composeResult)
// => Container { _value: '9' } object

上例中的Container,是一个具有map方法的对象,并且内部维护一个私有的属性值,我们把这样以一个对象称为容器,通常

  • Container 的这个属性命名为_value
  • _value 不能是某个特定的类型
  • 数据一旦存放到 Container,就会一直待在那儿。我们可以用 ._value 获取到数据, 但是这是不被提倡的。

为了方便调用,将容器升级:

class Container {
  static of(value) {
    return new Container(value)
  }

  constructor(value) {
    this._value = value
  }

  map(fn) {
    return Container.of(fn(this._value))
  }
  
  // 展开
  fold(fn) => fn(this._value)
}

const composeResult = Container.of(2)
    .map(x => _.trim(x))
    .map(x => _.parseInt(x))
    .map(x => x + 1)
    .map(x => x * x)
    .map(x => _.toString(x))
    
console.log(composeResult, typeof composeResult)
// => Container { _value: '9' } object

我们为容器定义了一个静态方法of,来避免使用 new 来创建对象。

我们把实现了of静态方法的函子又叫做Pointed函子 这样我们能够在不离开 Container 的情况下操作容器里面的值。Container 里的值传递给 map 函数之后,就可以任我们操作;操作结束后,为了防止意外再把它放回它所属的 Container。这样做的结果是,我们能连续地调用 map,运行任何我们想运行的函数。甚至还可以改变值的类型,就像上面最后一个例子中那样。

我们把这样一个容器,称为Functor

6.1. Functor(函子)

functor 是实现了 map 函数并遵守一些特定规则的容器类型

通俗理解就是:函子就是一个特殊容器,通过一个普通对象来实现,该对象具有map方法, map方法可以运行一个函数对值进行处理(变形关系)。

函子可以控制流(control flow)、异常处理(error handling)、异步操作(asynchronous actions)和 变更状态(change state).

6.2. 异常处理 MayBe/Either Functor

我们在编程的过程中,可能会遇到很多错误,需要对这些错误作出相应对处理。

MayBe 函子可以对外部传入空值或着异常情况做处理(控制副作用在允许的范围)

Maybe 看起来跟 Container 非常类似, 但是有一点不同:Maybe 会先检查自己的值是否为空,然后才调用传进来的函数。这样我们在使用 map 的时候就能避免传入的值为null 。

// 普通函子
class Container  {
  static of(value) {
    return new Container(value)
  }

  constructor(value) {
    this._value = value
  }

  map(fn) {
    return Container.of(fn(this._value))
  }
}

const r = Container.of('hello world').map(x => undefined).map(x => x.split(' '))

// => TypeError: Cannot read property 'split' of undefined

// MayBe 函子 
class MayBe  {
  static of(value) {
    return new MayBe(value)
  }

  constructor(value) {
    this._value = value
  }
  
   // 内部定义一个容错函数
  isNothing() {
    return this._value === null || this._value === undefined
  }
  
  // 调用map时,先进行副作用判断,在运行任何我们想要运行对函数
  map(fn) {
    return this.isNothing() ? MayBe.of(null) : MayBe.of(fn(this._value))
  }
  
  const r = MayBe.of('hello world').map(x => undefined).map(x => x.split(' '))
  
  // 打印=> MayBe { _value: null }
}

问题:上例MayBe函子在运行过程中能够对异常进行容错处理,当运行出错是,它只会返回MayBe { _value: null } 的新函子,但是具体是哪一个管道运行出现错误,我们是无法获知的。 所以,我们接下来认识另外一个函子:Either 函子

class Left {
  static of(value) {
    return new Left(value)
  }

  constructor(value) {
    this._value = value
  }

  map(fn) {
    return this
  }
 
  fold(f, g) {
    return f(this._value)
  }
}

class Right {
  static of(value) {
    return new Right(value)
  }

  constructor(value) {
    this._value = value
  }

  map(fn) {
    return Right.of(fn(this._value))
  }
  
  fold(f, g) {
    return g(this._value)
  }
}

Left 和 Right 是我们称之为 Either 的抽象类型的两个子类。我略去了创建 Either 父类的繁文缛节,因为我们不会用到它的,但你了解一下也没坏处。注意看,这里除了有两个类型,没别的新鲜东西。来看看它们是怎么运行的:

Right.of('hello world').map(x => x.split(' ')).fold(x => 'error', x => x)
=> Right { _value: [ 'hello', 'world' ] } 

Left.of('hello world').map(x => x.split(' ')).fold(x => 'error', x => x)
=> Left { _value: 'hello world' }

可以看出,Left会忽略map中的处理函数,Right就像一个普通的函子。

以下内容来自 深入学习javascript函数式编程,用于学习查询参考使用。

借助Either我们可以进行程序流程分支控制,例如进行异常处理、null检查等。

const findColor = name =>
    ({red: '#ff4444', blue: '#3b5998', yellow: '#fff68f'})[name];

const result = findColor('red').slice(1).toUpperCase();
console.log(result); // "FF4444"

这里如果我们给函数findColor传入green,则会报错。

TypeError: Cannot read property 'slice' of undefined

因此可以借助Either进行错误处理:

const findColor = name => {
  const found = {red: '#ff4444', blue: '#3b5998', yellow: '#fff68f'}[name];
  return found ? Right(found) : Left(null);
};

const result = findColor('green')
            .map(c => c.slice(1))
            .fold(e => 'no color',
                 c => c.toUpperCase());
console.log(result); // "no color"

更进一步,我们可以提炼出一个专门用于null检测的Either容器,同时简化findColor代码

const either = x =>
    x != null ? Right(x) : Left(null); // [!=] will test both null and undefined

const findColor = name =>
    either({red: '#ff4444', blue: '#3b5998', yellow: '#fff68f'}[name]);
    
findColor('green').map(x => x.slice(1)).map(x => x.toUpperCase()).fold(() => 'no-color', x => x)
// => no-color

findColor('red').map(x => x.slice(1)).map(x => x.toUpperCase()).fold(() => 'no-color', x => x)
// => FF4444

假设有一个可能会失败的函数,看一个读取配置文件config.json的例子:

const fs = require('fs');

const tryCatch = f => {
  try {
    return Right(f());
  } catch (e) {
    return Left(e);
  }
};

const getPort = () =>
    tryCatch(() => fs.readFileSync('config.json'))
    .map(c => JSON.parse(c))
    .fold(
        e => 3000,
        obj => obj.port
    );

const result = getPort();
console.log(result); // 8888 or 3000

我们用到了JSON.parse,如果config.json文件格式有问题,程序就会报错:

SyntaxError: Unexpected end of JSON input

因此需要针对JSON解析失败做异常处理,我们可以继续使用tryCatch来解决这个问题:

const getPort = () =>
    tryCatch(() => fs.readFileSync('config.json'))
    .map(c => tryCatch(() => JSON.parse(c)))
    .fold(
        left => 3000, // 第一个tryCatch失败
        right => right.fold( // 第一个tryCatch成功
            e => 3000, // JSON.parse失败
            c => c.port
        )
    );

这次重构我们使用了两次tryCatch,因此导致箱子套了两层,最后需要进行两次拆箱。为了解决这种箱子套箱子的问题,我们可以给Right和Left增加一个方法chain:

const Right = x => ({
  chain: f => f(x),
  map: f => Right(f(x)),
  fold: (f, g) => g(x),
  toString: () => `Right(${x})`
});

const Left = x => ({
  chain: f => Left(x),
  map: f => Left(x),
  fold: (f, g) => f(x),
  toString: () => `Left(${x})`
});

当我们使用map,又不想在数据转换之后又增加一层箱子时,我们应该使用chain:

const getPort = () =>
    tryCatch(() => fs.readFileSync('config.json'))
    .chain(c => tryCatch(() => JSON.parse(c)))
    .fold(
        e => 3000,
        c => c.port
    );

6.3. IO / Monad Functor

IO函子中的_value 是一个函数,它可以把不纯的函数存储到_value中。

class IO {
  static of(value) {
    return new IO(function() {
      return value
    })
  }
  constructor(fn) {
    this._value = fn
  }

  // map方法与之前的map方法不同的是--返回一个组合函数的新函子
  map(fn) {
    return new IO(fp.flowRight(fn, this._value))
  }
  
  // 取值--执行可能不纯的操作
  fold() {
    return this._value()
  }
}

IO函子将不纯的操作延迟到调用时执行(惰性执行):

const findColor = name => IO.of(({ red: '#ff4444', blue: '#3b5998', yellow: '#fff68f' })[name])

// 纯函数调用, 始终返回 => IO { _value: [Function (anonymous)] }
const getColorFunctor = name => findColor(name).map(x => x.slice(1)).map(x => x.toUpperCase())

// 将不纯的操作延迟到调用时执行
getColorFunctor('red').fold()
// => FF4444

IO函子可能遇到的问题,一个通过文件名,获取文件内容的例子:

// 读取文件
const readFile = filePath => IO.of(fs.readFileSync(filePath, {
  encoding: 'utf-8'
}))

//  
const printFile = file => {
  console.log('file', file)
  return IO.of(file)
}

const cat = fp.flowRight(printFile, readFile)

const cat_p = cat('./package.json')
// => IO(IO({ _value: [Function (anonymous)] }))

// 打印文件内容
cat_p.fold().fold()

// => {
//   name: 'loadsh',
//   main:'index.js'
//   ....
// }

如上例,当我们遇到IO(IO(fn))函子嵌套时,需要多次调用执行fold;这个时候Monad函子 就能解决这个问题。

首先,什么是Monad函子?

Monad 函子是可以变扁的Pointed函子,常用来解决函子嵌套问题。

通常,一个函子如果具有join 和 of方法并遵守一些定律就是一个Monad。

class IO {
  static of(value) {
    return new IO(function() {
      return value
    })
  }
  constructor(fn) {
    this._value = fn
  }

  // map方法与之前的map方法不同的是--返回一个组合函数的新函子
  map(fn) {
    return new IO(fp.flowRight(fn, this._value))
  }
  
  // 执行函子内部存储的fn
  join() {
    return this._value()
  }
  
  // 将嵌套函子解构
  flatMap(fn) {
    return this.map(fn).join()
  }
}

Monad函子实现读文件操作:

const r = readFile('package.json')
            // 传入的fn返回的是值,调用map处理
            .map(fp.split('\n'))
            .map(fp.toUpper)
            // 传入的fn返回的是函子,则调用flatMap解构
            .flatMap(print) // 得到一个IO { _value: function() {return file}}
            .join()
            
// => ["NAME": "LOADSH", "VERSION": "1.0.0", ...]

6.4. 异步执行 Task Functor

folktale中的Task函子提供了异步执行机制,异步读取package.json文件:

const fs = require('fs')

const { task } = require('folktale/concurrency/task')
const fp = require('lodash/fp')

// folktale task函子提供resolver 对象,成功返回resolve,失败返回reject
function readFile(filename) {
  return task(resolver => {
    fs.readFile(filename, 'utf-8', (error, data) => {
      if (error) resolver.reject(error)
      
      resolver.resolve(data)
    })
  })
}

// 调用readFile返回的是一个task函子,不会直接执行内部传入的函数
// task 函子run方法执行内部传入的函数
// listen 监听resolver结果
// 函子都具有一个map方法,在run 方法之前调用map方法,可以对执行结果进行处理
readFile('./package.json')
  .map(fp.toUpper)
  .map(fp.split('\n'))
  .map(fp.find(x => x.includes('version'.toUpperCase())))
  .run()
  .listen({
  onRejected: (err) => {
    console.log('failed+++', err)
  },
  onResolved: (value) => {
    console.log('success+++', value)
  }
})

6.5. Applicative Functor

待续……

七、函数式编程常用库

函数式编程常用库:

参考资料

本文用于记录学习过程中的内容和思考,如有错误,还请指正!