函子的学习必要性
到目前为止已经已经学习了函数式编程的一些基础,但是我们还没有演示在函数式编程中如何把副作用控制在可控的范围内、异常处理、异步操作等。
Functor函子
- 容器:包含值和值的变形关系(变形关系就是函数)
- 函子:是一个特殊的容器,通过一个普通的对象实现,该对象具有map方法,map方法可以运行一个函数对值进行处理(变形关系)
// 一个容器,包裹一个值
class Container {
// of 静态方法,可以省略 new 关键字创建对象
static of(value) {
return new Container(value);
}
constructor(value) {
this._value = value;
}
// map 方法,传入变形关系,将容器里的每一个值映射到另一个容器
map(fn) {
return Container.of(fn(this._value));
}
}
// 测试
console.log(
Container.of(5)
.map((x) => x + 1)
.map((x) => x * x)
);
// Container { _value: 36 }
总结:
- 函数式编程的运算不直接操作值,而是由函子完成
- 函子就是一个实现了map契约的对象
- 咱们可以把函子想象成一个盒子,这个盒子里封装了一个值
- 如果想要处理盒子里的这个值,我们需要给map方法传递一个处理值的函数(纯函数),由这个函数来对值进行处理
- 最终map返回一个包含新值的盒子(函子)
MayBe函子
// MayBe函子
class MayBe {
static of(value) {
return new MayBe(value);
}
constructor(value) {
this._value = value;
}
map(fn) {
return this.isNoting() ? MayBe.of(null) : MayBe.of(fn(this._value));
}
isNoting() {
return this._value === null || this._value === undefined;
}
}
let r = MayBe.of("Hello World").map((x) => x.toUpperCase());
console.log(r);
Maybe函子是一种用于处理可能为空的值的函子,它可以帮助我们更方便地处理空值情况,并且避免了出现null或undefined等异常值导致程序崩溃的风险。在实现上,Maybe函子通常会定义一个构造函数,用来包装普通的值。如果值存在,则将该值存储在Maybe函子中,否则创建一个空的Maybe函子。接着,就可以在后续操作中针对Maybe函子进行处理,而不用再担心空值的情况。
优点:
- 可以轻松地处理可能为空的值,避免了因为空值引发的异常和错误;
- 能够提高代码的可读性和可维护性,使得代码更加简洁、清晰。
缺点:
- 在某些场景下,可能需要额外的函数调用来处理Maybe函子中的值,这可能会增加一些复杂度;
- 在频繁使用Maybe函子的情况下,也可能会对性能造成一定的影响。
Either函子
- Either 两者中的任何一个,类似于 if...else...的处理
- 异常会让函数变的不纯,Either 函子可以用来做异常处理
// Either函子
class Left {
static of(value) {
return new Left(value);
}
constructor(value) {
this._value = value;
}
map(fn) {
return this;
}
}
class Right {
static of(value) {
return new Right(value);
}
constructor(value) {
this._value = value;
}
map(fn) {
return Right.of(fn(this._value));
}
}
function parseJSON(json) {
try {
return Right.of(JSON.parse(json));
} catch (e) {
return Left.of({ error: e.message });
}
}
let r = parseJSON('{ "name": "zs" }').map((x) => x.name.toUpperCase());
let rWithError = parseJSON('{ name: "zs" }').map((x) => x.name.toUpperCase());
console.log(r);
console.log(rWithError);
IO函子
// IO函子
const fp = require("lodash/fp");
class IO {
static of(x) {
return new IO(function () {
return x;
});
}
constructor(fn) {
this._value = fn;
}
map(fn) {
// 把当前的value和传入的fn组合成一个新的函数
return new IO(fp.flowRight(fn, this._value));
}
}
let io = IO.of(process).map((p) => p.execPath);
console.log(io._value());
这段代码定义了一个IO函子,并且演示了如何使用map方法对IO函子进行组合。
首先,我们可以看到在类的静态方法of
中,创建了一个新的IO函子,该函子的值为x。这个方法是用来创建纯粹的、没有副作用的IO函子的快捷方式。
接着,在IO构造函数中,我们将传入的函数fn存储在_value属性中。这个函数就是代表具有副作用的操作,例如读取文件或发送网络请求等。但是,在IO函子中,这些操作并不会立即执行,而是在后续调用map和run方法时才会执行。
然后,我们看到在map方法中,使用fp.flowRight方法将fn和传入的fn组合成一个新的函数。flowRight方法可以将多个函数从右到左进行组合,从而得到一个新的函数,并且每个函数的执行结果都将作为下一个函数的参数。在这里,我们将当前IO函子中存储的fn函数放在新函数的最后一位,从而保证整个组合后的函数依然具有副作用。
最后,在main函数中,我们调用IO.of(process).map((p) => p.execPath)创建一个新的IO函子io,并且调用io._value()来获取最终结果。由于IO函子是惰性求值的,所以在调用map方法时并不会立即执行操作,而是等到调用_value方法时才会执行。
总之,这段代码演示了如何使用IO函子来封装带有副作用的操作,并且通过map方法进行组合。由于IO函子是惰性求值的,所以可以避免一开始就执行具有副作用的操作,从而保证程序的稳定性和可靠性。
folktale
- 异步任务的实现过于复杂,我们可以使用folktale的Task来实现
folktale一个标准的函数式编程库
- 和lodash以及ramda不同的是,他并没有提供很多的功能函数
- 只提供了一些函数式处理的操作,例如:compose、curry 等,一些函子 Task、Either、 MayBe 等
// 使用folktale
// folktale中的compose和curry
const { compose, curry } = require("folktale/core/lambda");
const { toUpper, first } = require("lodash/fp");
let r = curry(2, (x, y) => x + y);
console.log(r(1, 2));
console.log(r(1)(2));
let comp = compose(toUpper, first);
console.log(comp(["one", "two"]));
Task异步执行
Task函子
// Task异步任务
const fs = require("fs");
const { split, find } = require("lodash/fp");
const { task } = require("folktale/concurrency/task");
function readFile(fileName) {
return task((resolver) => {
fs.readFile(fileName, "utf-8", (err, data) => {
if (err) resolver.reject(err);
resolver.resolve(data);
});
});
}
readFile("package.json")
.map(split("\n"))
.map(find((item) => item.includes("version")))
.run()
.listen({
onRejected: (err) => console.log(err),
onResolved: (data) => console.log(data),
});
这段代码使用Task异步任务来读取文件,并且演示了如何使用map方法对任务进行组合。
首先,我们可以看到在readFile函数中,创建了一个新的Task对象,并且将具有副作用的读取文件操作封装在任务中。这个任务是一个异步任务,它会在后台执行具有副作用的读取文件操作,并通过resolve或reject方法来返回最终结果。
接着,在main函数中,我们调用readFile函数来创建一个读取文件的任务,并且对该任务进行了一系列的组合。例如,通过map方法将文件内容按行分割成数组,然后再通过find方法找到包含"version"的那一行。这些操作仍然是一个异步任务,它们并不会立即执行。
最后,我们调用run方法来运行整个Task,同时利用listen方法来监听Task的执行状态。如果Task成功执行,则会执行onResolved回调函数,否则会执行onRejected回调函数。这个过程是非阻塞的,可以确保程序的稳定性和可靠性。
总之,这段代码演示了如何使用Task异步任务来处理具有副作用的操作,并且通过map方法进行组合。由于Task是一个惰性求值的工具,它能够避免阻塞主线程,提高程序的响应速度和稳定性。
Pointed函子
- Pointed 函子是实现了 of 静态方法的函子
- of 方法是为了避免使用 new 来创建对象,更深层的含义是 of 方法用来把值放到上下文Context(把值放到容器中,使用 map 来处理值)
class Container {
static of(value) {
return new Container(value);
}
constructor(value) {
this._value = value;
}
map(fn) {
return Container.of(fn(this._value));
}
}
console.log(Container.of(5).map((x) => x * 5));
单子Monad
IO函子的问题
// IO函子
const fp = require("lodash/fp");
const fs = require("fs");
class IO {
static of(x) {
return new IO(function () {
return x;
});
}
constructor(fn) {
this._value = fn;
}
map(fn) {
// 把当前的value和传入的fn组合成一个新的函数
return new IO(fp.flowRight(fn, this._value));
}
}
let readFile = function (filename) {
return new IO(function () {
return fs.readFileSync(filename, "utf-8");
});
};
let print = function (x) {
return new IO(function () {
console.log(x);
return x;
});
};
let cat = fp.flowRight(print, readFile);
let r = cat("package.json");
console.log(r._value()._value());
在上面的代码中,readFile
和 print
都返回了一个 IO
函子实例,而 cat
则是通过 fp.flowRight
进行组合后返回了一个新的 IO
函子实例。因此,在执行 cat("package.json")
后得到的结果 r
是一个 IO
函子实例。
当我们执行 r._value()
时,会得到一个函数,这个函数又返回了一个 IO
函子实例。因此,执行 r._value()._value()
时,实际上是对嵌套的两个 IO
函子分别进行了调用,并将它们的结果返回了出来。这样的嵌套调用是很不方便的
Monad
- Monad 函子是可以变扁的 Pointed 函子,IO(IO(x))
- 一个函子如果具有 join 和 of 两个方法并遵守一些定律就是一个 Monad
const fp = require("lodash/fp"); // IO Monad
const fs = require("fs");
class IO {
static of(x) {
return new IO(function () {
return x;
});
}
constructor(fn) {
this._value = fn;
}
map(fn) {
return new IO(fp.flowRight(fn, this._value));
}
join() {
return this._value();
}
flatMap(fn) {
return this.map(fn).join();
}
}
let readFile = function (filename) {
return new IO(function () {
return fs.readFileSync(filename, "utf-8");
});
};
let print = function (x) {
return new IO(function () {
console.log(x);
return x;
});
};
let r = readFile("package.json").map(fp.toUpper).flatMap(print).join();
上面的代码中,我们定义了一个 IO
类,在该类中我们实现了 map
、join
和 flatMap
方法。其中:
map
方法接受一个函数作为参数,将当前函子实例的值传入该函数,并将函数执行后的结果作为新的值创建一个新的IO
函子对象返回。join
方法用于解封当前函子实例,将其内部的值提取出来并返回。flatMap
方法是对map
和join
两个方法的组合使用,可以进行函数的映射和函子的解封操作。
在上面的代码中,我们使用 readFile
方法读取了一个文件,并通过 map
方法将文件内容转换成大写字母(使用了 Lodash 中的 fp.toUpper
方法)。然后,我们通过 flatMap
方法调用了 print
方法打印输出了文件内容。最后,通过 join
方法解封了 IO
函子得到了最终的结果。
需要注意的是,在 flatMap
方法中,我们先调用 map
方法进行了函数的映射,然后再调用 join
方法解封内部的函子。这样做是为了避免嵌套函子的问题,从而使代码更加简洁易读。