函数式编程

1,218 阅读10分钟

1. 介绍

函数式编程(Functional Programming,简称FP)不论是在前端还是后端,都逐渐热门起来,比如React(核心思路数据即视图),Vue3.0 的 Composition API ,Redux ,Lodash,Underscore 等等前端框架和库,都能看到函数式编程思维的身影,实际上函数式编程绝对不是最近几年才被创造的编程思想,函数式编程的起源,是一门叫做范畴论(Category Theory)的数学分支。

2. 学习函数式编程

  • 函数式编程的理念
  • 函数式编程特性(声明式编程、副作用、纯函数)
  • 柯里化、函数组合
  • 函子(容器),map

3. 举一个例子

'id=1'
// 转换成
{id: 2}

过程式编程写法:

用传统的编程思路,一上来就可以撸代码,定义临时变量,码起来:

var str1 = 'id=1';
var numArr = num.split('=');
numArr[1] = numArr[1] * 2;
var obj = {
  [numArr[0]]: numArr[1]
};

这里就是按照过程式编程的思路实现,完全面向过程。这样当然能完成任务,但是结果就是出现了一堆的中间临时变量,光看到这些变量的名字就令人到绝望,同时中间包含实现的逻辑,通常这样一个实现思路需要从头到尾读完才知道它做了什么,中间一旦出现错误,排查起来也是问题。

这里我们先不用函数式的思路来解决这个问题,等学完后函数式的基础内容,我们再回头看看这个问题用函数式的方式怎么实现。

4. 函数式编程

函数式编程的思维方式和过程式是完全不同,它着眼于函数,并不是过程,强调的是如何通过函数的组合变换来解决问题,而不是通过写什么样的语句去解决问题。当你的代码越来越多的时候,这种函数拆分和组合就会产生强大的力量。

其实在初中的时候就已经学习过函数,例如一次函数,二次函数,反比例函数等等的,其实不同的函数其实都是描述集合和集合之间的转换关系,并且输入函数的值有且只有一个输出的值。

0fc53632ba1b9b3e16e1d986ef3b5f81.svg

也就是说函数实际上其实描述一个关系,又或者说是一种映射。而且函数相互之间是可以组合,就是一个函数的输出作为另一个函数的输入。

在编程中,需要我们处理的只有"数据"和”关系“,而描述”关系“就是用函数,那么总的来说我们编程也只需要实现函数,也就是找出”关系“。一旦实现了这个函数(”关系“),数据通过函数,也就能转换成别的目标数据。

其实我们可以把函数式编程想象成流水线,把输入当作成原料,把输出当作成产品,原材料通过一个个的流水线上的处理步骤(函数),最终生成产品(结果)。

02.png

5. 函数式编程的特点

5.1 函数是”一等公民“

函数式编程实现就需要操作函数,所以这个特性意味着函数和其他数据类型是一样的地位,也就是可以把函数赋值其他变量,也可以作为参数传入函数,也可以作为别的函数的返回值,例如:

function fn1(x) {
  return x + 2;
}

// 1. 把函数赋值给其他变量
var f = fn1;
f(1); // 3 

// 2. 一个函数作为参数传入另外一个函数
function fn2(f) {
  var num = f(3) - 2;
  return num;
}
fn2(fn1); // 3

// 3. 一个函数作为另一个函数的返回值
function fn3(x) {
  return 2 * fn1(x);
}

fn3(1); // 6

5.2 声明式编程

var arr = [{name: "张三"}, {name: "李四"}, {name: "王五"}];
// 转换成
['张三', '李四', '王五'];

// 命令式
var names = [];
for (var i = 0; i < arr.length; i++) {
  names.push(arr[i].name);
}

// 声明式
var names1 = arr.map(function (obj) { return obj.name; });

命令式一开始先实例化一个数组,然后执行完这句代码之后,解释器才会执行后面的代码。然后再循环arr数组,然后还要增加一个计数器i,然后把各种逻辑语句都展示出来。

而使用map版本的就是一个表达式,没有执行顺序的要求,也没有暴露出来的循环语句,通过一个map工具实现;这种编程风格就是声明式编程,这样的好处代码的可读性特别高,因为大多数的声明式代码都是接近自然语言。声明式编程指明的是做什么,而非怎么做

5.3 没有副作用

函数式编程是基于没有副作用的前提的,如果函数有副作用,那么这就是过程式编程(命令式编程),那我们得知道常见的副作用都有哪些。

1. 外界交互导致的副作用:

var result = 0;

function fn(x) {
  for (let i = 0; i < x; i++) {
    result += i;
  }
  return result;
}

fn(3); 
fn(3);
// 两次接收的参数相同,但是函数fn得出的结果却是不一样的,这里就是外部作用域的变量在函数fn中使用导致的副作用。那么要怎么做才能解决出现的这个副作用?

2. 调用I/O

包括:读写文件、网络、数据库

let res = await fetch('/url');
// 虽然每一次传入的参数都是一样,但是返回的结果却是不一样(有可能成功,失败)

3. 磁盘检索

function getRandom() {
  return Math.random();
}

4. 抛出异常

function fn(x) {
  throw new Error();
}

注意:

  1. 副作用之所以不好,是因为结果不可预测。一个函数如果没有副作用,随时执行,给定相同的输入,必定得到相同的结果。
  2. 那是不是函数式编程就不需要副作用呢?并不是。只是在需要时限制它们,因为没有副作用,我们的代码就只能进行计算。经常使用的读写数据库,网络请求,读写文件,都是需要用到的,所以并不是有副作用就不用了。
  3. 保证函数没有副作用,就可以保证得出的数据的不变性,可以避免相互引用带来的各种问题。

5.4 纯函数

纯函数就是函数接收相同的输入数据,得出的输出结果不会发生变化,不依赖外部状态的变化。也就是没有任何副作用的函数。

用splice和slice举个例子:

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

// 纯的
nums.slice(0, 3); // [1, 2, 3]

nums.slice(0, 3); // [1, 2, 3]

nums.slice(0, 3); // [1, 2, 3]

// 不纯的
nums.splice(0, 3) // [1, 2, 3]

nums.splice(0, 3) // [4, 5]

nums.splice(0, 3) // []

上面的例子我们就可以判断出slice是纯函数,而splice就是带有副作用的函数。

注意:

  1. 之所以强调纯函数,一是因为使用纯函数输入相同的就会得出相同的结果,这样的代码更利于维护,优化代码的时候也不会影响到其他代码的执行;二是利用纯函数的特点,可以提前缓存函数执行的结果。

    // 缓存例子
    function memoize(fn) {
      var cache = {};
      return function() {
        var arg_str = JSON.stringify(arguments);
        cache[arg_str] = cache[arg_str] || fn.apply(null, arguments);
        console.log(JSON.stringify(cache));
        return cache[arg_str];
      }
    }
    
    // 
    var fibonacci = memoize(n => n < 2 ? n : fibonacci(n - 1) + fibonacci(n - 2));
    
    fibonacci(4); // 第一次缓存了 1,2,3, 4
    fibonacci(5); // 第二次直接获取 1,2,3,4对应的结果
    
  2. 纯函数没有依赖外部作用域,所以很容易理解。

6. 函数式编程构建代码

函数式编程有两种操作是必不可少的,那就是柯里化(curry)函数组合(compose),柯里化其实就是工厂流水线上的一个个的加工站,函数组合就是多个加工站组成的流水线。

6.1 柯里化

所谓"柯里化",就是把一个多参数的函数,转化为单参数函数,也叫单元函数

fn(a, b, c) -> fn(a)(b)(c)

实现一个两个数的累加方法的柯里化:

function add(x, y) {
  return x + y;
}

// add 柯里化
function add1(x) {
  return function(y) {
    return x + y;
  }
}

const increment = add1(1);
inccrement(10); // 11

注意:

  1. 柯里化强调的是单参数函数,和部分函数应用不一样,部分函数应用强调的是固定任意参数。

    // 柯里化
    fn(1,2,3) -> fn(1)(2)(3)
    // 部分函数应用
    fn(1,2,3) -> fn(1,2)(3) | fn(1)(2, 3)
    

6.2 高级柯里化

有没有函数可以直接把普通函数转换成柯里化函数呢,答案是肯定的。大部分的库都有柯里化的实现,例如lodash,underscore这些库,那我们来模拟实现一下高级柯里化函数curry

function curry(fn) {
  // 判断实参和形参的个数, 这里用剩余参数的写法,为了让args参数是一个数组
  return function curried(...args) {
    if (args.length < fn.length) {
      return function () {
        return curried(...args.concat(Array.from(arguments)));
      }
    }
    // 当形参和实参个数相同则调用fn函数
    return fn(...args);
  }
}

// add方法
function add(a, b, c) {
  return a + b + c;
}

const addCurry = curry(add);
addCurry(1)(2)(3);// 6
const addCurry1 = addCurry(1);
addCurry1(2, 3); // 6 ;这里并不是纯粹的柯里化,更像是部分函数应用

上面我们实现的curry函数并不是纯粹的柯里化函数。这个函数的实现没有限制参数个数,所以会根据参数返回对应的柯里化函数或者结果值。这里的高级柯里化函数既可以实现柯里化,也可以实现部分函数应用,但是柯里化并不是部分函数应用。

柯里化函数的应用

const replace = curry((a, b, str) => str.replace(a, b));
const replaceSpaceWith = replace(/\s*/g);
const replaceSpaceWithComma = replaceSpaceWith('*');
const replaceSpaceWithDash = replaceSpaceWith('|');

最后再回想一下,函数式编程用柯里化的原因是?这个问题要结合函数组合来想才能完整,接下来看函数组合来找答案吧。

函数组合(compose)

函数组合,就是把多个函数合并起来组合成一个新的函数。这里可以理解为每一个流水线站点(柯里化函数),组合起来就成了一条流水线。把柯里化函数组合起来,这个就是函数组合的工作。如果组合的不是一个柯里化函数,那么整合就不是直线型的,而是会出现分支。

// fn2->fn1,fn2执行完后的结果传入fn1函数
// 组合函数compose(fn1, fn2);
function compose(fn1, fn2) {
  return function (p) {
    return fn1(fn2(p));
  }
}
// 那么思考一个问题,如果fn1,fn2并不是纯函数,会出现什么结果?

compose.png

compose函数的实现

function compose(...fns) {
  return function (value) {
    return fns.reduce(function (args, fn) {
      return fn(args);
    }, value)
  }
}

函数组合的追踪Debug

当遇到函数组合出现问题,那要怎么调试,这个时候可以加入一个追踪函数(柯里化函数)trace调试问题函数。

var trace = curry((tag, x) => {console.log(tag, x); return x;});

var f = compose(fn1, fn2, fn3, trace('fn3之后'), fn4);

Point Free

PointFree 编程风格指的就是,函数无需提及需要操作的数据是什么样的。也就是函数编写过程中不出现参数,而只是通过函数组合生成新的函数的调用时候传入即可

// PointFree写法
var f = compose(fn1, fn2, fn3, fn4);
f(1);

// 非PointFree的写法
function f(num) {
  var n1 = fn1(num);
  var n2 = fn2(n1);
  var n3 = fn3(n2);
  var n4 = fn4(n3);
  return n4;
}
f(1);

PointFree的使用:

  1. pointFree给我们带来的就是使代码更加简洁和更易理解(无需考虑参数命名、精简代码),但并不是强迫自己写的代码一个参数都不能出现,这不实际,要具体问题具体分析。
  2. 学习编程范式的目的是为了让自己写出更加易懂,便于维护的代码,并不是增加自己的编程成本,不能本末倒置。

6.5 通过函数组合实现最开始的例子

'id=1'
// 转换成
{id: 2}
// 函数组合
function compose(...fns) {
  return function (value) {
    return fns.reduce(function (args, fn) {
      return fn(args);
    }, value);
  };
}
// 1. 字符串转数组
var split = function (str) {
  return str.split("=");
};
// 2. 数组第二个元素乘以2
var double = function (arr) {
  arr[1] = arr[1] * 2;
  return arr;
};
// 3. 数组转对象
var arrToObj = function (arr) {
  return { [arr[0]]: arr[1] };
};

// 组合(1,2,3)方法
var strToObj = compose(split, double, arrToObj);
var result = strToObj('id=1');
console.log(result);

7. 函数式编程之Functor(函子)

通过上面的学习,知道了函数式编程的是怎么实现的了,就是通过一个compose函数组合,把一个个的curry化函数(纯函数)组合起来。接下来我们学习函数式编程如何把副作用控制在可控范围、异常处理、异步操作等等。

7.1 什么是Functor(函子)

先用函子的方式处理上面的例子

function Functor(x) {
  return {
    map: function (fn) {
      return Functor(fn(x));
    }
  }
}

Functor('id=1')
  .map(str => str.split('='))
  .map(arr => {arr[1] = arr[1] * 2; return arr;})
	.map(arr => ({[arr[0]]: arr[1]}))

上面的代码,Functor就是一个函子,它的map方法接受函数fn作为参数,然后返回一个新的函子,里面包含的值是被fn处理过(字符串转数组,数组元素乘以2,数组转换成对象)。一般来说,函子的标志就是容器具有map方法。通过这个方法把容器中的值,映射到新的容器(函子),函子是范畴论里面的概念。

函数式编程里面的运算,都是通过函子完成的,即运算不直接针对值,而是针对这个值的容器(函子)。函子一般对外都具有对外接口map方法,传入的fn相当于运算符,通过fn去改变函子内的值。

学习函数式编程实际上就是学习容器(函子)的各种运算。因为可以把运算封装在函子里面,所以又衍生出各种不同类型的函子,有多少种运算,就有多少种函子。函数式编程就变成了运用不同函子去解决不同的问题。

上面的例子我们还是没有拿到最后的值,那要怎么做才能拿到最后转换的值呢?增加一个输出方法output,把最后的结果输出来。

function Functor(x) {
  return {
    map: function (fn) {
      return Functor(fn(x));
    },
    output: function (fn) {
      return fn(x);
    }
  }
}

Functor('id=1')
  .map(str => str.split('='))
  .map(arr => {arr[1] = arr[1] * 2; return arr;})
	.map(arr => ({[arr[0]]: arr[1]}))
	.output(x => x); // {id: 2}

7.2 Either函子

如果出现if....else...try....catch...的代码逻辑,我们要怎么设计函子,这时候就需要分情况处理了。

// 以try-catch为例子,try的逻辑交给Left函数处理,catch的逻辑交给Right处理
function Left(x) {
  return {
    map: function(fn) {
      return Left(fn(x));
    },
    output: function (fn) {
      return fn(x);
    }
  }
}

function Right(x) {
  return {
    map: function(fn) {
      return Right(x);
    },
    output: function(fn) {
      return fn(x)
    }
  }
}

// 定义tryCatch
function tryCatch(fn) {
  try {
    return Left(fn());
  } catch(e) {
    return Right(e);
  }
}

// 错误,catch捕获进入Right
tryCatch(() => a()).map(x => console.log('map')).output(x => x);
// 正确,进入Left
tryCatch(() => 3).map(x => x + 3).output(x => x);

上面的tryCatch函数,catch部分执行的Right函数更像是兜底操作,Right函数的map方法,并不会执行传入的函数,这样就保证就算出现错误,都能保证链式调用没有错误。

思考:能不能把Left函数和Right函数合并呢?

function Either(left, right) {
  return {
    map: function (fn) {
      return right ? Either(right) : Either(fn(left))
    },
    output: function (fn) {
      return right ? fn(right) : fn(left);
    }
  }
}

// 定义tryCatch
function tryCatch(fn) {
  try {
    return Either(fn());
  } catch(e) {
    return Either(null, e);
  }
}

tryCatch(() => a()).map(x => console.log('map')).output(x => x);
tryCatch(() => 3).map(x => x + 3).output(x => x);

7.3 是什么?为什么?怎么用?

函子究竟是什么?

这里为了不增加大家的负担,就不引入范畴论,感兴趣的可以自行搜索。Functor就是一个带有map方法的容器,通过map方法把原来容器的值映射到新的容器上对应的新的值。

为什么要用函子?

把值放到容器(函子)内,然后通过map操作它,这么做到底是为了什么呢?其实就是通过函子把一个个的小函数组合成更高级的函数。

怎么用函子?

其实函子的概念在代码中其实早已用过,只是那时候不知道原来这就是函子,比如:Array的mapfilter、jQuery的cssstyle、Promise的thencatch方法。所以同样的类型,所以可以不断地链式调用。

最后

鉴于文章篇幅关系,具体的函子应用就不在这篇文章继续写下去。

参考连接