函数式编程, 近年来逐渐由学术领域向商用领域渗透, 比起传统的OOP, 函数式编程有着非常大的优势, 逻辑清晰, 确定的输入输出, 可以随意组合, 且易于扩展.

对于 js 的函数式编程, 某些标准仍未列入 ES 规则中, 所以, 这里将 Scala 这种纯函数标准编程作为对比, 让我对函数式编程理解更加深刻. 本文参考该文章和一些网上的视频资料而作.

本文代码可以看这个仓库

函数作为一等公民

函数式编程, 是一个古老的概念, 甚至比计算机的诞生还早, 并伴随着 React 的流行带火的, React 是其最有力的推广者. 而跟着火爆的 js 并不是纯函数语言, 现在较为主流的纯函数语言, 包括诸如: Haskell, Rust, Scala, Clojure, Erlang, F# 等等. 他们有些语法特性, 是 js 所缺少的, 例如 Haskell 摒弃了大多数语言的 loop 循环(for, while), 进而使用递归和高阶函数来处理迭代. 几乎所有的函数式语言都具备的 模式匹配(Pattern Matching)Monad(如 Option、Either、Future)等特性. 这些都是 js 缺少的, 本文来讨论这些问题.

函数既可以作为参数, 也可以作为返回值

函数作为一等公民, 函数可以作为参数, 也可以作为返回值, 如:

1
2
3
4
5
6
7
8
9
10
11
// 这行
ajaxCall((json) => callback(json));

// 等价于这行 --> 函数作为参数
ajaxCall(callback);

// 那么,重构下 getServerStuff
const getServerStuff = (callback) => ajaxCall(callback);

// ...就等于 --> 函数作为返回值
const getServerStuff = ajaxCall; // <-- 看,没有括号哦

这种简化可以使得代码更简洁, 更容易理解, 更容易扩展.

命名的通用性, 使得后期易于扩展

作者举例:

1
2
3
4
5
6
// 只针对当前的博客
const validArticles = articles =>
articles.filter(article => article !== null && article !== undefined),

// 对未来的项目更友好
const compact = xs => xs.filter(x => x !== null && x !== undefined);

xs, x 这种命名是通用的, 容易理解, 容易扩展. 使得我们避免重复造轮子

JS 的 bug: this

作者极力反对在 this 上做操作

this 就像一块脏尿布,我尽可能地避免使用它,因为在函数式编程中根本用不到它。… 也有人反驳说 this 能提高执行速度。如果你是这种对速度吹毛求疵的人,那你还是合上这本书吧。要是没法退货退款,也许你可以去换一本更入门的书来读。

纯函数 Pure Function

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

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

副作用可能包含,但不限于:
更改文件系统
往数据库插入记录
发送一个 http 请求
可变数据
打印/log
获取用户输入
DOM 查询
访问系统状态

原书举了一个很好的例子说明纯函数的概念, 即 slicesplice:

1
2
3
4
5
6
7
8
9
10
11
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); // []

从上面的示例可以看出

  • slice 是纯函数, 因为无论调用多少次, 都会返回相同的结果.
  • splice 不是纯函数, 因为每次调用都会改变原数组, 并且返回值也不一样.

另一个例子:

1
2
3
4
5
6
7
8
9
10
11
// 不纯的: checkAge的值取决于外部变量
let minimum = 21;
const checkAge = (age) => age > minimum;

console.log(checkAge(24)); // true

minimum = 27;
console.log(checkAge(24)); // false

minimum = 18;
console.log(checkAge(24)); // true

改为纯函数, 将 minimum 变量作为内部变量或函数参数, 避免了外部变量的影响.

1
2
3
4
5
6
7
8
9
10
11
const checkAge = (age) => {
const minimum = 21;
return age >= minimum;
};
console.log(checkAge(24)); // true

minimum = 27;
console.log(checkAge(24)); // true

minimum = 18;
console.log(checkAge(24)); // true

另一种改法:

1
2
3
4
5
6
const immutableMinimum = Object.freeze({
value: 21,
});
const checkAge = (age) => age > immutableMinimum;

console.log(checkAge(24)); // true

利用 es6+ 的语法, 我们可以更轻松的实现纯函数, 例如:

1
2
3
4
5
6
7
8
const a = [1, 2];
const f = (a, el) => [...a, el];

const result = f(a, 3);

// 无论打印多少次, 输出的结果只取决于输入结果, 是纯函数:
console.log(result); // [1,2,3]
console.log(result); // [1,2,3]

特征

纯函数具有执行不依赖外部变量, 可缓存 (Cacheable), 可移植性/自文档化(Portable / Self-Documenting), 可测试性(Testable), 合理性(Reasonable), 代码可并行不会进入竞争态 等特点.

函数执行不依赖外部变量

1
2
3
4
5
6
// 不纯的
var minimum = 21;

var checkAge = function (age) {
return age >= minimum;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 不纯的
const arr = [1, 2];
const pushElementImpure = (arr, ele) => {
arr.push(ele);
};

// 变成纯函数:
const pushElementPure = (arr, ele) => {
return [...arr, ele];
};
// 纯函数无论调用多少次, 都不会改变原数组, 而且该函数的变化不会受外部变量的影响
pushElementPure(3);
pushElementPure(3);
console.log(arr); // [1,2]

可缓存性

可缓存特性的示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 用于缓存:
const memoize = (fn) => {
const cache = {};
return (...args) => {
const key = JSON.stringify(args);
if (!cache[key]) {
cache[key] = fn(...args);
}
return cache[key];
};
};

const square = (x) => x * x;

const sum = (a, b) => a + b;

const sumOfSquares = (arr) => arr.map(square).reduce(sum, 0);

const memoizedSumOfSquares = memoize(sumOfSquares);

console.log(memoizedSumOfSquares(numbers)); // 55 第一次计算
console.log(memoizedSumOfSquares(numbers)); // 55 第二次使用缓存,提升性能

作者在书中将函数变为纯函数的例子:

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
// 不纯的
var signUp = function(attrs) {
var user = saveUser(attrs);
welcomeUser(user);
};

var saveUser = function(attrs) {
var user = Db.save(attrs);
...
};

var welcomeUser = function(user) {
Email(user, ...);
...
};

// 纯的
var signUp = function(Db, Email, attrs) {
return function() {
var user = saveUser(Db, attrs);
welcomeUser(Email, user);
};
};

var saveUser = function(Db, attrs) {
...
};

var welcomeUser = function(Email, user) {
...
};

可缓存并不需一定写类似上面 的 momerize 函数, 可以直接返回一个不传入任何参数的函数, 使得函数变纯:

1
2
3
4
5
6
7
8
9
10
11
// impure
const signUpImpure = (Db, Email, attrs) => {
const user = saveUser(Db, attrs);
welcomeUser(Email);
};

// pure
const signUp = (Db, Email, attrs) => () => {
const user = saveUser(Db, attrs);
welcomeUser(Email);
};

相应的, 他们在调用时就产生区别:

1
2
3
4
5
6
7
8
// impure 调用: 立即执行:
const signUpUserImpure = signUp(database, emailService, userAttrs);
// signUpUserImpure 直接在下一步使用
signUpUserImpure;

// prue 调用: 多一次执行, 即延迟执行:
const signUpUserPure = signUp(database, emailService, userAttrs);
signUpUserPure();

可移植性/自文档化(Portable / Self-Documenting)

书里作者引用了这样一段话:

你上一次把某个类方法拷贝到新的应用中是什么时候?我最喜欢的名言之一是 Erlang 语言的作者 Joe Armstrong 说的这句话:“面向对象语言的问题是,它们永远都要随身携带那些隐式的环境。你只需要一个香蕉,但却得到一个拿着香蕉的大猩猩…以及整个丛林”。

下面是我自己给的例子阐述可移植性和自文档性的:

1
2
3
4
5
6
7
8
9
10
11
12
const add = (a, b) => a + b;
const multiply = (a, b) => a * b;

const operate = (operator, args) => args.reduce(operator);

const numbers = [2, 3, 4];

const sumResult = operate(add, numbers);
console.log(sumResult); // 输出:9

const productResult = operate(multiply, numbers);
console.log(productResult);

operate 函数的参数是 add, multiply, 中的任意一个, 所以 operate 函数是可移植的, 也是可自文档化的.

合理性利于重构

看下面一段代码: npm i immutable

1
2
3
4
5
6
7
8
9
10
11
12
13
const { Map } = require("immutable");

// Aliases: p = player, a = attacker, t = target
const jobe = Map({ name: "Jobe", hp: 20, team: "red" });
const michael = Map({ name: "Michael", hp: 20, team: "green" });

const decrementHP = (p) => p.set("hp", p.get("hp") - 1);

const isSameTeam = (p1, p2) => p1.get("team") === p2.get("team");

const punch = (a, t) => (isSameTeam(a, t) ? t : decrementHP(t));

console.log(punch(jobe, michael).toJS()); // 将 Map 转换为普通的 JS 对象进行输出

上面的 punch 函数, 可以进行下面的简化, 因为判断两者队伍相同的操作p1.get("team") === p2.get("team"), 实质上是判断 'red'==='green', 而我们知道, 这两个字符串是永远不可能相等的, 所以我们可以将 isSameTeam(a, t) 函数的判断在punch函数里去掉, 直接返回 decrementHP(t):

1
2
- const punch = (a, t) => (isSameTeam(a, t) ? t : decrementHP(t));
+ const punch = (a, t) => decrementHP(t);

接着, decrementHP(t) 继续简化:

1
2
- const punch = (a, t) => decrementHP(t)
+ const punch = (t) => t.set("hp", t.get("hp") - 1)

最终代码:

1
2
3
4
5
6
7
8
9
const { Map } = require("immutable");

// Aliases: p = player, a = attacker, t = target
const jobe = Map({ name: "Jobe", hp: 20, team: "red" });
const michael = Map({ name: "Michael", hp: 20, team: "green" });

const punch = (t) => t.set("hp", t.get("hp") - 1);

console.log(punch(jobe, michael).toJS()); // { name: 'Michael', hp: 19, team: 'green' }

柯里化 Curry (重点)

curry 的概念很简单:只传递给函数一部分参数来调用它,让它返回一个函数去处理剩下的参数。(partial application)

这是我看过最简洁, 且最确切的对柯里化的解释. 很多时候一些编程的理念没有在项目中得到实现, 往往是对其发明的目的还不够明确. 如果目的明确了, 我们大可以在项目中去重构我们的代码. 下面是柯里化在 📖 中作者举的一个最简单例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var add = function (x) {
return function (y) {
return x + y;
};
};

// 定义的部分, increment 函数并传入第一个参数
var increment = add(1);
var addTen = add(10);

// 再根据自己的需要, 调用函数并传入自己需要的参数
increment(2);
// 3

addTen(2);
// 12

再看下面的代码, 未使用柯里化的普通请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 未使用柯里化
const sendRequest = (method, url, data) => {
return fetch(url, {
method: method,
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
}).then((response) => response.json());
};

// 使用原始函数
sendRequest("POST", "https://jsonplaceholder.typicode.com/todos", {
key: "value",
}).then((response) => console.log("unused curry:", response));

将上面的代码改造后, 我们可以看到, 函数 currySendRequest, 创建专门的请求函数 postToApigetFromApi, 如果他们放在同一个文件里再导出postToApigetFromApi, 那么调用的时候, 我们只需传入 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
// 使用柯里化
const currySendRequest = (method) => (url) => (data) => {
return fetch(url, {
method: method,
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
}).then((response) => response.json());
};

// 创建专门的请求函数
const postRequest = currySendRequest("POST");
const getRequest = currySendRequest("GET");

const postToApi = postRequest("https://jsonplaceholder.typicode.com/posts");
const getFromApi = getRequest("https://jsonplaceholder.typicode.com/todos/12");

// 调用的地方, 无需再关心 method, api等参数
postToApi({ key: "value" }).then((response) =>
console.log("POST Response:", response)
);

getFromApi().then((response) => console.log("GET Response:", response));

柯里化的方法特别适用于需要重复调用同一函数但使用不同参数的情况, 参数可以独立进行维护

组合 Compose (重点)

最简单的组合函数, 如下, 作者将其称为函数饲养

1
2
3
4
5
var compose = function (f, g) {
return function (x) {
return f(g(x));
};
};

当然, 这种嵌套的写法f(g(x))可读性是比较差的, 作者在书中举例了另一种写法, 我愿意称之为管道操作, 但必须安装他的库 npm i @mostly-adequate/support 才能使用以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var toUpperCase = function (x) {
return x.toUpperCase();
};
var exclaim = function (x) {
return x + "!";
};
var shout = compose(exclaim, toUpperCase);

shout("send in the clowns");
//=> "SEND IN THE CLOWNS!"

// 不实用管道则得嵌套: exclaim(toUpperCase(x))
var shout = function (x) {
return exclaim(toUpperCase(x));
};

我这里自己写了一个类似的 compose 管道函数的例子如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const composePipe = (...fns) => {
const execute = (initialValue) => {
return fns.reduce((value, fn) => fn(value), initialValue);
};
return { execute };
};

// 示例函数
const toUpperCase = (x) => x.toUpperCase();
const exclaim = (x) => x + "!";
const reverse = (x) => x.split("").reverse().join("");

// 创建组合函数
const result = composePipe(toUpperCase, exclaim, reverse).execute("hello");

console.log(result); // 输出: !OLLEH

当然上面的 composePipe 函数还可以简化为柯里化的形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const composePipeCurry =
(...fns) =>
(initialValue) =>
fns.reduce((value, fn) => fn(value), initialValue);

// 示例函数
const toUpperCase = (x) => x.toUpperCase();
const exclaim = (x) => x + "!";
const reverse = (x) => x.split("").reverse().join("");

// ("hello") 相当于 composePipeCurry 函数里 (initialValue)
const result = composePipeCurry(toUpperCase, exclaim, reverse)("hello");

console.log(result); // 输出: !OLLEH

其实, 我还是喜欢第一种 composePipe 的写法, 柯里化虽然简洁了些, 但可读性反而变差了

分组

结合律的一大好处是任何一个函数分组都可以被拆开来,然后再以它们自己的组合方式打包在一起。

1
2
3
4
5
6
7
8
9
10
11
12
var loudLastUpper = compose(exclaim, toUpperCase, head, reverse);

// 或
var last = compose(head, reverse);
var loudLastUpper = compose(exclaim, toUpperCase, last);

// 或
var last = compose(head, reverse);
var angry = compose(exclaim, toUpperCase);
var loudLastUpper = compose(angry, last);

// 更多变种...

由于 compose 函数用 reduce 实现, 所以, 传入的函数没有限制, 所以可以任意进行组合或者分组, 这也体现了组合的灵活性.

pointFree 模式

pointfree 模式指的是,永远不必说出你的数据。

以下是书中的例子

1
2
3
4
5
6
7
// 非 pointfree,因为提到了数据:word
var snakeCase = function (word) {
return word.toLowerCase().replace(/\s+/gi, "_");
};

// pointfree
var snakeCase = compose(replace(/\s+/gi, "_"), toLowerCase);

以下是我对 point free 模式的理解写的 demo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const people = [
{ name: "Alice", age: 25 },
{ name: "Bob", age: 30 },
{ name: "Charlie", age: 35 },
];

// 传统方法, 必须有 person 参数才能参与
const processPerson = (person) => {
const upperCaseName = person.name.toUpperCase();
const incrementedAge = person.age + 1;
const greeting = `Hello, ${upperCaseName}!`;

return {
name: upperCaseName,
age: incrementedAge,
greeting: greeting,
};
};

// 传统模式: 假如上面的代码放在另一个文件, 我们要查清楚 processPerson 里面的操作,
// 还必须跑去 processPerson 函数里阅读代码及 debug
const processedPeople = people.map(processPerson);
console.log(processedPeople);
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
// 以下为改用组合模式 compose
const compose = (...fns) => {
const execute = (initialArray) => {
return initialArray.map((item) =>
fns.reduce((value, fn) => fn(value), item)
);
};

return { execute };
};

const toUpperCase = (str) => str.toUpperCase();
const increment = (n) => n + 1;
const createGreeting = (name) => `Hello, ${name}!`;

// Pointfree functions
const toUpperCaseName = (obj) => ({ ...obj, name: toUpperCase(obj.name) });
const incrementAge = (obj) => ({ ...obj, age: increment(obj.age) });
const addGreeting = (obj) => ({ ...obj, greeting: createGreeting(obj.name) });

// compose 方法, 可以根据函数名字, 清晰的知道做了哪些操作
const composedPeople = compose(
toUpperCaseName,
incrementAge,
addGreeting
).execute(people);
console.log(composedPeople);

debug compose 函数的方法

当我们在 debug compose 函数时,我们可以使用 一个 trace 来追踪错误, 以下是我自己写的 trace 函数, 没有用书中的例子:

1
2
3
4
const debug = (label) => (value) => {
console.log(`${label}:`, value);
return value;
};

label 指的是我们自己写的标志, value就是每一步所产生的值, 这个函数可以插在 compose 函数中的每一步, 我们用上面 composedPeople 的代码举例, 如:

1
2
3
4
5
6
7
8
const composedPeople = compose(
toUpperCaseName,
debug("After toUpperCaseName"),
incrementAge,
debug("After incrementAge"),
addGreeting,
debug("After addGreeting")
);

范畴学 (category theory)

组合背后的强大理论基础. 在编程中体现为处理:

对象 Object
态射 morphism
变化式 transformation

在编程里, 范畴学的应用体现在:

  • 对象的集合
    • 例如 Boolean 就是 [true, false]的集合, Number就是所有实数的集合
    • 可以用集合论(set theory)来处理类型
  • 态射的集合, 即标准的纯函数
  • 态射的组合
    • 结合律是在范畴学中对任何组合都适用的一个特性
  • 一种特殊态射 identity const id = x => x

总结

组合像一系列管道那样把不同的函数联系在一起,数据在其中流动

组合是高于其他所有原则的设计原则

范畴学将用在指导应用架构、副作用建模和保证正确性

type signature 类型签名

类型(type)是让所有不同背景的人都能高效沟通的元语言。

实质上, 我感觉就是 type script 的一种子类型. 比 type script 简单, type script 我们在项目中已经用的不少了, 这里不过多解释, 下面只给出一些书中的示例:

1
2
3
4
5
6
7
//  capitalize :: String -> String
var capitalize = function (s) {
return toUpperCase(head(s)) + toLowerCase(tail(s));
};

capitalize("smurf");
//=> "Smurf"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//  strLength :: String -> Number
var strLength = function (s) {
return s.length;
};

// [] 是指数组, 类似 ts 的 string[]
// join :: String -> [String] -> String
var join = curry(function (what, xs) {
return xs.join(what);
});

// match :: Regex -> (String -> [String])
var match = curry(function (reg, s) {
return s.match(reg);
});

// replace :: Regex -> (String -> (String -> String))
var replace = curry(function (reg, sub, s) {
return s.replace(reg, sub);
});

相同的变量名,其类型也一定相同

类似 ts 的泛型:

1
2
3
4
5
// id :: a -> a
const id = (x) => x;

// map:: (a -> b) -> [a] -> [b]
const map = curry((f, xs) => xs.map(f));

接口在类型签名中的表示:

1
2
3
// sort :: Ord a => [a] -> [a]

// assertEqual :: (Eq a, Show a) => a -> a -> Assertion

这里的 Ord, Eq , Show 均为接口, 接口的实现实现用 => 表示

Functor 函子 (重点)

Functor 是实现了 map 函数并遵守一些特定规则的容器类型。

本文花费了大量时间于 Functor 这章的内容, 因为 Functor 里的 Maybe, Either 等概念在 js 里没有原生支持, 而反观纯函数式语言的 Haskell, Scala, F# 等语言都有.

下面是提问 Chatgpt 后的回答:

Functor 是一个具有 map 方法的容器或上下文,该方法允许你将一个函数应用到容器内的每个值,并返回一个新的容器。

总结起来, Functor 就是允许使用 map 函数变换里面内容的容器. 在函数式编程中, 我们已经使用很久了, 但可能你并没意识到你在使用它. 例如, 在 JavaScript 中, ArrayString 都是 functor, 因为它们都实现了 map 方法.

Functor 的特性解析

以下是一个函子 Functor 的最简单实现, 符合了 Functor 的定义:

  • map 方法接收一个函数, 并返回一个函子.
  • 外部传进来的值, 利用私有变量 #value 保存起来
  • 所有的操作都是基于私有变量 #value , 他始终保存在容器内.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Container<T> {
#value: T;

constructor(value: T) {
this.#value = value;
}

map<U>(fn: (value: T) => U): Container<U> {
// map递归创建一个新的 Container, 更新私有变量 #value:
return new Container(fn(this.#value));
}

get value(): T {
return this.#value;
}
}

const c = new Container(2).map((x) => x + 3).map((x) => x * x);
console.log(c.value); // 25

接着, 我们一步步来完善上面的实现

Pointed Functor

实现了静态方法 of 的函子, of 方法是把值放到上下文 Context 中, 使用 map 来处理

我们在调用 Container 时仍需要 new 关键字, 这很不符合函数式编程的逻辑, 所以我们可以考虑将 new 关键字去掉, 优化 Functor 类, 添加静态方法 of 替代, 如:

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
class Container<T> {
#value: T;

constructor(value: T) {
this.#value = value;
}

// 静态方法 of 替换调用时的 new 关键字
static of<T>(value: T): Container<T> {
return new Container(value);
}

map<U>(fn: (value: T) => U): Container<U> {
return Container.of(fn(this.#value));
}

get value(): T {
return this.#value;
}
}

// 使用示例
const c = Container.of(3)
.map((x) => x + 1)
.map((x) => x * x);

console.log(c.value); // 输出: 16

extract 函数获取值

由于 Functor 最终的产物始终是一个容器, 上面的代码中, 还是利用面向对象的思维, 用 getter 方法, 获取容器内的值. 实际上, 严格意义上函数式编程仍是用函数的方式来获取容器内的值. 如下:

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
class Container<T> {
#value: T;

constructor(value: T) {
this.#value = value;
}

static of<T>(value: T): Container<T> {
return new Container(value);
}

map<U>(fn: (value: T) => U): Container<U> {
return Container.of(fn(this.#value));
}

// extract 方法,用于最终处理和提取值
extract<U>(fn: (value: T) => U): U {
return fn(this.#value);
}
}

// 使用示例
const c = Container.of(3)
.map((x) => x + 1)
.map((x) => x * x)
.extract((x) => x);

console.log(c); // 输出: 16

将其改为函数的形式:

1
2
3
4
5
6
7
8
9
10
11
12
const Container = <T>(value: T) => ({
map: <U>(fn: (value: T) => U) => Container(fn(value)),
extract: <U>(fn: (value: T) => U): U => fn(value),
});

// 使用示例
const c = Container(3)
.map((x) => x + 1)
.map((x) => x * x)
.extract((x) => x);

console.log(c); // 输出: 16

Functor 除了上面所提及的基础内容, 在实际开发中, 还有一个大功能, 是用来解决纯函数编程的副作用问题. 把副作用控制在我们可控的范围内. 这些副作用包括

  • 处理异常
  • 异步操作
  • I/O 操作

下面, 我们就 Functor 的副作用特征, 来展开讨论.

Maybe Functor

Maybe 是用于处理判断传入的值是否为 nullundefinedFunctor. Scala 中, 相当于 Option

而如果在 JS 里运用上 Maybe 的类型检查, 那么可以避免很多的 if 判断. 他原生的捕获了 nullundefined 的问题, 并且提供了 map 方法, 使得我们可以在 Maybe 类型中应用 map 方法. 使得程序不会因为 nullundefined 值报错从而使程序报错或中断.

我们自己来实现一个 Maybe 类型:

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
class Maybe<T> {
#value: T;
constructor(value) {
this.#value = value;
}
static of<T>(value: T): Maybe<T> {
return new Maybe(value);
}

map<U>(fn: (value: T) => U | null): Maybe<U | null> {
return this.isNone() ? Maybe.of(null) : Maybe.of(fn(this.#value));
}

extract<U>(fn: (value: T) => U): U {
return fn(this.#value);
}

isNone(): boolean {
return this.#value === null || this.#value === undefined;
}
}

// 调用示例:
const m = Maybe.of<string | null>(null)
.map((x) => x?.toUpperCase())
.extract((x) => x);

console.log(m);

将上面的 Maybe class 类型改为函数式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const Maybe = <T>(value: T) => ({
map: <U>(fn: (value: T) => U | null): ReturnType<typeof Maybe<U | null>> =>
Maybe(fn(value)),
extract: <U>(fn: (value: T) => U): U => fn(value),
});

// 使用示例
const m = Maybe<string | null>(null)
.map((x) => x?.toUpperCase() || null)
.extract((x) => x);

console.log(m); // null

const m1 = Maybe("hello world")
.map((x) => x.toUpperCase())
.extract((x) => x);

console.log(m1); // 'HELLO WORLD'

稍微解释一下上面的代码:

  • 上面的代码改为函数式后, 更为精简, 连 class 里的 isNone 方法, 都不需要了
  • 泛型 <U> 代表函数的返回值类型, 如果一个泛型, 他的返回值类型和他传入的类型不一样, 那么就必须在函数的括号前, 加上一个新的泛型, 作为返回值, 如上面所示

Either Functor

上面Maybe Functor 的实现, 解决了传入的参数为空值的需求. 但如果在链式调用的过程中, 某一次出现空值的情况, 那么对于调试或者编码来说, 是难以预测的, 比如:

1
2
3
4
5
6
7
8
const m1 = Maybe("hello world")
.map((x) => x.toUpperCase())
// 假设某一次调用出现了空值:
.map((x) => undefined)
.extract((x) => x);

// 输出的结果虽然捕获到, 但是这并不是我们想要的结果:
console.log(m1); // undefined

这时, Either 就派上用场了.

Either 是一个用于处理调用过程中错误和异常的 Functor.

Either是一个表示可能含有两种类型值的数据结构,它通常用于表示一个计算的结果可能成功(含有一个值)或失败(含有一个错误或另一种值)。

Either 有两个子构造:LeftRight。按照惯例,Right 通常表示成功,而 Left 通常表示失败或错误。Either 是这两种构造的叠加态.

以下是 Scala 关于 Either 的一个简单例子, 可以说明 Either 的作用:

1
2
3
4
5
6
def divide(a: Int, b: Int): Either[String, Double] = {
// 当除数为0时,返回错误信息Left
if (b == 0) Left("Division by zero")
// 当除数不为0时,返回结果Right
else Right(a.toDouble / b)
}

同样的, 我们用 ts 来简单的实现 Either:

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
class Either<L, R> {
static left<L, R>(value: L): Either<L, R> {
return new Left(value);
}

static right<L, R>(value: R): Either<L, R> {
return new Right(value);
}

map<U>(fn: (value: R) => U): Either<L, U> {
return this.isRight()
? Either.right(fn(this.getValue()))
: Either.left(this.getError());
}

flatMap<U>(fn: (value: R) => Either<L, U>): Either<L, U> {
return this.isRight() ? fn(this.getValue()) : Either.left(this.getError());
}

getOrElse(defaultValue: R): R {
return this.isRight() ? this.getValue() : defaultValue;
}

isLeft(): this is Left<L> {
return false;
}

isRight(): this is Right<R> {
return false;
}

getValue(): R {
throw new Error("Cannot get value from Left");
}

getError(): L {
throw new Error("Cannot get error from Right");
}
}

class Left<L> extends Either<L, never> {
private value: L;

constructor(value: L) {
super();
this.value = value;
}

isLeft(): this is Left<L> {
return true;
}

getError(): L {
return this.value;
}
}

class Right<R> extends Either<never, R> {
private value: R;

constructor(value: R) {
super();
this.value = value;
}

isRight(): this is Right<R> {
return true;
}

getValue(): R {
return this.value;
}
}

// 使用代码:
const rightValue: Either<string, number> = Either.right(5);
const leftValue: Either<string, number> = Either.left("Error");

const result1 = rightValue
.map((x) => x + 1)
.flatMap((x) => Either.right(x * 2))
.getOrElse(0);

const result2 = leftValue
.map((x) => x + 1)
.flatMap((x) => Either.right(x * 2))
.getOrElse(0);

console.log(result1); // 输出: 12
console.log(result2); // 输出: 0

将上面的 Either class 类型改为函数式:

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
41
42
43
44
45
type Either<L, R> = { type: "Left"; value: L } | { type: "Right"; value: R };

const left = <L, R>(value: L): Either<L, R> => ({ type: "Left", value });
const right = <L, R>(value: R): Either<L, R> => ({ type: "Right", value });

const isRight = <L, R>(
either: Either<L, R>
): either is { type: "Right"; value: R } => either.type === "Right";

const map = <L, R, U>(
either: Either<L, R>,
fn: (value: R) => U
): Either<L, U> => (isRight(either) ? right(fn(either.value)) : either);

const flatMap = <L, R, U>(
either: Either<L, R>,
fn: (value: R) => Either<L, U>
): Either<L, U> => (isRight(either) ? fn(either.value) : either);

const getOrElse = <L, R>(either: Either<L, R>, defaultValue: R): R =>
isRight(either) ? either.value : defaultValue;

// 示例代码:
const rightValue: Either<string, number> = right(5);
const leftValue: Either<string, number> = left("Error");

// Using map and flatMap directly
const ret1 = getOrElse(
flatMap(
map(rightValue, (x) => x + 1),
(x) => right(x * 2)
),
0
);

const ret2 = getOrElse(
flatMap(
map(leftValue, (x) => x + 1),
(x) => right(x * 2)
),
0
);

console.log(ret1); // 输出: 12
console.log(ret2); // 输出: 0

IO Functor

IO Functor 相比于上面提到的 Functor, 有以下特点:

  • IO Functor 的私有变量 #value 是一个函数, 把函数当做值来处理.
  • #value 作为函数存储, 好处是可以缓存, 惰性执行. 调用了才执行
  • 把不是纯函数的操作交给调用者处理

下面是一个 IO 的例子

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
41
42
43
44
45
46
47
48
49
50
51
class IO<T> {
#effect: () => T;

constructor(effect: () => T) {
if (typeof effect !== "function") {
throw new TypeError("IO expects a function");
}
this.#effect = effect;
}

// map method to transform the value inside IO
map<U>(fn: (value: T) => U): IO<U> {
return IO.of(() => fn(this.#effect()));
}

// run method to execute the effect
run(): T {
return this.#effect();
}

// static of method to create an IO instance
static of<T>(value: T | (() => T)): IO<T> {
return new IO(
// 为了兼容普通值, 如果用 of 传入的普通参数, 将其视为一个函数执行
typeof value === "function" ? (value as () => T) : () => value
);
}
}

// 使用示例

// 创建一个IO实例,将其包装一个副作用函数
const io = IO.of(5)
.map((x) => x + 1)
.map((x) => x * 2);

// 运行并获取最终的结果
const result = io.run();
console.log(result); // 输出: 12

// 创建一个读取用户输入的IO Functor
const readInput = IO.of(() => prompt("Enter a value:"));
const p = (x: string) => IO.of(() => console.log(`You entered: ${x}`));

const app = readInput
.map((input) => input?.toUpperCase() || "")
.map((input) => `Hello, ${input}!`)
.map(p)
.map((io) => io.run());

app.run(); // 将显示一个提示输入框,然后打印用户输入的值

将其改为函数式表达, 省略掉静态方法 of:

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
41
42
43
type Effect<T> = () => T;

interface IO<T> {
map<U>(fn: (value: T) => U): IO<U>;
run(): T;
}

const IO = <T>(value: T | Effect<T>): IO<T> => {
const effect: Effect<T> =
typeof value === "function" ? (value as Effect<T>) : () => value;

return {
map<U>(fn: (value: T) => U): IO<U> {
return IO(() => fn(effect()));
},
run(): T {
return effect();
},
};
};

// 使用示例

// 创建一个IO实例,将其包装一个副作用函数
const io = IO(5)
.map((x) => x + 1)
.map((x) => x * 2);

// 运行并获取最终的结果
const result = io.run();
console.log(result); // 输出: 12

// 创建一个读取用户输入的IO Functor
const readInput = IO(() => prompt("Enter a value:"));
const p = (x: string) => IO(() => console.log(`You entered: ${x}`));

const app = readInput
.map((input) => input?.toUpperCase() || "")
.map((input) => `Hello, ${input}!`)
.map(p)
.map((io) => io.run());

app.run(); // 将显示一个提示输入框,然后打印用户输入的值

Async IO Functor

我们将上面的IO 变成异步的 AsyncIO 去获取数据:

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
class AsyncIO<T> {
private effect: () => Promise<T>;

constructor(effect: () => Promise<T>) {
if (typeof effect !== "function") {
throw new TypeError("AsyncIO expects a function");
}
this.effect = effect;
}

// map method to transform the value inside AsyncIO
map<U>(fn: (value: T) => U): AsyncIO<U> {
return new AsyncIO<U>(async () => {
const result = await this.effect();
return fn(result);
});
}

// run method to execute the effect
async run(): Promise<T> {
return await this.effect();
}

// static of method to create an AsyncIO instance
static of<T>(value: T | (() => Promise<T>)): AsyncIO<T> {
return new AsyncIO<T>(async () => {
if (typeof value === "function") {
return await (value as () => Promise<T>)();
} else {
return value;
}
});
}
}

// 使用示例

// 创建一个AsyncIO实例,将其包装一个异步任务
const fetchData = AsyncIO.of(async () => {
const response = await fetch("https://jsonplaceholder.typicode.com/todos/1");
if (!response.ok) {
throw new Error("Network response was not ok");
}
return response.json();
});

// 使用map方法对数据进行转换
const processedData = fetchData.map((data) => ({
...data,
title: data.title.toUpperCase(),
completed: data.completed ? "Yes" : "No",
}));

// 运行并获取最终的结果
processedData
.run()
.then((result) => {
console.log(result); // { userId: 1, id: 1, title: 'DELECTUS AUT AUTEM', completed: 'No' }
})
.catch((error) => {
console.error("Error:", error);
});

// 用顶层 await的方式获取:
const ret = await processedData.run();
console.log(ret); // { userId: 1, id: 1, title: 'DELECTUS AUT AUTEM', completed: 'No' }
// 顶层 await 必须添加 export {}:
export { ret };

同样的, 我们也可以将 class 改为函数式编写:

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
type Effect<T> = () => Promise<T>;

interface AsyncIO<T> {
map<U>(fn: (value: T) => U): AsyncIO<U>;
run(): Promise<T>;
}

const AsyncIO = <T>(effect: Effect<T>): AsyncIO<T> => {
if (typeof effect !== "function") {
throw new TypeError("AsyncIO expects a function");
}

return {
map<U>(fn: (value: T) => U): AsyncIO<U> {
return AsyncIO(async () => {
const result = await effect();
return fn(result);
});
},
run(): Promise<T> {
return effect();
},
};
};

AsyncIO.of = <T>(value: T | Effect<T>): AsyncIO<T> => {
return AsyncIO(async () => {
if (typeof value === "function") {
return await (value as Effect<T>)();
} else {
return value;
}
});
};

Monad 单子 (重点)

  • Monad 是可以扁平化的 Pointed Functor, 一般扁平化的函数名是 flatMap
  • Monad 是含有 join 和 静态函数 of 的特殊 Functor

下面我们举例说明为什么要使用 Monad, 假设我们要实现类似 Linux 的 cat 命令, 即读取一个文件在将其打印出来, 我们用普通的IO Functor 来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { pipe as compose } from "./02-compose02-reduce.js";
import fs from "fs";

class Monad<T> {
effect: () => T;
constructor(fn) {
this.effect = fn;
}

static of(value) {
return new Monad(() => value);
}
map<N>(fn: (value: T) => N): Monad<N> {
return new Monad(() => fn(this.effect()));
}
}

尽管上面的代码我暂时写的比较不规范, 例如: effect 没有使用私有变量, 因为我们等下需要在调用时用到, 我们再来看看调用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const readFile = (filename) => {
return new Monad(() => {
return fs.readFileSync(filename, "utf-8");
});
};

const print = (x) => {
return new Monad(() => {
return x;
});
};

const catCmd = compose(readFile, print);

// Monad(Monad(x))
const result = catCmd("package.json").effect().effect();
console.log(result); // 打印出根目录下的 package.json 文件里的内容

不难发现, 我们在调用时如果 .effect() 的时候, 会得到一个 Monad 类型, MonadFunctor 的一种, 所有 Functor 都是装着值的容器, 上面由于调用了 2 次 .effect() 所以 result 的类型是嵌套的类型: Monad<Monad<T>>

这样, 我们就需要调用 .effect() 再次得到 T 类型, 才能达到我们需要的效果. 但这种调用方式显然是我们不愿意看到的, 如果有 n 层 Functor 包裹, 那么我们就需要调用 n 次 .effect(). 太恶心了.

这时候就要解决 Monad Functor的嵌套问题, 我们可以定义一个 flatMap 方法来解决, 如下:

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
41
42
43
44
45
46
47
48
49
50
51
52
import fs from "fs";
import { pipe as compose } from "./02-compose02-reduce.js";

class CatIO<T> {
#effect: () => T;

constructor(fn: () => T) {
if (typeof fn !== "function") {
throw new TypeError("CatIO expects a function");
}
this.#effect = fn;
}

static of<T>(value: T): CatIO<T> {
return new CatIO(() => value);
}

map<U>(fn: (value: T) => U): CatIO<U> {
return new CatIO(() => fn(this.#effect()));
}

join<U>(this: CatIO<CatIO<U>>): CatIO<U> {
return this.#effect();
}

flatMap<U>(fn: (value: T) => CatIO<U>): CatIO<U> {
return this.map(fn).join();
}

run(): T {
return this.#effect();
}
}

// 调用示例:
const readFile = (filename: string): CatIO<string> => {
return new CatIO(() => {
return fs.readFileSync(filename, "utf-8");
});
};

const print = (x: string): CatIO<string> => {
return new CatIO(() => {
console.log(x); // 打印文件内容
return x;
});
};

const catCmd = compose(readFile, print);

const result = catCmd("package.json").flatMap(print).run();
console.log(result);

当然, 我们也可以把 class 改为函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// IO effect type
type IO<T> = {
run: () => T;
map: <U>(fn: (value: T) => U) => IO<U>;
flatMap: <U>(fn: (value: T) => IO<U>) => IO<U>;
};

// Helper function to create an IO instance
const CatIO = <T>(effect: () => T): IO<T> => {
return {
run: effect,
map: function <U>(fn: (value: T) => U): IO<U> {
return CatIO(() => fn(this.run()));
},
flatMap: function <U>(fn: (value: T) => IO<U>): IO<U> {
return this.map(fn).run();
},
};
};

// Static `of` method to create an IO instance with a value
CatIO.of = <T>(value: T): IO<T> => {
return CatIO(() => value);
};

Monoid

在函数式编程中,Monoid 是一个代数结构,它包含一个结合运算和一个单位元。

js 中,Monoid 可以看作是 reduce 操作的一种抽象和泛化。

下面我们将以一些简单的例子来介绍 Monoid 的概念:

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
interface Monoid<T> {
empty: () => T;
concat: (x: T, y: T) => T;
}

// string 拼接
const stringMonoid: Monoid<string> = {
empty: () => "",
concat: (x: string, y: string) => x + y,
};

// 两数相加
const numberMonoid: Monoid<number> = {
empty: () => 0,
concat: (x: number, y: number) => x + y,
};

// 组合函数: 实际上底层操作仍是 reduce, 但我们必须学会这种写法:
const fold = <T>(monoid: Monoid<T>, values: T[]): T => {
return values.reduce(monoid.concat, monoid.empty());
};

// 使用示例:
const stringArray = ["Hello", ", ", "World", "!"];
const numberArray = [1, 2, 3, 4, 5];

const foldedString = fold(stringMonoid, stringArray);
const foldedNumber = fold(numberMonoid, numberArray);

console.log("Folded String:", foldedString); // 输出: "Hello, World!"
console.log("Folded Number:", foldedNumber); // 输出: 15

来个稍微复杂点的需求: 处理 json array, 这种是开发 api 过程中最常见接口类型. staff 数组里的记录按照部门分组, 再按照部门分数求平均分

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
// 代表Monoid 的接口
interface Monoid<T> {
empty: (length?: number) => T;
concat: (x: T, y: T) => T;
}

type StaffMember = {
name: string;
age: number;
dept: string;
scores: number[];
};

// 定义一个新的平均分数计算 Monoid
const scoreArrayMonoid: Monoid<number[]> = {
empty: (length: number = 0) => Array(length).fill(0),
concat: (x, y) => {
return x.map((val, index) => val + (y[index] ?? 0));
},
};

const calculateAverageScores = (
monoid: Monoid<number[]>,
scoresArray: number[][]
): number[] => {
const initialScores = monoid.empty(scoresArray[0].length);
const totalScores = scoresArray.reduce(
(acc, scores) => monoid.concat(acc, scores),
initialScores
);

return totalScores.map((total) => total / scoresArray.length);
};

// 分组函数
const groupBy = <T>(arr: T[], key: keyof T): Record<string, T[]> => {
return arr.reduce((acc, item) => {
const group = item[key] as unknown as string;
if (!acc[group]) {
acc[group] = [];
}
acc[group].push(item);
return acc;
}, {} as Record<string, T[]>);
};

// 平均分数插入函数
const addAverageScore = (
groupedData: Record<string, StaffMember[]>,
monoid: Monoid<number[]>
): Record<string, (StaffMember | { dept: string; scores: number[] })[]> => {
const result: Record<
string,
(StaffMember | { dept: string; scores: number[] })[]
> = {};

for (const dept in groupedData) {
const members = groupedData[dept];
const allScores = members.map((member) => member.scores);
const avgScores = calculateAverageScores(monoid, allScores);

result[dept] = [...members, { dept, scores: avgScores }];
}

return result;
};

// 示例数据
const staff: StaffMember[] = [
{ name: "Max", age: 20, dept: "IT", scores: [29, 23, 28] },
{ name: "Jane", age: 20, dept: "IT", scores: [33, 23, 28] },
{ name: "Alex", age: 55, dept: "sales", scores: [26, 23, 28] },
{ name: "May", age: 45, dept: "IT", scores: [31, 23, 28] },
{ name: "Kelly", age: 34, dept: "sales", scores: [30, 27, 22] },
];

// 分组并计算平均分
const groupedByDept = groupBy(staff, "dept");
const result = addAverageScore(groupedByDept, scoreArrayMonoid);

console.log(result);

输出的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
IT: [
{ name: 'Max', dept: 'IT', scores: [ 29, 23, 28 ] },
{ name: 'Jane', dept: 'IT', scores: [ 33, 23, 28 ] },
{ name: 'May', dept: 'IT', scores: [ 31, 23, 28 ] },
{ dept: 'IT', scores: [ 31, 23, 28 ] }
],
sales: [
{ name: 'Alex', dept: 'sales', scores: [ 26, 23, 28 ] },
{ name: 'Kelly', dept: 'sales', scores: [ 30, 27, 22 ] },
{ dept: 'sales', scores: [ 28, 25, 25 ] }
]
}

常用的函数式编程库

参考资料: