函数式编程(二)


纯函数

纯函数的概念:相同的输入永远会得到相同的输出,而且没有可观察的副作用。纯函数的函数指的是数学中的函数,用来描述输入和输出之间的关系。

lodash就是一个纯函数的功能库,提供了对数组、数字、对象、字符串、函数等操作的一些方法

我们拿数组中的两发方法 slice 和 splice 来举例说明纯函数和不纯的函数。

  • slice 返回数组中的指定部分,不会改变原数组,所以是一个纯函数。
  • splice 对数组进行操作发挥该数组,会改变原数组,所以是一个不纯的函数。
const array = [1,2,3,4,5];

console.log(array.slice(0,3));
console.log(array.slice(0,3));
console.log(array.slice(0,3));
// 打印结果未修改原数组
// [ 1, 2, 3 ]
// [ 1, 2, 3 ]
// [ 1, 2, 3 ]

console.log(array.splice(0,3));
console.log(array.splice(0,3));
console.log(array.splice(0,3));

// 打印结果 显示修改了原数组
// [ 1, 2, 3 ]
// [ 4, 5 ]
// []
// 实现一个简单的纯函数
function get_sum(n1, n2) {
  return n1 + n2;
}

副作用

纯函数定义中,纯函数没有任何可观察的副作用。那什么是副作用?副作用简单解释就是让一个函数变得不纯,纯函数根据相同的输入返回相同的输出,如果函数依赖于外部的状态就无法保证输出相同,就会带来副作用。

// 不纯的函数
// 我们定义了一个 check_age 的函数用来判断用户的年明是否大于等于标准值。
// 我们对于 纯函数的定义 相同的输入永远会得到相同的输出 。对于 check_age函数来说比如我们输入 20 是否永远返回的都是 true 呢?答案肯定是否定的。
const mini = 18;
function check_age(age) {
  return age >= mini;
}

// 把这个改为纯函数只需要让外部的变量放到函数内部声明,这样就可以确保相同的输入永远会得到相同的输出
function check_age(age) {
  const mini = 18; // 这里只是为了方便解释 副作用, 实际编程中纯函数里不会出现硬编码,具体的解决办法之后我会写到。
  return age >= mini;
}

上面实例里第一个 check_age 就是一个有副作用的函数,在外面定义的 全局变量就是这个副作用的来源,它使得这个函数变得不纯。除了全局变量可以作为副作用的来源,还可以是配置文件、数据库、用户的输入等;所有外部交互都可能产生副作用,副作用也使得方法的通用性下降,不适合程序的扩展和可复用性,副作用也会给程序带来一些安全隐患和不确定性,但是副作用不可能完全禁止,我们要尽可能的控制它在可控的范围内。

纯函数的优势

可缓存

因为纯函数对相同的输入始终有相同的结果,所以可以把纯函数的结果缓存起来,下次调用直接使用缓存后的结果。在日常编程中如果有一个函数的耗时时间比较长,又要频繁的调用,那我们就可以把这个函数的结果缓存起来。lodash中memoize这个函数就是做这件事情的,下面我们来看一个实例。

// 引用 lodash
const _ = require("lodash");
//定义一个 获取圆 面积的函数。
function get_area(r) {
    console.log(`计算${r}面积`);
  return Math.PI * r * r;
}
//接下来我们想把计算圆面积的结果缓存下来
const get_area_with_memoize = _.memoize(get_area);

console.log(get_area_with_memoize(4));
console.log(get_area_with_memoize(4));
console.log(get_area_with_memoize(4));
// 打印结果 - 计算一次结果后 值就被缓存了起来。
// 计算4面积
// 50.26548245743669
// 50.26548245743669
// 50.26548245743669

memoize 的实现原理其实也不复杂,主要还是通过闭包的原理来实现的。

function memoize(fn) {
  // 定义一个存储结果的对象。把函数传入的值作为键 对象的值就是处理结果
  const cache = {};
  return function () {
    const key = JSON.stringify(arguments); // 把传入的值转换为 字符串
    // 判断是否有缓存
    cache[key] = cache[key] || fn.apply(fn, arguments);
    return cache[key];
  };
}

柯里化

要解释什么是柯里化之前我们先来看个例子,还记得副作用的时候check_age函数么,我们来进一步优化它。

// 首先我们吧min直接做成传参来解决硬编码的问题。
function check_age(min, age) {
  return age >= min;
}
check_age(18,15);
check_age(18,20);
check_age(20,26);

观看例子中的调用我们发现 18明显被频繁使用,这就显得很不友好,不如直接在内部把min定义成18。但是我们把min作为传参是有一种考虑就是有时候验证年龄的的时候不一定会用 18。哪有什么好的方法解决这个问题呢?还是要用到闭包。

function check_age(min) {
  return function (age) {
    return age >= min;
  };
}

const check_age18 = check_age(18); 
check_age18(15);
check_age18(24);

把check_age改造成一个高阶函数,并接受一个 min 返回一个新函数函数。这样就解决了18 被重复使用的问题。这种形式就是柯里化。

所以柯里化就是把一个多元的函数转换成一个一元函数。简单来说就是当一个函数有多个参数的时候先传第一部分参数调用它(这部分参数以后不会变)然后返回一个新的函数接受剩余的参数并返回结果。

// check_age 使用 es6 的方式会更简洁。
const check_age = min => (age => age >= min);

lodash中的柯里化函数 curry

  • 功能:创建一个函数,该函数接受一个或多个func的参数,如果func所需要的参数被提供则执行func并返回执行结果,否则继续返回该函数并等待接收剩余的参数。
  • 参数:需要柯里化的函数
  • 返回值:柯里化后的函数
// 实例
const _ = require("lodash");
function get_sum(a, b, c) {
  return a + b + c;
}

const curried = _.curry(get_sum); // 生成一个柯里化函数

console.log(curried(1, 2, 3)); // 6
console.log(curried(1)(2, 3)); // 6 调用(1) 返回一个新函数等待接收剩余参数 在调用 (2, 3) 参数接收完毕,返回结果 6
console.log(curried(1)(2)(3)); // 6 同理

生成柯里化函数后,每次调用传递参数,只传递部分参数会返回一个新的函数,直到参数都传递完毕才执行结果。只要curry怎么用之后我们在写一个实际案例理解一下。

//实现一个提取字符串中的空格。通常的写法
// "".match(/\s+/g);
//如果我们要对一个数组里的数据进行提取,那这么写明显是不可复用的。
// 我们用纯函数的方式实现一个这样的方法。
// function match(reg, str) {
//   return str.match(reg);
// }
//然后在通过柯里化 避免 reg的频繁复用。
// const curried_match = _.curry(match); 
// 这么写也还是不太简洁,所以我们干脆这样
const match = _.curry(function match(reg, str) {
  return str.match(reg);
});
//我们现在通过 match 生成一个新函数来判断是否字符串里有空的字符。
const have_space = match(/\s+/g);
console.log(have_space('衫小寨主题 就是好看')); // [ ' ' ];

//那我们在写一个检测数组中字符串是否有空格的函数
const filter = _.curry(function (fn, array) {
  return array.filter(fn);
});

//因为我们已经写过一个过滤空格的函数了,所以这里可以直接使用
console.log(filter(have_space, ["衫小寨主题 就是好看"])); //[ '衫小寨主题 就是好看' ]

//当然直接这么调用 filter 意义不并大,看起来也不舒服。我们还可以改造一下,filter本身也是一个柯里化函数。
const find_space = filter(have_space);
// 结果是一样的
console.log(find_space(["衫小寨主题 就是好看"])); //[ '衫小寨主题 就是好看' ] 

通过以上代码你可能觉得麻烦,还不如一开始面向过程变成一下就写完了,但是我们现在所写的这些函数在将来都是可以重复使用的。这也是函数式编程的好处。那既然我们已经会使用lodash中柯里化函数,那接下来我们来模拟实现一个。

function curry(func) {
  return function curriedFn(...args) {
    // 判断 实参和形参 是否一致
    if (args.length < func.length) {
      // 不一致 返回一个新的 函数 等待接收之后的参数
      return function () {
        // 把当前函数的arguments 和 之前传入的 args 合并一起传入。
        return curriedFn(...args.concat([...arguments])); 
      };
    } else {
      //一致则 执行这个函数
      return func(...args);
    }
  };
}

柯里化总结

  • 函数的柯里化可以让我们给一个函数传递传递较少的参数并得到一个已经记住了某些固定参数的新函数
  • 这是一种对函数参数的“缓存”
  • 让函数变得更灵活,让函数的颗粒度更小
  • 可以把多元函数转换成一元函数,可以组合使用函数产生强大的功能

函数组合概念

我们使用纯函数和柯里化很容易写出洋葱代码 比如 h(g(f(x)));函数的组合可以让我们把细颗粒度的函数重新组合成一个新的函数。

介绍函数组组合之前,我们先理解一个概念就是数据的管道。下面这张图表示程序中使用函数处理数据的过程,给 fn 函数输入参数a,返回结果 b。可以想象成a通过一个管道得到了b。

那如果这个管道特别长,中间出现漏水的情况,我们就不能很快的知道具体是哪个地方漏水了,那我们这个时候可以把这个管道拆成多个。也就是当 fn 函数比较复杂的时候,我们把函数 fn 拆成多个小函数,此时对了中间运算产生的m和n。但在函数的组合中是不需要考虑这些结果的。

函数组合概念

  • 如果一个函数要经过多个函数处理才能得到最终的值,这个时候可以把中间过程的函数合并成一个函数。
  • 函数就相当于数据的管道,函数组合就是把这些管道连接起来,让数据穿过多个管道形成最终结果。
  • 函数组合默认是从右到做执行

我们来自己实现一个组合函数

// 实现一个简化版函数组合用来演示 先仅接受两个函数
function compose(f, g) {
  return function (value) {
    return f(g(value)); // 其实我们发现 我们的封装了一个洋葱代码。之后我们在解决
  };
}
// 上面函数只是做了组合,接下来我们在定义两个函数。
//翻转数组
function reverse(arr) {
  return arr.reverse();
}
// 获得数字第一个元素 (仅为了演示中间细节先忽略)
function first(arr) {
  return arr[0];
}

// 我们定义一个获取数组最后一个值的函数
const last = compose(first, reverse); // 顺序是右往左
// last 是通过 compose 生成的一个新的函数,而这个函数需要传入一个值,也就是一个数组
console.log(last([1, 2, 3, 4])); // 打印 4

以上我们就实现了一个简单的组合函数,但尽可以组合两个函数,那要组合多个函数怎么办呢?我们可以使用lodash中的组合函数。

  • lodash中的组合函数flow()或者flowRight(),它们都可以组合多个函数。
  • flow()是从左到右运行。
  • flowRight()是从右到左运行,这个我们比较常用
// 我们用lodash中的函数组合来 实现上一个实例里的函数顺便我们在转换成大写。
const _ = require("lodash");
const reverse = (arr) => arr.reverse();
const first = (arr) => arr[0];
const toUpper = (s) => s.toUpperCase();
// 组合函数
const f = _.flowRight(toUpper, first, reverse); // flowRight 支持传入多个函数
console.log(f(["a", "b"])); // 打印 B
// flowRight 是如何实现 可以传入任意函数并组合的呢 ?接下来我们模拟一下。
function compose(...args) {
  return function (value) {
    // args 是传入的位置数量的函数,因为默认我们要从右往左执行所以这里先把数组翻转一下
    // 然后我们要以此调用每个函数并传入 value 然后在返回给下一个函数
    return args.reverse().reduce(function (acc, fn) {
      return fn(acc);
    }, value);
  };
}

// 用es6简化一下
const compose = (...args) => (value) => args.reverse().reduce((acc, fn) => fn(acc), value);

函数的组合需要满足 组合率 ,如 组合函数 const f = compose(f, g, h);

我们可以吧g 和 h 组合,还可以把 f 和 g 组合, 结果都是一样的

lodash中的FP模块,在我们的例子中其实大部分方法lodash中都有对应的函数,但函数的组合需要柯里化的函数,所以我们每次都要在封装一下,很不友好,所以lodash中提供了FP模块。这个模块提供了使用的对函数式编程友好的方法,而且是柯里化的,提供的所有方法都是函数优先,数据之后。

  • 分享:
评论
还没有评论
    发表评论 说点什么