# 函数式编程的特性

  • 函数是一等公民
  • 高阶函数
  • 闭包

# 函数是一等公民

  • 函数可以存储在变量或数组,对象中等
  • 函数可以作为参数进行传递
  • 函数可以作为返回值

在 Javascript 中,函数就是一个普通对象。因此可以把函数存储在变量或数组,对象中等。可以作为参数或返回值。

函数是一等公民,是学习高阶函数,函数柯里化的基础。

# 高阶函数

什么是高阶函数?

  • 把函数作为一个参数传递给另一个函数
  • 把函数做为另一个函数的返回结果

# 函数作为参数

函数作为参数的好处:

  • 通过将函数作为参数,使用回调函数可以处理自定义逻辑,使函数更加灵活
  • 调用 forEach 函数,屏蔽了内部实现细节
  • forEach 函数名称更加语义化
// 模拟forEach 遍历数组
function forEach(array,fn){
    for (let i = 0; i < array.length; i++){
        fn(array[i])
    }
}

//模拟filter 过滤数组
function filter(array, fn) {
    let result = [];
    for (let i = 0; i < array.length; i++) {
        let item = array[i];
        if (fn(item)){
            result.push(item)
        }
    }
    return result;
}

// 1 2 3 4
forEach([1, 2, 3, 4], function (item) {
    console.log(item)
})


const result = filter([1, 2, 3, 4], function (item) {
    return item > 2;
})

// [3,4]
console.log(result)

# 函数作为返回值

// 函数作为返回值 
// 避免重复调用多次pay方法
function once(fn){
    let done = false;
    return function(){
        if(!done){
            done = true;
            return fn.call(this,...arguments)
        }
    }
}

// 支付5
const pay = once(function(value){
    console.log(`支付${value}`)
})

pay(5)
pay(5)
pay(5)
pay(5)

# 使用高阶函数的意义

  • 高阶函数可以抽象通用的逻辑
  • 抽象可以帮助我们屏蔽实现细节,只需要关注最终目标与结果
  • 通过将函数作为参数,使用回调函数可以处理自定义逻辑,使函数更加灵活

# 常用的高阶函数

  • forEach
  • map
  • filter
  • every
  • some
  • find/findIndex
  • reduce
  • sort
  • ...
/**
 * 
 * @param {*} array 
 * @param {*} fn 
 * map模拟实现 遍历数组 对数组元素进行相关操作 返回一个新的数组
 */
function map(array, fn){
    let result = [];
    for (let i = 0; i < array.length;i++){
        result.push(fn(array[i]));
    }
    return result;
}

const result1 = map([1, 2, 3, 4],function(item){
    return item*2
})

// [ 2, 4, 6, 8 ]
console.log(result1);

/**
 * 
 * @param {*} array 
 * @param {*} fn 
 * 模拟every实现 遍历数组 如果数组中的每个元素都匹配条件 则返回true 否则返回false
 */

function every(array, fn) {
    let flag = true;
    for (let i = 0; i < array.length; i++) {
        // 条件不成立
        if(!fn(array[i])){
            flag = false;
            break;
        }
    }
    return flag;
}

const result2 = every([1, 2, 3, 4], function (item) {
    return item >= 1;
})

// true
console.log(result2);


/**
 * 
 * @param {*} array 
 * @param {*} fn 
 * 模拟some实现 遍历数组 如果数组中有一个元素匹配条件 则返回true 如果都不匹配则返回false
 */
function some(array, fn) {
    let flag = false;
    for (let i = 0; i < array.length; i++) {
        // 条件成立
        if (fn(array[i])) {
            flag = true;
            break;
        }
    }
    return flag;
}

const result3 = some([1, 2, 3, 4], function (item) {
    return item >= 4;
})

// true
console.log(result3);

# 闭包

  • 闭包有两部分组成,一个是当前的执行上下文A,一个是在该执行上下文中创建的函数B
  • 当B执行的时候引用了当前执行上下文A中的变量就会产出闭包
  • 当一个值失去引用的时候就会会标记,被垃圾收集回收机回收并释放空间
  • 闭包的本质就是在函数外部保持内部变量的引用,从而阻止垃圾回收
function one() {
    var a = 1;
    var b = 2;
    function two() {
        var c = 3;
        // 引用了外部变量
        console.log(a, c);
    }
    return two;
}
let two = one();
two(); //1,3

# 纯函数

函数式编程中的函数指的是 纯函数

# 纯函数的概念

  • 函数的返回值只与参数有关,相同的输入产生相同的输出。并且不会对外部产生副作用
    • 纯函数类似数学中的函数(用来描述输入与输出的关系)。y = f(x)
  • Lodash 是一个纯函数的功能库,提供了一系列对数组,对象,函数等的操作方法
  • 数组中的 slice 方法是纯函数,splice 不是纯函数

# 纯函数的好处

  • 可缓存:相同的输入产生相同的输出,所以可以把纯函数的结果缓存起来
/**
 * 
 * @param {*} fn 
 * 模拟 Lodash memoize方法实现 将结果缓存起来
 */
function memory(fn){
    let cache = {};
    return function(){
        const key = JSON.stringify(arguments); // 获取参数并转换成字符串
        cache[key] = cache[key] || fn.call(fn, ...arguments); // 缓存计算结果 
        return cache[key]; // 返回结果
    }
}

function getArea(r){
    console.log("触发getArea")
    return Math.PI * r * r;
}

const getAreaWithMemeoy = memory(getArea);

// 触发一次 getArea 执行
getAreaWithMemeoy(4)
getAreaWithMemeoy(4)
getAreaWithMemeoy(4)
  • 可测试:纯函数始终有输入和输出,相同的输入产生相同的输出。单元测试就是断言函数的执行结果,所以纯函数是可测试的。
  • 方便并行处理
    • 在多线程情况下并行操作共享的内存数据(如:全局变量),变量的值不确定,很可能出现意外的情况
    • 纯函数只依赖参数,不需要访问共享的数据,所以在并行环境下可以任意运行纯函数(web worker

# 副作用

/**
 * 
 * @param {*} age 
 * 不是纯函数 外部变量 mini 会影响函数的执行结果
 */
function checkAge(age) {
    return age > mini;
}


/**
 * 
 * @param {*} age 
 * 是纯函数 通过硬编码实现,可以通过函数柯里化进行优化
 */
function checkAge2(age) {
    const mini = 12;
    return age > mini;
}

副作用 使函数不纯,纯函数始终接收相同的输入产生相同的输出。如果函数依赖外部变量,就无法保证输出相同,就会带来 副作用

# 副作用的来源

  • 配置文件
  • 数据库
  • 获取用户输入数据
  • ...

所有外部交互都可能产生 副作用副作用 使方法的通用性下降,不利于程序的扩展以及复用。同时 副作用 也会给程序带来安全隐患,带来不可确定性。但是 副作用 不可能完全消除,需尽可能控制 副作用 在可控范围之类产生。

# 函数柯里化

# 函数柯里化的概念

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

  • 先传递一部分产生调用它(这部分参数以后不变)
  • 然后返回接受余下的参数而且返回结果的新函数

使用函数柯里化解决上面硬编码的问题。

/**
 * 
 * @param {*} mini 
 * 是纯函数 通过函数柯里化进行实现
 */
function checkAge(mini) {
    return function (age){
        return age > mini;
    }
}

const checkAge12 = checkAge(12);
console.log(checkAge12(11))

# Lodash 中的柯里化函数

  • _.curry(func, [arity=func.length])
    • 创建一个函数,该函数接收 func 的参数,要么调用func返回的结果,如果 func 所需参数已经提供,则直接返回 func 所执行的结果。或返回一个函数,接受余下的func 参数的函数,可以使用 func.length 强制需要累积的参数个数。
    • 参数:需要柯里化的函数
    • 返回值:柯里化后的函数
var abc = function(a, b, c) {
  return [a, b, c];
};
 
var curried = _.curry(abc);
 
curried(1)(2)(3);
// => [1, 2, 3]
 
curried(1, 2)(3);
// => [1, 2, 3]
 
curried(1, 2, 3);
// => [1, 2, 3]

const match = _.curry(function (reg, str){
    return str.match(reg)
});

// 匹配字符串有空格
const haveSapce = match(/\s+/g);

// 匹配字符串有数字
const haveNumber = match(/\d+/g);

# 柯里化函数模拟实现

/**
 * 
 * @param {*} fn 
 * 模拟 curry 函数实现
 */
function curry(fn){
    // ...arg 保存上次调用的参数
    return function curriedFn(...arg){
        // 判断实参和形参的个数
        // fn.length 需要柯里化的函数的形参的个数
        if (arguments.length < fn.length){
            // 实参个数小于形参个数 需要返回一个新的函数接收剩余的参数
            return function(){
                // arguments 保存本次调用的参数 从第二次开始
                // arguments 是伪数组,需要转成数组
                // 拼接每次调用的参数 执行柯里化函数
                return curriedFn(...arg.concat(Array.from(arguments)))
            }
        }
        // 实参个数大于等于形参个数 执行需要柯里化的函数 并传递参数 arg
        return fn.call(fn, ...arg)
    }
}

function getSum(a, b, c){
    return a + b + c;
}
var curried = curry(getSum);

console.log(curried(1, 2, 3)); // 6
console.log(curried(1)(2)(3)); // 6
console.log(curried(1)(2, 3)); // 6
console.log(curried(1, 2)(3)); // 6

# 函数柯里化总结

  • 函数柯里化让我们给函数传递较少参数,得到一个记住某些固定参数的新函数
  • 通过闭包,对函数参数进行缓存
  • 让函数变得更灵活,颗粒度更小
  • 可以把多元函数(多个参数的函数)转成一元函数,可以通过组合生成功能更佳强大的函数
  • 可以解决函数硬编码相关问题

# 函数组合

  • 纯函数和函数柯里化容易写出洋葱代码 h(g(f(x)))
    • 获取数组中最后一个元素再转换为大写字母 _.toUpper(_.first(_.reverse(array)))
  • 函数组合可以避免洋葱代码的出现,可以让我们把细粒度的函数重新组合成一个新的函数

# 数据的管道

给fn函数传入参数a,返回结果b,可以想象a数据通过一个管道得到了数据b。

当fn函数比较复杂时,我们可以把fn函数拆解为几个小的函数。此时多了中间过程产生的m和n。

可以想象把 fn 这个管道拆解 f1,f2,f3。数据a通过管道f3得到结果m,m再通过管道f2得到结果n,n再通过管道f1得到最终结果b。

  • fn = compose(f1,f2,f3)
  • b = fn(a)

# 函数组合概念

如果一个数据需要经过多个函数处理才能得到最终结果,这个时候可以把中间过程的函数合并成一个函数。同时通过灵活组合不同函数,可以实现不同的功能。

组合的方式就是抽象单一功能的函数,然后再组成复杂功能。这种方式既锻炼了你的抽象能力,也给维护带来巨大的方便。

  • 函数就像是数据的管道,函数组合就是将这些管道连接起来,让数据穿过多个管道形成最终的结果
  • 函数组合默认 从右向左执行
/**
 * 
 * @param {*} f 
 * @param {*} g 
 * 函数组合简单演示
 * 翻转数组 然后返回数组第一个元素
 */
function compose(f, g){
    return function(x){
        return f(g(x))
    }
}

function first(array){
    return array[0];
}

function reverse(array) {
    return array.reverse();
}

const composeFn = compose(first, reverse);
console.log(composeFn([1, 2, 3, 4])) //4

# Lodash 中的组合函数

  • .flow([funcs])**和 **.flowRight([funcs]) 都是组合函数,可以组合多个参数
  • _.flow([funcs]) 从左往右执行
  • _.flowRight([funcs]) 从右往左执行,使用频率更高
function square(n) {
  return n * n;
}
 
// 先执行 _.add,再执行square
var addSquare = _.flow([_.add, square]);
addSquare(1, 2);
// => 9
function square(n) {
  return n * n;
}
 
// 先执行 _.add,再执行square
var addSquare = _.flowRight([square, _.add]);
addSquare(1, 2);
// => 9

# 组合函数模拟实现

/**
 * 
 * @param  {...any} args 
 * 模拟 flowRight 实现
 */

function flowRight(...args) {
    return function (value) {
        // 翻转args数组 
        // 确保从右往左执行函数 并将每次的结果 作为参数传递给下一个函数
        return args.reverse().reduce((result, currentFn) => {
            return currentFn(result);
        }, value)
    }
}

var str = 'function program'

function stringToUpper(str) {
    return str.toUpperCase()
}

function stringReverse(str) {
    return str.split('').reverse().join('')
}

var toUpperAndReverse = flowRight(stringReverse, stringToUpper)
console.log(toUpperAndReverse(str))


function stringToArray(str) {
    return str.split('')
}

var toUpperAndArray = flowRight(stringToArray, stringToUpper)
console.log(toUpperAndArray(str))

上面的代码,我们可以通过 flowRight 组合不同的函数,生成各种复杂的逻辑,来满足不同的业务需求。可以看到,组合的方式就是抽象单一功能的函数,然后再组成复杂功能。这种方式既锻炼了你的抽象能力,也给维护带来巨大的方便。

# 函数组合满足结合律

我们即可以把 gh 组合,也可以把 fg 组合,结果是一样的。

flowRight(flowRight(f, g), h) = flowRight(f, flowRight(g, h)) = flowRight(f, g, h)
function first(array) {
    return array[0];
}

function reverse(array) {
    return array.reverse();
}

function toUpper(str) {
    return str.toUpperCase()
}

/**
 * 
 * @param  {...any} args 
 * 模拟 flowRight 实现
 */

function flowRight(...args){
    return function(value){
        // 翻转args数组 
        // 确保从右往左执行函数 并将每次的结果 作为参数传递给下一个函数
        return args.reverse().reduce((result, currentFn)=>{
            return currentFn(result);
        }, value)
    }
}

// 翻转数组 获取第一个元素 然后转换成大写字母
const composeFn1 = flowRight(toUpper,first, reverse);
const composeFn2 = flowRight(flowRight(toUpper, first), reverse);
const composeFn3 = flowRight(toUpper, flowRight(first, reverse));
console.log(composeFn1(['a','b', 'c', 'd'])) // 'D'
console.log(composeFn2(['a', 'b', 'c', 'd'])) // 'D'
console.log(composeFn3(['a', 'b', 'c', 'd'])) // 'D'

# 如何调试组合函数

function logger(value){
    console.log(value);
    return value;
}

const composeFn1 = flowRight(toUpper, logger, first, logger, reverse);

console.log(composeFn1(['a','b', 'c', 'd'])) 
// [ 'd', 'c', 'b', 'a' ]
// d
// D

优化打印日志

/**
 * 通过函数柯里化返回一个新的函数
 * 可以通过 tag 更加清晰的描述打印信息及流程
 */
const trace = curry((tag, value) =>{
    console.log(tag, value);
    return value;
})

const composeFn1 = flowRight(toUpper, trace('first之后'), first, trace('reverse之后'), reverse);
console.log(composeFn1(['a', 'b', 'c', 'd'])) 

// reverse之后 [ 'd', 'c', 'b', 'a' ]
// first之后 d
// D

# Lodash 中的函数编程模块

当我们使用 函数组合 解决问题时,会使用 Lodash 中提供的一些方法。但是如果这些方法有 多个参数 的时候,我们需要对这些方法进行 柯里化 处理,需要重新包装一些方法,满足 函数组合 的要求(接收一个参数),比较麻烦。

Lodash 中的函数编程模块(Function Program),提供了对 函数式编程 友好的方法。

  • 这些方法是 不可变的,而且是已经 柯里化
  • 如果一个方法的参数是函数,那么 函数优先,并且 数据滞后
  • 先传递函数,再传递数据

Lodash 模块中的方法一般是先传递数据,再传递函数。Lodash 中的函数编程模块,提供的方法是先传递函数,再传递数据。

# Point Free

在函数式编程的世界中,有这样一种很流行的编程风格。这种风格被称为 tacit programming,也被称作为 point-frepoint 表示的就是 形参,意思大概就是 没有形参 的编程风格。

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

  • 不需要指明处理的数据,没有形参
  • 只需要组合成运算过程
  • 需要定义一些辅助的基本运算函数

使用函数组合解决问题,就是一种 pointfree 模式。

有参的函数的目的是得到一个结果,而 pointfree 的函数的目的是得到另一个函数。

// 这就是有参的,因为 word 这个形参
var snakeCase = word => word.toLowerCase().replace(/\s+/ig, '_');

// 这是 pointfree,没有任何形参
// 函数组合合成为一个新的函数 不关心数据
var snakeCase = compose(replace(/\s+/ig, '_'), toLowerCase);

函数式编程 的核心是将 运算过程 抽象为 函数pointfree 模式是将抽象出来的函数再合成为一个新的函数。这个合成的过程又是一个抽象的过程,这个抽象的过程中是不需要关心数据的。

const fp = require('lodash/fp');

// 把字符串的首字母提取,并转换成大写,然后使用. 拼接
// world wide web W. W. W. 
const firstLetterToUpper1 = fp.flowRight(fp.join(". "), fp.map(fp.first), fp.map(fp.toUpper), fp.split(' '));
// 优化 避免多次循环
const firstLetterToUpper1 = fp.flowRight(fp.join(". "), fp.map(fp.flowRight(fp.first, fp.toUpper)), fp.split(' '));

# Functor 函子

函数式编程 的核心是将 运算过程 抽象为 函数,将来可以最大化复用这些函数。函数式编程 是建立在 数学基础 上的,比如,纯函数就是数学中的 函数Functor 是建立在数学中的 范畴论 的基础上的。

Functor 可以把副作用控制在可控范围之内,同时可以 处理异常,和 异步操作 等等。

# 什么是函子

  • 容器:包含值和值的变形关系(这个变形关系就是函数)
  • 函子:是一个特殊的容器,通过一个普通的对象来实现,该对象具有map方法,map方法可以运行一个函数对值进行处理(变形关系)
class Functor{
    constructor(val){
        this._value = val; 
    }
    map(fn){
        // 处理值 并返回一个新的函子
        return new Functor(fn(this._value));
    }

    // 静态方法 返回函子对象
    static of(val) {
        return new Functor(val);
    }
}

console.log(new Functor(5).map(x => x * 2).map(x => console.log(x)))
// 10
// Functor { _value: undefined }

console.log(Functor.of(5).map(x => x * 2))
// Functor { _value: 10 }

不要直接修改 _value 的值,通过 map 方法修改 _value 的值。

# Functor 函子 总结

  • 函数编程的运算不直接操作值,而是通过 函子 完成,
  • 函子是一个实现 map契约 的对象
  • 我们可以把函子想象成一个容器,这个容器封装了一个值
  • 想要处理容器中的值,我们需要给 map 方法传递一个处理值的函数(纯函数),由这个函数对值进行处理
  • 最终 map 方法,返回一个包含新值的容器(函子)
  • 可以通过 map 方法进行链式调用
  • 因为我们可以把不同 运算方法 封装在函子中,所以可以衍生出不同类型的 函子,有多少 运算 就有多少 函子
  • 最终可以使用 函子 解决实际的问题

# Maybe 函子

  • 我们在编程中可能遇到各种错误,需要对这些错误进行相应的处理
  • Maybe 函子,增加了对空值的判断,若是函子内部的值为空,则直接返回一个内部值为空的函子(控制副作用在可控范围内)
class Maybe {
    constructor(val) {
        this._value = val;
    }

    /**
     * 
     * @param {*} fn 
     * 如果值为 null 或 undefined 返回值为 null 的函子
     */
    map(fn) {
        console.log(this._value)
        return !this.isNothing() ? Maybe.of(fn(this._value)) : Maybe.of(null);
    }

    // 静态方法 返回函子对象
    static of(val) {
        return new Maybe(val);
    }

    // 判断传入的值是否合法
    isNothing() {
        return this._value === undefined || this._value === null;
    }
}

当使用 Maybe 函子时传入空值则不会报错

console.log(Maybe.of(null).map(toUpper));  // Maybe { _value: null }

console.log(Maybe.of('hello').map(toUpper).map(x=>null).map(first)); // Maybe { _value: null }

Maybe 函子 可以处理 nullundefined,但是多次调用 map 方法,哪次出现 null 值,是不太明确的。

# Either 函子

Either 函子是指内部有分别有左值(left)和右值(right),正常情况下会使用右值,而当右值不存在的时候会使用左值的函子。

  • Either 两者中的任意一个,类似于 if...else 的处理
  • 使用 Maybe 函子时,当我们传递 null 时,仅仅返回一个值为 null 的函子。不会给出任何有效的信息,对异常信息吗,没有有效提示。
  • 异常会使函数不纯,Either 函子可以用来做异常处理,并给出有效提示信息。
  • 正常情况使用 Right 函子,异常情况使用 Left 函子
class Left {
    constructor(val) {
        this._value = val;
    }

    map(fn) {
        // 直接返回当前对象
        return this;
    }

    // 静态方法 返回函子对象
    static of(val) {
        return new Left(val);
    }
}

class Right {
    constructor(val) {
        this._value = val;
    }

    map(fn) {
        // // 处理值 并返回一个新的函子
        return Right.of(fn(this._value));
    }

    // 静态方法 返回函子对象
    static of(val) {
        return new Right(val);
    }
}

function parseJson(str) {
    try {
        // 正常情况使用 Right 函子
        return Right.of(JSON.parse(str))
    } catch (e) {
        // 异常情况使用 Left 函子
        return Left.of({ error: e.message})
    }
}

const result = parseJson('{name:"111"}')
console.log(result) 
// Left { _value: { error: 'Unexpected token n in JSON at position 1' } }

# IO 函子

  • IO 函子内部的 _value 是一个函数,因为函数是一等公民,这里把函数当做值处理。
  • IO 函子可以把不纯的操作存储在 _value 中,函子内部不会调用这个函数,因此可以延迟执行这些不纯的操作(惰性执行),保证当前的操作是纯的
  • 把不纯的操作交给调用者来处理
const fp = require('lodash/fp')
class IO {
    constructor(fn) {
        // _value是一个函数
        this._value = fn;
    }
    map(fn) {
        // 帮当前的 _value 和 fn 组合成一个新的函数
        return new IO(fp.flowRight(fn, this._value));
    }

    // 静态方法 返回函子对象
    static of(val) {
        return new IO(function(){
            return val;
        });
    }
}

const result = IO.of(process).map(p=>p.execPath)
console.log(result._value()) // node执行进程的路径 result._value是不纯的函数

IO 函子内部传递的函数有可能是不纯的操作,但是 IO 函子执行返回的结果,始终是一个纯的操作。IO 函子可能包裹了不纯的操作,调用 map 方法始终返回一个 IO 函子。

IO 函子中的 value 属性,会组合很多函数,这些函数可能是不纯的操作。把这些不纯的操作延迟到了调用者来处理。

通过 IO 函子,控制了副作用在可控范围内发生。

更新时间: 6/3/2020, 2:16:25 AM