漫谈 JS 函数式编程(一)

数据抽象或过程抽象 纯函数 函数式编程能够减少系统中的非纯函数 Ramda.js

这可能是最简单易懂的函数式编程介(扯)绍(淡)了

目前前端界(以及其他一些领域)对函数式编程大体上两种态度,一些人是觉得函数式编程特牛逼,尤其是现在许多新生的框架和库都在标榜自己的函数式特征。而另一些人,又觉得函数式编程学起来很难,而且似乎也没有什么卵用,理由是在自己经历的项目里面很难看到具体的函数式编程应用场景,甚至其中许多人认同一个观点,觉得函数式编程只适合于学术研究,很难在工程项目中实际使用。

不管你在阅读本文之前属于哪一种人,又或者你是刚接触函数式编程的新人,都没有关系。本文不是研究函数式编程范式的学术研究,而函数式编程作为一个可以说是程序设计理论中最古老的编程范式,在它几十年上百年的发展历史中,已经积累了大量的资料和素材,对于想要在学术领域里完全弄明白它的同学,完全可以在网上、书店里找到各种资料。本文的重点不在于概念,而在于实战。因此,你不会听到太多各种函数式编程的名词讨论,比如诸如 Curry、Mond 之类的专业术语。相反,我们主要来讨论函数式编程在前端领域内使用的一些实际例子,了解为什么前端需要学习函数式编程,使用函数式编程写代码能给我们带来什么。如果弄明白了这些,那么关于函数式编程不实用的谣言也就不攻自破了。

数据抽象或过程抽象

为什么我们接受面向过程或面向对象思想很容易,而我们要完全接受函数式编程却感觉难得多?

我认为这个问题大体上可以这么解释:

人脑本能地容易理解“看得见“、“摸得着”的物体,对于“运动”和“变化”一类不着形的东西,人脑理解起来要略微地费劲一些。而人类要做好一件复杂的事情,大脑有两种抽象方向,一种是对实体进行抽象,另一种是对过程进行抽象:

简答来说,即在软件设计的过程中,如果要保证软件产品的功能稳定可用,同时要保证它的灵活性和可扩展性,那么系统就要有变化的部分和不变的部分。哪些部分应当设计成“不变”,哪些部分应当设计成“可变”,在这个取舍过程中,FP(函数式编程)和 OOP(面向对象编程)正是走了两条不同的路线。

面向对象对数据进行抽象,将行为以对象方法的方式封装到数据实体内部,从而降低系统的耦合度。而函数式编程,选择对过程进行抽象,将数据以输入输出流的方式封装进过程内部,从而也降低系统的耦合度。两者虽是截然不同,然而在系统设计的目标上可以说是殊途同归的。

面向对象思想和函数式编程思想也是不矛盾的,因为一个庞大的系统,可能既要对数据进行抽象,又要对过程进行抽象,或者一个局部适合进行数据抽象,另一个局部适合进行过程抽象,这都是可能的。数据抽象不一定以对象实体为形式,同样过程抽象也不是说形式上必然是 functional 的,比如流式对象(InputStream、OutputStream)、Express 的 middleware,就带有明显的过程抽象的特征。但是在通常情况下,OOP更适合用来做数据抽象,FP更适合用来做过程抽象。

纯函数

再具体深入下去之前,我们先来解答一个问题,那就是为什么用 FP 或过程抽象能够降低系统的耦合度。这里我们要先理解一个概念,这个概念叫“纯函数”。

根据定义,如果一个函数符合两个条件,它被称为纯函数:

此函数在相同的输入值时,总是产生相同的输出。函数的输出和当前运行环境的上下文状态无关。 此函数运行过程不影响运行环境,比如不会触发事件、更改环境中的对象、终端输出值等。

简单来说,也就是当一个函数的输出不受外部环境影响,同时也不影响外部环境时,该函数就是纯函数。

JavaScript 内置函数中有不少纯函数,也有不少非纯函数。

比如以下函数是纯函数:

String.prototype.toUpperCase Array.prototype.map Function.prototype.bind

以下函数不是纯函数:

Math.random Date.now document.body.appendChild Array.prototype.sort

为什么要区分纯函数和非纯函数呢?因为在系统里,纯函数与非纯函数相比,在可测试性、可维护性、可移植性、并行计算和可扩展性方面都有着巨大的优势。

在这里我用可测试性来举例:

对于纯函数,因为是无状态的,测试的时候不需要构建运行时环境,也不需要用特定的顺序进行测试:

test(t => {
    t.is(add(10, 20), 30); //add(x,y) 是个纯函数,不需要为它构建测试环境
    ...
});

对于非纯函数,就比较复杂:

test.before(t => {
    let list = document.createElement('ul');
    list.id = 'xxxxxx';
    ...
});

test(t => {
    let list = document.getElementById('xxxxxx');
    t.is(sortList(list).innerHTML, `<ul>
        ...
    </ul>`);
});

test.after(t => {
    ...
    document.removeChild(list);
});

函数式编程能够减少系统中的非纯函数

首先我们看一个例子:

JS Bin on jsbin.com

//two impure functions

function setColor(el, color){
  el.style.color = color;
}

function setColors(els, color){
  els.forEach(el => setColor(el, color));
}

let items1 = document.querySelectorAll('ul > li:nth-child(2n + 1)');
let items2 = document.querySelectorAll('ul > li:nth-child(3n + 1)');

setColors(items2, 'green');
setColors(items1, 'red');

在这里我们有两个彼此依赖的非纯函数,setColor(el, color) 和 setColors(els, color)。在测试的时候,我们需要构建环境来测试两个函数。

现在,我们用函数式编程思想来改造这个系统:

JS Bin on jsbin.com

//only one impure function

function batch(fn){
  return function(target, ...args){
    if(target.length >= 0){
      return Array.from(target).map(item => fn.apply(this, [item, ...args]));
    }else{
      return fn.apply(this, [target, ...args]);
    }
  }
}

function setColor(el, color){
  el.style.color = color;
}

let setColors = batch(setColor);

let items1 = document.querySelectorAll('ul > li:nth-child(2n + 1)');
let items2 = document.querySelectorAll('ul > li:nth-child(3n + 1)');

setColors(items2, 'green');
setColors(items1, 'red');

在这里,我们建立一个过程抽象的高阶函数 batch(fn),这个函数的作用是,对它的输入函数返回一个新的函数,这个函数与输入函数的区别是,如果调用的第一个实参是一个数组,那么将这个数组展开,用每一个值依次调用输入函数,返回一个数组,包活每次调用返回的结果。

batch(fn) 本身虽然看似复杂,但是有意思的事,这个函数无疑是纯函数,所以 batch(fn) 自身的测试是非常简单的:

test(t => {
  let add = (x, y) => x + y;
  let listAdd = batch(add);

  t.deepEqual(listAdd([1,2,3], 1), [2,3,4]);
});

由于我们上面举的例子 setColor 和 setColors 虽然不是纯函数,但是却非常简单,因此似乎设计 batch(fn) 的意义不大,有把系统变得更复杂的嫌疑。然而,对于有许多操作 DOM 的函数的框架或库,有了 batch(fn),我们就可以实现很简单的接口(对单一元素操作),然后利用 batch(fn) 获得更复杂接口(对元素进行批量操作),从而大大降低系统本身的复杂的,提升可维护性。

注意一点,batch(fn) 输出的函数有副作用,然而 batch(fn) 用闭包将输出的函数的副作用限制在了 batch(fn) 的作用域内。

Ramda.js 的 lift 方法

Ramda.js 的 lift 方法和 batch 有一点点类似,不过功能更强大。让我们来用它实现一个有一点点“烧脑”的效果,来作为这篇文章的结尾:

JS Bin on jsbin.com

async function reducer(promise, action){
  let res = await promise;
  return action(res);
}

function continuous(...functors){
  return async function(input){
    return await functors.reduce(reducer, input)
  }
}

function sleep(ms){
  return new Promise(resolve => setTimeout(resolve, ms));
}

async function setColor(item, color){
  await sleep(500);
  item.style.color = color;
}

let comb = R.lift((el, color) => {
  return [el, color];
});

let changeColorTo = (args) => R.partial(setColor, args);

let items = Array.from(list.children);

let task = R.map(changeColorTo, comb(
  items,
  ['red', 'orange', 'yellow']
));

continuous(...task)(0);

-- 期待下一篇吧 --

文章来源:

Author:十年踪迹
link:https://www.h5jun.com/post/js-functional-1.html