函数式编程

111 阅读6分钟

函数式编程

  • 函数式编程是一种编程范式
  • 函数式编程是一个很大的命题,在本文中将介绍几个基本概念:纯函数、柯里化(curry)、组合(compose)、容器(container)、函子(functor),希望能激起你对它的兴趣。

容器 - 如何实现链式调用

容器可以想象成一个瓶子,也就是一个对象,里面可以放各种不同类型的值。想想,瓶子还有一个特点,跟外界隔开,只有从瓶口才能拿到里面的东西;类比看看, container 回暴露出接口供外界操作内部的值,如下所示:

var Container = function (x) {
  this.__value = x;
};

Container.of = function (x) {
  return new Container(x);
};

Container.prototype.map = function (f) {
  return Container.of(f(this.__value));
};

测试使用:

Container.of(3); // Container {__value: 3}
Container.of(4); // Container {__value: 4}

var add1 = function (num) {
  return num + 1;
};
var add2 = function (num) {
  return num + 2;
};

Container.of(3).map(add1).map(add2); // Container {__value: 6}
Container.of(4).map(add2).map(add2).map(add2); // Container {__value: 10}

在这个实例中出现的 Container 是一个容器,通过 Container.of 来实例化保存值到 this.__value

add1add2 都是 纯函数,我们通过 map 函数来操作容器内的值,我们把 Container 看作数据结构,这种数据结构可以通过 map 操作,那么它就叫 functor(函子)。

纯函数 - 函数式编程的核心

什么是纯函数:纯函数是这样一种函数,即相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用。

比如 slicesplice,这两个函数的作用并别无二致。但是我们说 slice 符合纯函数的定义是因为对相同的输入它保证能返回相同的输出。

splice 的调用却会产生可观察到的副作用,这个数组被永久地改变了。

var xs = [1, 2, 3, 4, 5];

// 纯的
xs.slice(0, 3); // => [1,2,3]
xs.slice(0, 3); // => [1,2,3]

// 不纯的
xs.splice(0, 3); // => [1,2,3]
xs.splice(0, 3); // => [4,5]

在函数式编程中,我们尽量杜绝 splice 这种会改变数据的函数。我们追求的是 slice 那种可靠的,每次都能返回同样结果的函数。

看下一个例子:

// 不纯的
var num_1 = 1;
var add1 = function (num) {
  return num + num_1;
};

// 纯的
var add1 = function (num) {
  return num + 1;
};

解析:

  • 在不纯的版本中,add1 的结果将取决于 num_1 这个可变变量的值。换句话说,它取决于系统状态(system state)。因为它引入了外部的环境,从而增加了认知负荷(cognitive load)。
  • 这种依赖状态是影响系统复杂度的罪魁祸首,不仅让它变得不纯,而且导致每次我们思考整个软件的时候都痛苦不堪。

为什么要使用纯函数呢

好处:

  1. 可缓存性,因为纯函数对于相同的输入有相同的输出,所以纯函数是可以缓存运算结果的;
  2. 可移植性,因为不会受环境变量等外部状态的影响,可以方便移植;
  3. 可测试性,无需配置外部变量,一个输入一个输出,直接断言;等等。

有哪些不纯的情况呢

  1. IO 操作,你不知道你读取的内容会是怎样;
  2. 接口请求,你不确定接口返回的内容是什么;
  3. dom 操作,引起了副作用;
  4. 甚至连 console.log 都是不纯的,因为它有副作用;等等。

函数柯里化

什么是柯里化(curry)?curry 的概念很简单,只传递给函数一部分参数来调用它,让它返回一个函数去处理剩下的参数

// 普通函数
const add = (x, y) => x + y;
add(1, 2); // 3

// 柯里化函数
const add = (x) => (y) => x + y;
const value = add(1);
value(2);

我们把 add 函数通过柯里化变成了接受部分参数并返回一个处理剩余函数且返回结果的函数。在实际环境中我们可能用到 ramda 这样的库来帮助我们实现柯里化。

var R = require("ramda");
var add = function (x, y) {
  return x + y;
};
var addTen = R.curry(add)(10);

addTen(1); // 11
addTen(2); // 12

柯里化是函数式编程的工具,他能实现预加载函数、分步取值、避免重复传参、锁定函数运行环境等等功能。

实现一个通用的相乘函数
const multiply = (x) => (y) => x * y;
const multiply10 = multiply(10);

const value1 = multiply10(1); // 10
const value2 = multiply10(2); // 20
实现一个可累加的 add 函数
const add = (...args) => {
  const num = args.reduce((a, b) => a + b);
  const _add = (x) => add(num, ...x);
  _add.toString = () => num;
  return _add;
};

// 调用方式
+add(1, 2, 3)(4); // 10

函数组合

函数组合(compose)指的是将两个或多个函数组合在一起。

const compose = (a, b) => (x) => a(b(x));

const addOne = (x) => x + 1;
const addTwo = (x) => x + 2;

const addStree = compose(addTwo, addOne);

addStree(1); // 4

组合后的函数遵循:从右往左执行,例子:

const ride10 = (x) => x * 10;
const addOne = (x) => x + 1;

const newNum = compose(addOne, ride10);

newNum(1); // 11
newNum(10); // 101

函子 functor

函子(functor)是函数式编程中的一个概念,它表示一个特殊的容器,该容器包含了值和函数。

回顾开始的例子:

var Container = function (x) {
  this.__value = x;
};

Container.of = function (x) {
  return new Container(x);
};

Container.prototype.map = function (f) {
  return Container.of(f(this.__value));
};

现在我们转换角度,把调用 Container.of 返回的对象看作一种数据结构 Container {__value: 3} ,这种数据结构只能使用 map 方法进行操作,类似这样的数据结构被称为 functor

这样做的好处是什么呢?我们能在不离开 容器(Container) 的情况下操作容器里面的值,操作完成之后又放回容器。

我们可以不断的进行这一操作,就像 组合函数 一样。这是一种抽象,我们让容器保存值,并且请求容器通过 map 里的函数去操作值。

下面是一个写情书的小例子

// 一封情书
class Mail {
  constructor(content) {
    this.content = content;
  }
  map(fn) {
    return new Mail(fn(this.content));
  }
}

// 1. 写情书
let mail1 = new Mail("love");

// 2. 读了信
let mail2 = mail1.map(function (mail) {
  return read(mail);
});

// 3. 涂抹
let mail3 = mail2.map(function (mail) {
  return cross(mail);
});

// 4. 后置
mail3.map(function (mail) {
  return read(mail);
});

// 链式
new Mail("love").map(read).map(cross).map(read);

// Functor遵守了特定数据协议
// 具有一个通用map方法,返回新实例
// 结合外部的解码运算能力,处理 => 管道中不同层级又很纯净的单元操作

一道简单面试题改造

	// 1. url中参数式如何展示数组的?
  // location.search => '?name[]=progressive$%coding&name[]=objective$%coding&name[]=functional$%coding'
  // 2. 参数提取拼接数组
  // ['progressive$%coding', 'objective$%coding', 'functional$%coding']
  // 3. 转换成数组对象做存储 + 字符串处理
  // [{name: 'Progressive Coding'}, {name: 'Objective Coding'}, {name: 'Functional Coding'}]

  // 解:
  // 1. 字符串拆分数组 遍历
  // 2. 字符串 => key value 遍历

  const _array = ['progressive$%coding', 'objective$%coding', 'functional$%coding'];
  const _objArr = [];
  const nameParser = (array, objArr) => {
    // 对于数组的处理部分
    array.forEach(item => {
      let names = item.split('$%');
      let newName = [];

      // 对于name进行处理
      names.forEach(name => {
        let nameItem = name[0].toUpperCase() + name[1].slice(1);

        newName.push(nameItem);
      })
      objArr.push({
        name: newName.join(' ');
      })
    })
    return objArr;
  }

  // 问题:
  // 1. 过程中存在逻辑包裹 - 看完争端逻辑才明白在做什么
  // 2. 存在临时变量,收尾封闭 - 迭代拓展难度较高

改造为函数式:

// step1. 需求分析 => 数组 > 数组对象 => [字符串 > 对象]
// nameParser => [objHelper :: string > object]

// step2. 功能明确 => objHelper = formatName + assembleObj

// step3. 功能拆分 => objHelper = [(split + capitalize + join)] + assembleObj

// step4. 代码实现
const _array = [
  "progressive$%coding",
  "objective$%coding",
  "functional$%coding",
];

// 原子操作
const assembleObj = (key, x) => {
  let obj = {};

  obj[key] = x;
  return obj;
};

const capitalize = (str) => str[0].toUpperCase() + str.slice(1);

// 声明结构 - 组合 - ramda
const formatName = R.merge(join(" "), map(capitalize), split("$%"));

const objHelper = R.merge(assembleObj("name"), formatName);

const nameParser = map(objHelper);

nameParser(_array);