函数式编程的一些思考

函数式编程关心数据的映射,命令式编程关心解决问题的步骤 – nameoverflow

这里的映射就是数学上「函数」的概念——一种东西和另一种东西之间的对应关系。

这也是为什么「函数式编程」叫做「函数」式编程

正文开始

JavaScript 算不算 FP 语言?

JavaScript 因为有一等函数这张门票,大部分时候可以算 FP 语言。除了类型系统上由于 JavaScript 是动态类型语言,不容易模拟类型系统之外,大部分函数式特性都可以在 JS 里比较自然地实现。


函数作为一等公民

用函数作为主要载体的编程模式,用函数拆解,抽象一般的表达式。

从命令式和声明式的区别开始

题外话:关于 const:

常量不是对这个值本身的限制,而是对赋值的那个变量的限制。换句话说,这个值并没有因为 const 不可变,只是赋值本身不可变。比如值是个复杂值,内容仍然可以修改。

1
2
3
const a = [1, 2, 3];
a = [...a, 4]; // TypeError: Assignment to constant variable.
a.push(4);

命令式:编写一条条命令去让计算机执行这些动作

声明式:我们写一写表达式,而不是一步步的具体指示

除了函数,递归其实是一个描述表达式的很好的方法。

好!我们以 SICP 上的一些 🌰 来看

推荐去看看 SICP

看完这边,然后再看看递归在前端中的应用,其实场景还挺少的鹅。

说了这么多函数是第一公民,声明式,我们来看看函数到底有什么值得我们去用的地方。

关于尾递归优化

函数调用会在内存形成一个”调用记录”,又称”调用帧”(call frame),保存调用位置和内部变量等信息。如果在函数 A 的内部调用函数 B,那么在 A 的调用记录上方,还会形成一个 B 的调用记录。等到 B 运行结束,将结果返回到 A,B 的调用记录才会消失。如果函数 B 内部还调用函数 C,那就还有一个 C 的调用记录栈,以此类推。所有的调用记录,就形成一个”调用栈”(call stack)。

递归非常耗费内存,因为需要同时保存成千上百个调用记录,很容易发生栈溢出

一个阶乘函数,计算 n 的阶乘,最多需要保存 n 个调用记录

我们如果用尾递归的形式,那只要保存 1 个调用记录


纯函数的作用

“纯”: 相同的输入只能得到相同的输出

1
2
3
4
5
6
7
8
9
const 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
const f = (a,b,c,d) => {...}
const curried = curry(f);

curried(a, b, c, d)
curried(a, b, c)(d)
curried(a)(b, c, d)
curried(a, b)(c, d)
curried(a)(b, c)(d)
curried(a)(b)(c, d)
curried(a, b)(c)(d)

“安全”的操作

下面这种写法不能保证所有的 remove 都是正确执行的

1
2
3
4
5
6
7
8
const append = function(parent, child) {
parent.appendChild(child);
};
const remove = function(dom) {
dom.remove();
};
append(parent, child); //插入
remove(child); //删除

再来看看下面这种写法,想要删除的节点都是来自删除添加的节点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const append = function(parent, child) {
parent.appendChild(child);
return function() {
child.remove();
};
};
// point free
const append2 = function(parent, child) {
parent.appendChild(child);
return child.remove.bind(child);
};

const remove = append(parent, child); // 插入一个节点,同时返回所插入的节点的删除操作
remove(); // 删除

简单的来总结一下,以函数作为主体,确保了函数之间不会相互干扰


函数组合

组合嘛,顾名思义,就是把几样东西给组合起来

1
2
3
4
5
const toUpperCase = x => x.toUpperCase();
const exclaim = x => x + '!';

const shout = compose(toUpperCase, exclaim);
shout('hello, green pomelo');

有个有趣的点就是这是和数学上的结合律很相似

1
2
3
compose(toUpperCase, compose(head, reverse))
<->
compose(compose(toUpperCase, head), reverse)

这边具体的在 /src/test/compose.test.js 里有案例


更强大的 Functor

我们从 Promise 来讲 Monad 是个啥

Promise is Monad

1
2
3
4
5
6
7
8
const getB = a => new Promise((resolve, reject) => fetch(a, resolve));
const getC = b => new Promise((resolve, reject) => fetch(b, resolve));
const getD = c => new Promise((resolve, reject) => fetch(c, resolve));

getB(a)
.then(getC)
.then(getD)
.then(console.log);

所以 Monad 是个啥

我们可以实现一个简单的对象 P,然后将 A B 分开来传入这个对象 P,从而可以把回调拆分开

A(B) => P(A).then(B)

经过包装后,P 已经有 Promise 的雏形了

但是它还没有这样的能力

P(A).then(B).then(C).then(D)

那么 Monad 就是一个增强的对象 P,支持链式调用

在每次 Resolve 一个 Promise 时,我们需要判断两种情况:

  • 如果被 Resolve 的内容仍然是 Promise(即所谓的 thenable),那么递归 Resolve 这个 Promise。

  • 如果被 Resolve 的内容不是 Promise,那么根据内容的具体情况(如对象、函数、基本类型等),去 fulfillreject 当前 Promise。

1
2
3
4
5
Promise.resolve(1).then(console.log);

Promise.resolve(Promise.resolve(Promise.resolve(Promise.resolve(1)))).then(
console.log
);

这也就是披着 Promise 外衣的 Monad 的核心功能:

对于一个 P 这样装着某种内容的容器,我们能够递归地把容器一层层拆开,直接取出最里面装着的值。(就像洋葱一样)
之后我们就可以实现了链式调用的能力

1
2
3
4
Promise(A)
.then(B)
.then(C)
.then(D);

额外的好处是不管同步还是异步,都是一致处理,最后的结果也会是相同的。

1
2
3
4
5
6
7
8
9
10
11
12
13
const add = x => x + 1;

Promise.resolve(1)
.then(add)
.then(add)
.then(console.log);

const add = x => new Promise(resolve => setTimeout(() => resolve(x + 1), 1000));

Promise.resolve(1)
.then(add)
.then(add)
.then(console.log);
  • 最简单的 P(A).then(B) 实现里,它的 P(A) 相当于 Monad 中的 unit 接口,能够把任意值包装到 Monad 容器里。

  • 支持嵌套的 Promise 实现中,它的 then 背后其实是 FP 中的 join 概念,在容器里还装着容器的时候,递归地把内层容器拆开,返回最底层装着的值。

  • Promise 的链式调用背后,其实是 Monad 中的 bind 概念。你可以扁平地串联一堆 .then(),往里传入各种函数,Promise 能够帮你抹平同步和异步的差异,把这些函数逐个应用到容器里的值上。

说到这里,Monad 就是:

  • 可以把值包装为容器

  • 对于容器中的值,可以把函数应用在值上面(包括容器中嵌套容器,需要递归将函数应用到值上)

总结一下

Promise 消除回调地狱的关键(为什么可以和 Monad 联系起来)

  • 拆分 A(B)P(A).then(B) 的形式。这其实是 Monad 来构建容器的 unit

  • 不分同步还是异步,都能写 P(A).then(B).then(C) 的形式,这是 Monad 的 bind


函数式在 JavaScript(前端) 中的实践

React 中涉及到的函数式

渲染模式

UI = View(State)

Components as functions

1
const Hello = props => <div>Hello {props.name}!</div>;

Props 的不可变


Redux 优雅的修改共享状态

(state, action) => state

前端组件中的共享状态

A 状态会被 B,C 组件影响或者依赖

或者更多的,D E F G 函数用到这个状态,H I J K L 组件会影响这个状态


高阶组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import React, { Component } from 'react';

export default (WrappedComponent, name) => {
class NewComponent extends Component {
constructor() {
super();
this.state = { data: null };
}

componentWillMount() {
let data = localStorage.getItem(name);
this.setState({ data });
}

render() {
return <WrappedComponent data={this.state.data} />;
}
}
return NewComponent;
};

下面就是组件在具体页面中的使用了

这些组件的共同特点就是从一段请求中拿到数据放到组件中,那这段逻辑就是相同的,我们抽离出来放到高阶组件中去。高阶组件内部的包装组件和被包装组件之间通过 props 传递数据。

1
2
3
4
5
6
7
8
9
10
import wrapWithLoadData from './wrapWithLoadData';

class InputWithUserName extends Component {
render() {
return <input value={this.props.data} />;
}
}

InputWithUserName = wrapWithLoadData(InputWithUserName, 'username');
export default InputWithUserName;
1
2
3
4
5
6
7
8
9
10
import wrapWithLoadData from './wrapWithLoadData';

class TextareaWithContent extends Component {
render() {
return <textarea value={this.props.data} />;
}
}

TextareaWithContent = wrapWithLoadData(TextareaWithContent, 'content');
export default TextareaWithContent;

高阶组件的灵活性

比如我们现在的需求改成从 localStorage 中拿到某个数据,注入组件中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import React, { Component } from 'react';

export default (WrappedComponent, name) => {
class NewComponent extends Component {
constructor() {
super();
this.state = { data: null };
}

componentWillMount() {
ajax.get('/data/' + name, data => {
this.setState({ data });
});
}

render() {
return <WrappedComponent data={this.state.data} />;
}
}
return NewComponent;
};

我们甚至可以写个更高阶的


合理使用函数式(

Redux 的 Store 管理选择

  • 某个状态只被 1 个组件依赖影响

那这个状态放在组件里的 state 是完全没问题的,没有其他组件可以访问到它

  • 某个状态被多个组件依赖影响

🌰:Button => HTTP => Loading Model (Loading State)

那这个 Loading State 的状态是放在 <Button /> 组件里还是放在 <Loading /> 组件里呢?

其实都不合适,必然会导致另一个组件的依赖。解决方法是把这种状态抽离出来作为公共部分

这才是 Redux 这类状态管理解决的问题:管理多个组件所依赖或者影响的状态


总结

讲函数式和一些数学上的定理联系起来还是挺有趣的,比如 SICP 里的那个把求和的过程抽象出来。

用递归去写写函数也是很有趣的,把自己从命令式的一步步中解放出来。递归有趣在写出来不知道是对不对的,嘛,交给上帝就好了。

前端业务中的用到的函数式编程说多也不算多。

  • 比如递归其实用的很少
  • 那个柯里化的使用场景,其实是我找了很久才找到的(:我很多时候其实也并不能说服自己为什么要这么用呢
  • 但是处理副作用去封装一些东西还是需要的
  • 嗯,接着就是 Promise 吧,其实就是 Monad 的一个典型的实践嘛
  • React 和 Redux 的一些设计理念,比较贴近函数式的思想吧。
  • 还有类型推导这些?可以试试 TypeScript 嘛,我也还没正式的去学