函数式编程, 近年来逐渐由学术领域向商用领域渗透, 比起传统的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);
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 查询
访问系统状态
…
原书举了一个很好的例子说明纯函数的概念, 即 slice
和 splice
:
1 2 3 4 5 6 7 8 9 10 11
| var xs = [1, 2, 3, 4, 5];
xs.slice(0, 3); xs.slice(0, 3); xs.slice(0, 3);
xs.splice(0, 3); xs.splice(0, 3); xs.splice(0, 3);
|
从上面的示例可以看出
slice
是纯函数, 因为无论调用多少次, 都会返回相同的结果.
splice
不是纯函数, 因为每次调用都会改变原数组, 并且返回值也不一样.
另一个例子:
1 2 3 4 5 6 7 8 9 10 11
| let minimum = 21; const checkAge = (age) => age > minimum;
console.log(checkAge(24));
minimum = 27; console.log(checkAge(24));
minimum = 18; console.log(checkAge(24));
|
改为纯函数, 将 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));
minimum = 27; console.log(checkAge(24));
minimum = 18; console.log(checkAge(24));
|
另一种改法:
1 2 3 4 5 6
| const immutableMinimum = Object.freeze({ value: 21, }); const checkAge = (age) => age > immutableMinimum;
console.log(checkAge(24));
|
利用 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); console.log(result);
|
特征
纯函数具有执行不依赖外部变量, 可缓存 (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 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)); console.log(memoizedSumOfSquares(numbers));
|
作者在书中将函数变为纯函数的例子:
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
| const signUpImpure = (Db, Email, attrs) => { const user = saveUser(Db, attrs); welcomeUser(Email); };
const signUp = (Db, Email, attrs) => () => { const user = saveUser(Db, attrs); welcomeUser(Email); };
|
相应的, 他们在调用时就产生区别:
1 2 3 4 5 6 7 8
| const signUpUserImpure = signUp(database, emailService, userAttrs);
signUpUserImpure;
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);
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");
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());
|
上面的 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");
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());
|
柯里化 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; }; };
var increment = add(1); var addTen = add(10);
increment(2);
addTen(2);
|
再看下面的代码, 未使用柯里化的普通请求:
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
, 创建专门的请求函数 postToApi
和 getFromApi
, 如果他们放在同一个文件里再导出postToApi
和 getFromApi
, 那么调用的时候, 我们只需传入 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");
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");
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);
|
当然上面的 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("");
const result = composePipeCurry(toUpperCase, exclaim, reverse)("hello");
console.log(result);
|
其实, 我还是喜欢第一种 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
| var snakeCase = function (word) { return word.toLowerCase().replace(/\s+/gi, "_"); };
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 }, ];
const processPerson = (person) => { const upperCaseName = person.name.toUpperCase(); const incrementedAge = person.age + 1; const greeting = `Hello, ${upperCaseName}!`;
return { name: upperCaseName, age: incrementedAge, greeting: greeting, }; };
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
| 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}!`;
const toUpperCaseName = (obj) => ({ ...obj, name: toUpperCase(obj.name) }); const incrementAge = (obj) => ({ ...obj, age: increment(obj.age) }); const addGreeting = (obj) => ({ ...obj, greeting: createGreeting(obj.name) });
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
| var capitalize = function (s) { return toUpperCase(head(s)) + toLowerCase(tail(s)); };
capitalize("smurf");
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| var strLength = function (s) { return s.length; };
var join = curry(function (what, xs) { return xs.join(what); });
var match = curry(function (reg, s) { return s.match(reg); });
var replace = curry(function (reg, sub, s) { return s.replace(reg, sub); });
|
相同的变量名,其类型也一定相同
类似 ts 的泛型:
1 2 3 4 5
| const id = (x) => x;
const map = curry((f, xs) => xs.map(f));
|
接口在类型签名中的表示:
这里的 Ord
, Eq
, Show
均为接口, 接口的实现实现用 =>
表示
Functor
函子 (重点)
Functor
是实现了 map
函数并遵守一些特定规则的容器类型。
本文花费了大量时间于 Functor
这章的内容, 因为 Functor 里的 Maybe
, Either
等概念在 js 里没有原生支持, 而反观纯函数式语言的 Haskell
, Scala
, F#
等语言都有.
下面是提问 Chatgpt 后的回答:
Functor
是一个具有 map
方法的容器或上下文,该方法允许你将一个函数应用到容器内的每个值,并返回一个新的容器。
总结起来, Functor 就是允许使用 map 函数变换里面内容的容器. 在函数式编程中, 我们已经使用很久了, 但可能你并没意识到你在使用它. 例如, 在 JavaScript 中, Array
和 String
都是 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> { 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);
|
接着, 我们一步步来完善上面的实现
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; }
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);
|
由于 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<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);
|
将其改为函数的形式:
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);
|
Functor
除了上面所提及的基础内容, 在实际开发中, 还有一个大功能, 是用来解决纯函数编程的副作用问题. 把副作用控制在我们可控的范围内. 这些副作用包括
下面, 我们就 Functor
的副作用特征, 来展开讨论.
Maybe Functor
Maybe
是用于处理判断传入的值是否为 null
和 undefined
的 Functor
. 在 Scala
中, 相当于 Option
而如果在 JS 里运用上 Maybe
的类型检查, 那么可以避免很多的 if
判断. 他原生的捕获了 null
和 undefined
的问题, 并且提供了 map
方法, 使得我们可以在 Maybe
类型中应用 map
方法. 使得程序不会因为 null
和undefined
值报错从而使程序报错或中断.
我们自己来实现一个 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);
const m1 = Maybe("hello world") .map((x) => x.toUpperCase()) .extract((x) => x);
console.log(m1);
|
稍微解释一下上面的代码:
- 上面的代码改为函数式后, 更为精简, 连 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);
|
这时, Either
就派上用场了.
Either
是一个用于处理调用过程中错误和异常的 Functor
.
Either
是一个表示可能含有两种类型值的数据结构,它通常用于表示一个计算的结果可能成功(含有一个值)或失败(含有一个错误或另一种值)。
Either
有两个子构造:Left
和 Right
。按照惯例,Right
通常表示成功,而 Left
通常表示失败或错误。Either
是这两种构造的叠加态.
以下是 Scala
关于 Either
的一个简单例子, 可以说明 Either
的作用:
1 2 3 4 5 6
| def divide(a: Int, b: Int): Either[String, Double] = { if (b == 0) Left("Division by zero") 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); console.log(result2);
|
将上面的 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<U>(fn: (value: T) => U): IO<U> { return IO.of(() => fn(this.#effect())); }
run(): T { return this.#effect(); }
static of<T>(value: T | (() => T)): IO<T> { return new IO( typeof value === "function" ? (value as () => T) : () => value ); } }
const io = IO.of(5) .map((x) => x + 1) .map((x) => x * 2);
const result = io.run(); console.log(result);
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(); }, }; };
const io = IO(5) .map((x) => x + 1) .map((x) => x * 2);
const result = io.run(); console.log(result);
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<U>(fn: (value: T) => U): AsyncIO<U> { return new AsyncIO<U>(async () => { const result = await this.effect(); return fn(result); }); }
async run(): Promise<T> { return await this.effect(); }
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; } }); } }
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(); });
const processedData = fetchData.map((data) => ({ ...data, title: data.title.toUpperCase(), completed: data.completed ? "Yes" : "No", }));
processedData .run() .then((result) => { console.log(result); }) .catch((error) => { console.error("Error:", error); });
const ret = await processedData.run(); console.log(ret);
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);
const result = catCmd("package.json").effect().effect(); console.log(result);
|
不难发现, 我们在调用时如果 .effect()
的时候, 会得到一个 Monad
类型, Monad
是 Functor
的一种, 所有 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
| type IO<T> = { run: () => T; map: <U>(fn: (value: T) => U) => IO<U>; flatMap: <U>(fn: (value: T) => IO<U>) => IO<U>; };
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(); }, }; };
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; }
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, };
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); console.log("Folded Number:", foldedNumber);
|
来个稍微复杂点的需求: 处理 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
| interface Monoid<T> { empty: (length?: number) => T; concat: (x: T, y: T) => T; }
type StaffMember = { name: string; age: number; dept: string; scores: number[]; };
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 ] } ] }
|
常用的函数式编程库
参考资料: