函数式编程初探


2019-9-3 前端基础

函数式编程初探

前言

继"面向对象编程"之后,"函数式编程"会成为另一种编程的主流范式(paradigm),也就是如何编写程序的方法论。那什么是编程范式呢?这里我引用一段来自维基百科关于编程范式的说明贴在下面

编程范例是一种根据编程语言的特性对编程语言进行分类的方法。语言可以分为多种范例。一些范例主要涉及对语言执行模型的影响,例如允许副作用,或者操作序列是否由执行模型定义。其他范例主要关注代码的组织方式,例如将代码分组为单元以及代码修改的状态。还有一些人主要关注语法和语法的风格。
常见的编程范例包括:

  • 程序员指示机器如何改变其状态的必要性,
    • 程序这组指令转换成程序,
    • 面向对象的,它将指令与它们所处的状态部分组合在一起,
  • 声明中,程序员只声明了期望的结果的性质,但不知道如何计算它
    • 功能,其中所期望的结果被声明为一系列功能应用的价值,
    • 将所需结果声明为关于事实和规则系统的问题的答案的逻辑,
    • 数学,其中所需结果被声明为优化问题的解决方案

编程范式从概念上来讲指的是编程的基本风格和典范模式。
换句话说其实就是程序员对于如何使用编程来解决问题的世界观和方法论。
如果把一门编程语言比作兵器,它的语法、工具和技巧等是招法,那么它采用的编程范式也就是是内功心法。

ok,理论就简单的说这些,有一点理解就好,那么们来窥探这一方世界吧,let's go

高阶函数的定义


要是想要弄明白函数式编程首先要明白什么是高阶函数。

函数可以作为参数被传递
函数可以作为返回值输出

明白这个初步定义后,那我们来看一点小例子:

使用ES6自带的高阶函数来编写代码
假如,我们有这样一个数组:

const classA = [
    {
        name: '张三',
        age: 17
    },
    {
        name: '李四',
        age: 15
    },
    {
        name: '王五',
        age: 16
    },
]
1
2
3
4
5
6
7
8
9
10
11
12
13
14

有一个需求,是找出班级中16岁年纪的学生,我们使用低阶函数做筛选是这样的:

let student = [];
for (let i = 0; i < classA.length; i++) {
    if (classA[i].age === 16) {
        student.push(class[i])
    }
}
1
2
3
4
5
6

使用高阶函数是这样的:

const student = classA.filter( item => item.age === 16 )
1

那么使用这样的高阶函数有什么好处呢,有两点:

  • 第一,同等复杂度的代码,高阶函数能让实现更加简单
  • 第二,高阶函数能够非常方便的拆分逻辑

比如说,这样一个筛选学生的函数,可以拆成两部分:

const isAge = item => item.age === 16;
const result = classA.filter(isAge);
1
2

这样拆分后,逻辑就分为了两个部分,第一部分是判断年纪的函数,第二部分是筛选结果的函数。

如果,以后我们的需求有了变化,不筛选学生年纪了,改成了筛选学生姓名,或者一些其它的东西,那么我们只需要改动判断年纪的函数就行了,筛选结果的函数不变。

嗯,可能有人会说,这太简单了,那么,稍微来点难度的东西!

假如,我们有这样一个数组:

[
    {
        name: '张三',
        age: '26',
        price: '1000'
    },
    {
        name: '李四',
        age: '25',
        price: '3655'
    },
    {
        name: '王五',
        age: '30',
        price: '8888'
    },
]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

我们要实现上面的功能,使用高阶函数来做转换:

const result = array.reduce((value, item, index) => {
  value[index] = {
    name: item[0],
    age: item[1],
    price: item[2]
  };
  return value;
}, []);
1
2
3
4
5
6
7
8

这里我们使用了ES6的高阶函数reduce,具体相关介绍可以去看凹凸实验室写的JavaScript中reduce()方法不完全指南;

ok,到了这里,已经对函数式编程有了些简单的概念了,我所理解的函数式编程是:

编写代码的时候,函数式编程更多的是从声明式的方法,而传统的编程更多的是命令式的方法。例如,上面的筛选学生年纪,传统的编程思想是,我创建了什么,我循环了什么,我判断了什么,得出了什么结果;函数式编程的思想是,我声明了一个筛选的函数,我声明了一个判断的函数,我把这两个函数结合起来,得出了一个结果。

从函数式编程到节流函数;

节流函数说白了,就是一个控制事件触发频率的函数,以前可以一秒内,无限次触发,现在限制成500毫秒触发一次,在短时间内只触发一次,达到优化的目的,如下:

throttle(fn, wait=500) {
    if (typeof fn != "function") {
        // 必须传入函数
        throw new TypeError("Expected a function")
    }
    // 定时器
    let timer,
    // 是否是第一次调用
    firstTime = true;
    
    // 这里不能用箭头函数,是为了绑定上下文
    return function (...args) {
        // 第一次
        if (firstTime) {
            firstTime = false;
            fn.apply(this,args);
        }
        
        if (timer) {
            return;
        }else {
            timer = setTimeout(() => {
                clearTimeout(timer);
                timer = null;
                fn.apply(this, args);
            },wait)
        }

    }
}

// 单独使用,限制快速连续不停的点击,按钮只会有规律的每500ms点击有效
button.addEventListener('click', throttle(() => {
    console.log('hhh')
}))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

写好了这样一个高阶函数后,我们就可以在各处调用了,比如:

// 有一个点击增加的功能,但是要求最少过了1秒才能增加一次,就可以
const add = x => x++;
throttle(add,1000);

// 又有了一个减少的功能,但是要求最少2秒减少一次
const cutDown = x => x--;
throttle(cutDown,2000);
1
2
3
4
5
6
7

到这里已经明白了什么是高阶函数,但是还不够,还需要了解一些函数式编程的重要概念

纯函数

在函数式编程的概念中,还有一个重要的概念是纯函数,那么什么是纯函数呢? 在此,我们先了解一下什么叫数学函数

一般的,在一个变化过程中,假设有两个变量 x、y,如果对于任意一个 x 都有唯一确定的一个y和它对应,那么就称 x 是自变量,y 是 x 的函数。x 的取值范围叫做这个函数的定义域,相应 y 的取值范围叫做函数的值域。

以上定义,在初中数学咱们都应该学过...

换句话说,函数只是两种数值之间的关系:输入和输出。

尽管每个输入都只会有一个输出,但不同的输入却可以有相同的输出。下图展示了一个合法的从 x 到 y 的函数关系; 与之相反,下面这张图表展示的就不是一种函数关系,因为输入值 5 指向了多个输出:

那回过头来看看什么叫纯函数?

纯函数是这样一种函数,即相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用。 他的重点在于“相同的输入,永远会得到相同的输出”,后面所说的副作用也是为了满足这一点。

根据定义可以看出纯函数其实就是数学函数,即表示从输入的参数到输出结果的映射。

而没有副作用的纯函数显然都是引用透明的。

引用透明性(Referential Transparency)指的是,如果一段代码在不改变整个程序行为的前提下,可以替换成它的执行结果。

说了半天,什么是副作用(Side Effects)?

副作用是在计算的过程中,系统状态的一种变化,或者与外部世界进行的可观察的交互。

副作用可能包含,但不限于以下行为:

  • 更改文件系统
  • 往数据库中插入记录
  • 发送一个 http 请求
  • 改变数据
  • 打印 log
  • 获取用户输入
  • DOM 查询
  • 访问系统状态
  • ...

只要是跟函数外部环境发生的交互就都是副作用——这一点可能会让你怀疑无副作用编程的可行性。函数式编程的哲学就是假定副作用是造成不正当行为的主要原因。

当然这并不是说,要禁止使用一切副作用,而是说,要让它们在可控的范围内发生。在后面讲到函子(functor)和单子(monad)的时候我们会学习如何控制它们。

纯函数的好处都有啥

面向对象语言的问题是,它们永远都要随身携带那些隐式的环境。你只需要一个香蕉,但却得到一个拿着香蕉的大猩猩...以及整个丛林
by Erlang 作者:Joe Armstrong

所以使用纯函数将会有以下好处:

  • 可缓存性(Cacheable)
  • 可移植性/自文档化(Portable / Self-Documenting)
  • 可测试性(Testable)
  • 合理性(Reasonable)
  • 并行代码(Parallel Code)

为什么要避免改变状态和可变数据?

Shared mutable state is the root of all evil
共享可变状态是万恶之源
by Pete Hunt

好了好了,先暂时告别理论;来用代码解释:

const z = 10;
add(x, y) {
    return x + y;
}
1
2
3
4

上面的add函数就是一个纯函数,它读取x和y两个参数的值,返回它们的和,并且不会受到全局的z变量的影响

把这个函数改一下

const z = 10;
add(x, y) {
    return x + y + z;
}
1
2
3
4

这个函数就变成了不纯的函数了,因为它返回的值会受到全局的z的影响

换句话说,这个函数会被外部环境影响

so,我们就得出了第一个判断是否纯函数的重要依据

1、纯函数不会受到外部环境的影响

再用splice和slice来解释一下:

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

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

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]

xs.splice(0,3);
//=> []

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

slice收到同样的参数,每次返回相同的值,所以是纯函数

splice收到同样的参数,每次返回不同的值,所以不是纯函数

so,我们就得出了第二个判断是否纯函数的重要依据:

2、纯函数相同的输入,永远会得到相同的输出

所以回到上面对于纯函数的定义,纯函数是:

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

使用纯函数能够极大的降低编程的复杂度,但是不合理的使用,为了抽象而去抽象,反而会使代码变得非常难以理解。

柯里化

概念

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

const add = x => y => x + y;
add(1)(2);
// => 3
1
2
3

上面的例子,就是一个很典型的柯里化函数,在我们第一次调用的时候,接收了第一次传入的参数(用闭包记住),返回了一个新的函数;在第二次调用的时候,接收第二次传入的参数,并且和第一次传入的函数相加,返回它们的和。

这个例子说明了柯里化的一个特征,或者说是一个基础,即柯里化函数有延迟求值的特殊性,而这种特殊性又需要用到一些手段来实现。

运用上面的思想编写一个的柯里化函数

// 创建柯里化函数,保存了第一次传入的参数和函数,返回值
//是一个函数并且接收第二次传入参数,同时调用传入的函数进行计算
currying (fn, ...args1) {
    return (...args2) => {
        return fn(...args1, ...args2)
    }
}

// 定义一个一般函数
const add = (x, y) => x + y;

// 使用
const increment = currying(add, 1);
console.log(increment(2));
const addTen = currying(add, 10);
console.log(addTen(2));

// => 3
// => 12
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

这个列子还有点小问题,即返回的值没有自动柯里化,可以改造下:

currying(fn, ...args1) {
  // '判断传入的参数是否满足传入函数需要的参数,比如说add函数需要两个参数相加,
  //那么判断是否传入了两个参数,满足调用传入函数计算结果'
  if (args1.length >= fn.length) {
    console.log(args1, '--1--');
    return fn(...args1);
  }
  // '不满足返回一个新的函数,继续调用柯里化函数,传入保存的第一次传入的函数,
  //传入保存的第一次传入的参数,传入第二次传入的参数,继续上面的判断逻辑,返回计算结果'
  return (...args2) => {
    console.log(args2, '--2--');
    return currying(fn, ...args1, ...args2);
  };
},

// 定义一个一般函数
const add = (x, y) => x + y;

// 使用
const increment = currying(add, 1);
console.log(increment(2));
const addTen = currying(add, 10);
console.log(addTen(2));

// => [2] --2--
// => [1,2] --1--
// => 3
// => [2] --2--
// => [10,2] --1--
// => 12
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

函数在js中是一等公民,它和其它对象,或者其它数据没有什么区别,可以存在数组,存在对象,赋值给变量,当作参数传来传去,所以函数也有下标属性,用上面的例子证明一下

const add = (x, y) => x + y;
console.log(add.length)
// => 2
1
2
3

在ES6中,...是扩展运算符,他的使用是这样的

// 放在函数作为单独参数,会把一个数组变成参数序列,比如上面例子中的数组[1,2]变成了参数x=1,y=2
fn(...args1)

// 放在函数中作为第二个参数,会把传入的值变成一个数组,
//如果传入的是一个数组那么还是数组,传入一个对象,会变成一个数组对象
function currying(fn,...x) {
	console.log(x)
}
currying(0,1)
// => [1]

// 放在回调函数中作为第二个和第三个参数
// 第一次调用会返回一个函数,会在闭包里存贮值,
//第二次调用会把闭包里的值和第二次参数里的值合并成数组
return currying(fn, ...args1, ...args2);
// => [1,2]

// 但是单独在函数中这么使用会报错
function currying(fn,...x,...y) {
	console.log(x)
}
currying(0,1,2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

理解了这些,上面的例子就很好懂了。

柯里化函数比较重要的思想是:

多次判断传入的参数是否满足计算需求,满足,返回计算结果,如果不满足,继续返回一个新的柯里化函数

上面的柯里化函数还可以继续优化,比如说,this绑定啊,特殊的变量占位符啊,等等,这样的工作,一些库,比如说ramda已经实现,可以去看它的源代码里面是怎样实现的,重点还是要明白柯里化函数是怎么一回事。

代码组合

首先,先写一个简单的组合函数:

const compose = (f, g) => x => f(g(x));
1

这个组合函数接收两个函数当作参数,然后返回一个新的函数,x是两个函数之间都要使用的值,比如说:

// 我们要实现一个给字符串全部变成大写,然后加上个感叹号的功能,只需要定义两个函数,然后组合一下
const toUpperCase = x => x.toUpperCase();
const exclaim = x => `${x}!`;
const shout = compose(exclaim, toUpperCase);

shout('hello world')
// => HELLO WORLD!
1
2
3
4
5
6
7

注意:组合函数里面,g函数比f函数先执行,所以在组合里面,是从右往左执行的,也就是说,要把先执行的函数放在组合函数的右边

这个组合函数还是有点问题,它只能接收2个参数,我们来稍微改造下,让它变得强大点:

const compose = (...fns) => (...args) => fns.reduceRight((res, fn) => [fn.call(null, ...res)], args)[0];

// 使用,实现一个功能,字符串变成大写,加上个感叹号,还要截取一部分,再在前面加上注释
const toUpperCase = x => x.toUpperCase();
const exclaim = x => `${x}!`;
const head = x => `slice is: ${x}`;
const reverse = x => x.slice(0, 7);

const shout = compose(exclaim, toUpperCase, head, reverse)
shout('my name is maya')
// => SLICE IS: MY NAME!
1
2
3
4
5
6
7
8
9
10
11

组合的原理其实就是数学中的结合律:

(a + b) + c  =  a + (b + c)
1

so,在组合中你可以这样

// 第一种
const one = compose(exclaim, toUpperCase)
const shout = compose(one, head, reverse)
shout('my name is maya')
// => SLICE IS: MY NAME!

// 第二种
const two = compose(toUpperCase, head)
const shout = compose(exclaim, two, reverse)
shout('my name is maya')
// => SLICE IS: MY NAME!

// 第三种
const three = compose(head, reverse)
const shout = compose(exclaim, toUpperCase, three)
shout('my name is maya')
// => SLICE IS: MY NAME!

...
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

so,到了这里,我对组合的理解是:

组合是什么,组合就是运用了数学里的结合律,像是搭积木一样,把不同的函数联系起来,让数据在里面流动

在各种库里面都有组合的函数,lodash,underscore,ramda等等,比如在underscore里面,组合是这样的:

  // Returns a function that is the composition of a list of functions, each
  // consuming the return value of the function that follows.
  _.compose = function() {
    var args = arguments;
    var start = args.length - 1;
    return function() {
      var i = start;
      var result = args[start].apply(this, arguments);
      while (i--) result = args[i].call(this, result);
      return result;
    };
  };

1
2
3
4
5
6
7
8
9
10
11
12
13

结合使用

嗯,到了这里,已经初步了解了函数式编程的概念了,那么我们怎么使用函数式编程的方式写代码呢,举个例子:

// 伪代码,思路
// 比如说,我们请求后台拿到了一个数据,然后我们需要筛选几次这个数据, 取出里面的一部分,并且排序

// 数据
const res = {
    status: 200,
    data: [
        {
            id: xxx,
            name: xxx,
            time: xxx,
            content: xxx,
            created: xxx
        },
        ...
    ]
}

// 封装的请求函数
const http = xxx;

// '传统写法是这样的'
http.post
    .then(res => 拿到数据)
    .then(res => 做出筛选)
    .then(res => 做出筛选)
    .then(res => 取出一部分)
    .then(res => 排序)
    
// '函数式编程是这样的'
// 声明一个筛选函数
const a = curry()
// 声明一个取出函数
const b = curry()
// 声明一个排序函数
const c = curry()
// 组合起来
const shout = compose(c, b, a)
// 使用
shout(http.post)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

如何在项目中正式使用函数式编程

我觉得,想要在项目里面正式使用函数式编程有这样几个步骤:

  • 1、先尝试使用ES6自带的高阶函数
  • 2、熟悉了ES6自带的高阶函数后,可以自己尝试写几个高阶函数
  • 3、在这个过程中,尽量使用纯函数编写代码
  • 4、对函数式编程有所了解之后,尝试使用类似ramda的库来编写代码
  • 5、在使用ramda的过程中,可以尝试研究它的源代码
  • 6、尝试编写自己的库,柯里化函数,组合函数等

当然了,这个只是我自己的理解,我在实际项目中也没有完全的使用函数式编程开发,我的开发原则是:

不要为了函数式而选择函数式编程。如果函数式编程能够帮助你,能够提升项目的效率,质量,可以使用;如果不能,那么不用;如果对函数式编程还不太熟,比如我这样的,偶尔使用

扩展

函数式编程是在范畴论的基础上发展而来的,而关于函数式编程和范畴论的关系,阮一峰大佬给出了一个很好的说明,在这里复制粘贴下他的文章
本质上,函数式编程只是范畴论的运算方法,跟数理逻辑、微积分、行列式是同一类东西,都是数学方法,只是碰巧它能用来写程序
所以,你明白了吗,为什么函数式编程要求函数必须是纯的,不能有副作用?因为它是一种数学运算,原始目的就是求值,不做其他事情,否则就无法满足函数运算法则了。

参考;

Thomas: 11/22/2019, 6:37:20 PM