月影谈为什么我们要用函数式编程

Posted 奇舞周刊

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了月影谈为什么我们要用函数式编程相关的知识,希望对你有一定的参考价值。

关注奇舞周刊360前端团队奇舞团带你领略前端前沿技术

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

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

不管你在阅读本文之前属于哪一种人,又或者你是刚接触函数式编程的新人,都没有关系。本文不是研究函数式编程范式的学术研究,而函数式编程作为一个可以说是程序设计理论中最古老的编程范式,在它几十年上百年的发展历史中,已经积累了大量的资料和素材,对于想要在学术领域里完全弄明白它的同学,完全可以在网上、书店里找到各种资料。本文的重点不在于概念,而在于实战。因此,你不会听到太多各种函数式编程的名词讨论,比如诸如 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.is(add(10, 20), 30); //add(x,y) 是个纯函数,不需要为它构建测试环境

    ...

});

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

test.before(=> {

    let list = document.createElement('ul');

    list.id = 'xxxxxx';

    ...

});


test(=> {

    let list = document.getElementById('xxxxxx');

    t.is(sortList(list).innerhtml, `<ul>

        ...

    </ul>`);

});


test.after(=> {

    ...

    document.removeChild(list);

});

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

首先我们看一个例子:

//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)。在测试的时候,我们需要构建环境来测试两个函数。

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

//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(=> {

  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 有一点点类似,不过功能更强大。让我们来用它实现一个有一点点“烧脑”的效果,来作为这篇文章的结尾:

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);


奇舞周刊

——————————————————

领略前端技术 阅读奇舞周刊


长按二维码,关注奇舞周刊

以上是关于月影谈为什么我们要用函数式编程的主要内容,如果未能解决你的问题,请参考以下文章

前端必学-函数式编程(六)

视频从Cycle.js谈函数式与响应式编程

浅谈函数式编程

本期沙龙浅谈函数式编程

当我们在谈函数式编程(Functional Programming,FP),到底在谈论什么?

浅谈函数式编程与大数据