第八章:容器宝典
强大的容器

我们已经学会了如何通过管道式组合纯函数处理数据。这些函数是行为的声明式规范。但控制流、错误处理、异步操作、状态管理以及作用该如何处理?本章将揭示这些重要抽象机制的构建基础。
首先构建基础容器。该容器应该能容纳任何类型的值——若只能存放西米布丁将失去实用价值。我们将以对象形式实现,但不赋予其面向对象意义上的属性和方法。这个容器应当被视为珍宝匣,是承载数据的特殊盒子。
class Container {
constructor(x) {
this.$value = x;
}
static of(x) {
return new Container(x);
}
}这是我们的首个容器,特地命名为Container。通过Container.of构造函数可以避免到处使用new关键字。of函数的奥妙远不止于此,但目前可将其视为向容器注入值的规范方式。
让我们审视这个崭新的容器...
Container.of(3);
// Container(3)
Container.of('hotdogs');
// Container("hotdogs")
Container.of(Container.of({ name: 'yoda' }));
// Container(Container({ name: 'yoda' }))在Node环境中容器显示为{$value: x}而非预期的Container(x)。Chrome能正确输出类型,不过这无关紧要。只要理解容器结构即可。虽然能改写inspect方法展示美观类型信息,但为简明起见,本书将以概念化形式示意容器输出效果。
在深入之前明确几个要点:
Container是单一属性的对象。尽管多数容器只保存单值,但容量并不受此限。我们约定属性名为$value。$value不能固定为特定类型,否则将违背容器通用性原则。数据一经存入即受容器保护。虽然可通过
.$value取出,但这种方式违背设计初衷。
这种设计的优势将逐渐显现,此刻请暂时保持耐心。
第一个函子(Functor)
当数值被容器包裹后,需要一个执行函数操作的机制。
// (a -> b) -> Container a -> Container b
Container.prototype.map = function (f) {
return Container.of(f(this.$value));
};这类似于数组的map方法,但我们操作的是Container a而非[a]。工作原理本质相同:
Container.of(2).map(two => two + 2);
// Container(4)
Container.of('flamethrowers').map(s => s.toUpperCase());
// Container('FLAMETHROWERS')
Container.of('bombs').map(append(' away')).map(prop('length'));
// Container(10)我们始终保持值在Container中操作。这种特性极其重要:容器内的值通过map传递处理,处理完毕立即放回容器保存。由于值从未脱离容器保护,我们可以链式调用多个map操作。如第三个示例所示,在此过程中还能改变值类型。
注意多次调用map实际构成了函数组合!背后的数学原理是什么?朋友们,这就是著名的*函子(Functor)*概念。
函子是实现
map方法并遵循特定法则的类型
具体而言,函子(Functor)是包含契约的接口。虽然可命名为Mappable,但这样就失去了函子的趣味来源。函子源于范畴论,本章末将探讨其数学基础,现在让我们专注于直觉理解和实践应用。
为什么要包裹值并通过map操作?换种问法可能启发思路:委托容器执行函数能带来什么?答案是实现了函数应用的抽象。当我们map函数时,其实请求容器代为执行——这是极具威力的设计理念。
薛定谔的Maybe

基础Container功能单一,通常称作Identity容器(其数学内涵后文详述)。真正实用的函子是像Maybe这样的变体,它在映射前会进行空值检查(此为教学简化版):
完整实现参见附录 B
class Maybe {
static of(x) {
return new Maybe(x);
}
get isNothing() {
return this.$value === null || this.$value === undefined;
}
constructor(x) {
this.$value = x;
}
map(fn) {
return this.isNothing ? this : Maybe.of(fn(this.$value));
}
inspect() {
return this.isNothing ? 'Nothing' : `Just(${inspect(this.$value)})`;
}
}Maybe继承自Container,新增空值校验机制。在map操作前检查是否存在有效值,从而规避空指针异常。
Maybe.of('Malkovich Malkovich').map(match(/a/ig));
// Just(True)
Maybe.of(null).map(match(/a/ig));
// Nothing
Maybe.of({ name: 'Boris' }).map(prop('age')).map(add(10));
// Nothing
Maybe.of({ name: 'Dinah', age: 14 }).map(prop('age')).map(add(10));
// Just(24)即使映射空值也不会引发程序崩溃。因为Maybe在每次函数应用时都会检查有效性。
虽然点语法可用,但根据前文原则我们倾向无点风格(pointfree)。实际上无论采用哪种函子,map都能正确代理:
// map :: Functor f => (a -> b) -> f a -> f b
const map = curry((f, anyFunctor) => anyFunctor.map(f));这使函数组合更加优雅。Ramda的map同样适用该规则。教学时适当使用点语法,实践中可自由选择。注意此处类型签名中引入Functor f =>,表明f必须是函子——这个概念不难理解,但需要特别指出。
应用场景
实践中常见Maybe应用于可能返回空值的函数:
// safeHead :: [a] -> Maybe(a)
const safeHead = xs => Maybe.of(xs[0]);
// streetName :: Object -> Maybe String
const streetName = compose(map(prop('street')), safeHead, prop('addresses'));
streetName({ addresses: [] });
// Nothing
streetName({ addresses: [{ street: 'Shady Ln.', number: 4201 }] });
// Just('Shady Ln.')safeHead在标准头部元素获取函数基础上添加类型安全。引入Maybe后,我们必须显式处理空值。该函数通过返回Maybe明示可能失败的情况,并通过map链强制保证取值安全,自动完成空值校验。此类API显著提升代码健壮性,将脆弱的纸片代码加固为木质结构。
有时函数会通过返回Nothing显式标识失败:
// withdraw :: Number -> Account -> Maybe(Account)
const withdraw = curry((amount, { balance }) =>
Maybe.of(balance >= amount ? { balance: balance - amount } : null));
// This function is hypothetical, not implemented here... nor anywhere else.
// updateLedger :: Account -> Account
const updateLedger = account => account;
// remainingBalance :: Account -> String
const remainingBalance = ({ balance }) => `Your balance is $${balance}`;
// finishTransaction :: Account -> String
const finishTransaction = compose(remainingBalance, updateLedger);
// getTwenty :: Account -> Maybe(String)
const getTwenty = compose(map(finishTransaction), withdraw(20));
getTwenty({ balance: 200.00 });
// Just('Your balance is $180')
getTwenty({ balance: 10.00 });
// Nothing当余额不足时withdraw返回Nothing。该设计强制后续流程必须map处理结果,当出现Nothing时后续finishTransaction逻辑将跳过,确保失败后不更新账务数据。这正是预期行为——只有在取款成功时才更新账户状态。
值的释放
经常被忽视的是程序终需产生作用:发送JSON、界面渲染、文件操作等。我们无法直接return输出结果,必须通过特定函数释放容器内的值。这类似于禅宗公案:若程序无任何可观测效果,是否算真正运行?它可能只是空耗CPU周期后归于沉寂...
应用程序应该妥善保管数据直至最终操作。该过程可通过map完成而无需释放值。若试图强行取出Maybe中的值,可能面临值缺失的风险。正如薛定谔的猫处于叠加态,代码逻辑应保持这种状态直至最终操作,以此实现线性控制流。
当然我们也可以选择返回默认值继续流程,这需要maybe工具函数:
// maybe :: b -> (a -> b) -> Maybe a -> b
const maybe = curry((v, f, m) => {
if (m.isNothing) {
return v;
}
return f(m.$value);
});
// getTwenty :: Account -> String
const getTwenty = compose(maybe('You\'re broke!', finishTransaction), withdraw(20));
getTwenty({ balance: 200.00 });
// 'Your balance is $180.00'
getTwenty({ balance: 10.00 });
// 'You\'re broke!'maybe类似于条件判断:map对应if(x !== null)分支,而maybe处理完整的if/else逻辑。
初用Maybe可能稍显繁琐(Swift/Scala开发者会联想到Option(al))。频繁的空值检查看似冗余(尤其在值必定存在时),但习惯后将显著提升安全性——毕竟它能有效避免偷工减料带来的隐患。
编写不安全代码犹如在车流中拋掷彩绘鸡蛋,或使用劣质建材建造养老院。而Maybe为我们的函数安全保驾护航。
容我指出,实际实现通常将Maybe拆分为Just(x)/Nothing两种类型,以保持参数化特性。这也是为何常见到Some(x)/None的命名方式。
纯错误处理

throw/catch并不纯粹——抛错会中断流程而非返回值。使用Either类型可实现更优雅的错误处理:
完整实现参见附录 B
class Either {
static of(x) {
return new Right(x);
}
constructor(x) {
this.$value = x;
}
}
class Left extends Either {
map(f) {
return this;
}
inspect() {
return `Left(${inspect(this.$value)})`;
}
}
class Right extends Either {
map(f) {
return Either.of(f(this.$value));
}
inspect() {
return `Right(${inspect(this.$value)})`;
}
}
const left = x => new Left(x);Left和Right是Either的两种子类型(省略了父类声明)。它们的行为差异显著:
Either.of('rain').map(str => `b${str}`);
// Right('brain')
left('rain').map(str => `It's gonna ${str}, better bring your umbrella!`);
// Left('rain')
Either.of({ host: 'localhost', port: 80 }).map(prop('host'));
// Right('localhost')
left('rolls eyes...').map(prop('host'));
// Left('rolls eyes...')Left会忽略map操作,Right则与Container行为一致。Left的关键在于携带错误信息。
以日期转年龄为例,Either相比Nothing能提供错误详情:
const moment = require('moment');
// getAge :: Date -> User -> Either(String, Number)
const getAge = curry((now, user) => {
const birthDate = moment(user.birthDate, 'YYYY-MM-DD');
return birthDate.isValid()
? Either.of(now.diff(birthDate, 'years'))
: left('Birth date could not be parsed');
});
getAge(moment(), { birthDate: '2005-12-12' });
// Right(9)
getAge(moment(), { birthDate: 'July 4, 2001' });
// Left('Birth date could not be parsed')如同Nothing,返回Left将中断流程。但此时我们获得了错误诊断信息。类型签名中Either(String, Number)表明可能返回错误消息或有效年龄,这种非正式签名已足够传达类型信息。
// fortune :: Number -> String
const fortune = compose(concat('If you survive, you will be '), toString, add(1));
// zoltar :: User -> Either(String, _)
const zoltar = compose(map(console.log), map(fortune), getAge(moment()));
zoltar({ birthDate: '2005-12-12' });
// 'If you survive, you will be 10'
// Right(undefined)
zoltar({ birthDate: 'balloons!' });
// Left('Birth date could not be parsed')当日期有效时输出预测结果,否则返回包含错误信息的Left。这种处理类似于具备错误提示的异常机制,但更冷静优雅。
流程分支由日期有效性决定,但代码保持线性结构。通常我们会将console.log外置并通过map调用,该示例演示了Right分支的特殊处理。下划线表示忽略右侧值(某些环境需用console.log.bind(console)实现一等函数)。
值得注意:fortune函数完全独立于函子系统。这种设计使核心函数可脱离容器使用,需要时通过map*提升(lift)*到适当容器中。这种方式提高了函数复用性,可灵活适配不同函子。
Either既适合验证错误,也能处理文件丢失等严重错误。建议将其替代Maybe以增强错误反馈。
虽然我们仅用Either处理错误,其实它能表达逻辑析取(||),编码范畴论中的*余积(Coproduct)*概念。作为典型和类型,其可能值数目是组成类型可能性之和。尽管主要作为错误处理函子使用,但其他应用也值得探索。
除map外,either工具函数接收两个处理函数:
// either :: (a -> c) -> (b -> c) -> Either a b -> c
const either = curry((f, g, e) => {
let result;
switch (e.constructor) {
case Left:
result = f(e.$value);
break;
case Right:
result = g(e.$value);
break;
// No Default
}
return result;
});
// zoltar :: User -> _
const zoltar = compose(console.log, either(id, fortune), getAge(moment()));
zoltar({ birthDate: '2005-12-12' });
// 'If you survive, you will be 10'
// undefined
zoltar({ birthDate: 'balloons!' });
// 'Birth date could not be parsed'
// undefined此处id函数原样返回Left值用于错误输出。通过maybe和either的对比可见条件分支处理的提升。
Old McDonald司效应...

在讨论纯函数时,我们见过通过包裹副作用实现纯函数的案例:
// getFromStorage :: String -> (_ -> String)
const getFromStorage = key => () => localStorage[key];若不封装,getFromStorage将因外部状态变化成为非纯函数。包裹后始终返回固定功能,虽然未产生实际作用,但宣称其为纯函数便问心无愧。
但此时该函数如同未拆封的手办被束之高阁。要操作其内容,就需要IO容器的介入。
class IO {
static of(x) {
return new IO(() => x);
}
constructor(fn) {
this.$value = fn;
}
map(fn) {
return new IO(compose(fn, this.$value));
}
inspect() {
return `IO(${inspect(this.$value)})`;
}
}IO特殊之处在于其存储的是函数——但应视为延迟执行的副作用包裹器。其of方法实现延迟执行:虽然写入IO(x),实际存储的是() => x以避免立即求值。注意,为便于理解我们示意性展示IO内部值,但实际必须执行才能获取(这将破坏纯度)。
使用示例:
// ioWindow :: IO Window
const ioWindow = new IO(() => window);
ioWindow.map(win => win.innerWidth);
// IO(1430)
ioWindow
.map(prop('location'))
.map(prop('href'))
.map(split('/'));
// IO(['http:', '', 'localhost:8000', 'blog', 'posts'])
// $ :: String -> IO [DOM]
const $ = selector => new IO(() => document.querySelectorAll(selector));
$('#myDiv').map(head).map(div => div.innerHTML);
// IO('I am some inner html')ioWindow可直接映射,而`# 第八章:容器宝典
强大的容器

我们已经学会了如何通过管道式组合纯函数处理数据。这些函数是行为的声明式规范。但控制流、错误处理、异步操作、状态管理以及作用该如何处理?本章将揭示这些重要抽象机制的构建基础。
首先构建基础容器。该容器应该能容纳任何类型的值——若只能存放西米布丁将失去实用价值。我们将以对象形式实现,但不赋予其面向对象意义上的属性和方法。这个容器应当被视为珍宝匣,是承载数据的特殊盒子。
class Container {
constructor(x) {
this.$value = x;
}
static of(x) {
return new Container(x);
}
}这是我们的首个容器,特地命名为Container。通过Container.of构造函数可以避免到处使用new关键字。of函数的奥妙远不止于此,但目前可将其视为向容器注入值的规范方式。
让我们审视这个崭新的容器...
Container.of(3);
// Container(3)
Container.of('hotdogs');
// Container("hotdogs")
Container.of(Container.of({ name: 'yoda' }));
// Container(Container({ name: 'yoda' }))在Node环境中容器显示为{$value: x}而非预期的Container(x)。Chrome能正确输出类型,不过这无关紧要。只要理解容器结构即可。虽然能改写inspect方法展示美观类型信息,但为简明起见,本书将以概念化形式示意容器输出效果。
在深入之前明确几个要点:
Container是单一属性的对象。尽管多数容器只保存单值,但容量并不受此限。我们约定属性名为$value。$value不能固定为特定类型,否则将违背容器通用性原则。数据一经存入即受容器保护。虽然可通过
.$value取出,但这种方式违背设计初衷。
这种设计的优势将逐渐显现,此刻请暂时保持耐心。
第一个函子(Functor)
当数值被容器包裹后,需要一个执行函数操作的机制。
// (a -> b) -> Container a -> Container b
Container.prototype.map = function (f) {
return Container.of(f(this.$value));
};这类似于数组的map方法,但我们操作的是Container a而非[a]。工作原理本质相同:
Container.of(2).map(two => two + 2);
// Container(4)
Container.of('flamethrowers').map(s => s.toUpperCase());
// Container('FLAMETHROWERS')
Container.of('bombs').map(append(' away')).map(prop('length'));
// Container(10)我们始终保持值在Container中操作。这种特性极其重要:容器内的值通过map传递处理,处理完毕立即放回容器保存。由于值从未脱离容器保护,我们可以链式调用多个map操作。如第三个示例所示,在此过程中还能改变值类型。
注意多次调用map实际构成了函数组合!背后的数学原理是什么?朋友们,这就是著名的*函子(Functor)*概念。
函子是实现
map方法并遵循特定法则的类型
具体而言,函子(Functor)是包含契约的接口。虽然可命名为Mappable,但这样就失去了函子的趣味来源。函子源于范畴论,本章末将探讨其数学基础,现在让我们专注于直觉理解和实践应用。
为什么要包裹值并通过map操作?换种问法可能启发思路:委托容器执行函数能带来什么?答案是实现了函数应用的抽象。当我们map函数时,其实请求容器代为执行——这是极具威力的设计理念。
薛定谔的Maybe

基础Container功能单一,通常称作Identity容器(其数学内涵后文详述)。真正实用的函子是像Maybe这样的变体,它在映射前会进行空值检查(此为教学简化版):
完整实现参见附录 B
class Maybe {
static of(x) {
return new Maybe(x);
}
get isNothing() {
return this.$value === null || this.$value === undefined;
}
constructor(x) {
this.$value = x;
}
map(fn) {
return this.isNothing ? this : Maybe.of(fn(this.$value));
}
inspect() {
return this.isNothing ? 'Nothing' : `Just(${inspect(this.$value)})`;
}
}Maybe继承自Container,新增空值校验机制。在map操作前检查是否存在有效值,从而规避空指针异常。
Maybe.of('Malkovich Malkovich').map(match(/a/ig));
// Just(True)
Maybe.of(null).map(match(/a/ig));
// Nothing
Maybe.of({ name: 'Boris' }).map(prop('age')).map(add(10));
// Nothing
Maybe.of({ name: 'Dinah', age: 14 }).map(prop('age')).map(add(10));
// Just(24)即使映射空值也不会引发程序崩溃。因为Maybe在每次函数应用时都会检查有效性。
虽然点语法可用,但根据前文原则我们倾向无点风格(pointfree)。实际上无论采用哪种函子,map都能正确代理:
// map :: Functor f => (a -> b) -> f a -> f b
const map = curry((f, anyFunctor) => anyFunctor.map(f));这使函数组合更加优雅。Ramda的map同样适用该规则。教学时适当使用点语法,实践中可自由选择。注意此处类型签名中引入Functor f =>,表明f必须是函子——这个概念不难理解,但需要特别指出。
应用场景
实践中常见Maybe应用于可能返回空值的函数:
// safeHead :: [a] -> Maybe(a)
const safeHead = xs => Maybe.of(xs[0]);
// streetName :: Object -> Maybe String
const streetName = compose(map(prop('street')), safeHead, prop('addresses'));
streetName({ addresses: [] });
// Nothing
streetName({ addresses: [{ street: 'Shady Ln.', number: 4201 }] });
// Just('Shady Ln.')safeHead在标准头部元素获取函数基础上添加类型安全。引入Maybe后,我们必须显式处理空值。该函数通过返回Maybe明示可能失败的情况,并通过map链强制保证取值安全,自动完成空值校验。此类API显著提升代码健壮性,将脆弱的纸片代码加固为木质结构。
有时函数会通过返回Nothing显式标识失败:
// withdraw :: Number -> Account -> Maybe(Account)
const withdraw = curry((amount, { balance }) =>
Maybe.of(balance >= amount ? { balance: balance - amount } : null));
// This function is hypothetical, not implemented here... nor anywhere else.
// updateLedger :: Account -> Account
const updateLedger = account => account;
// remainingBalance :: Account -> String
const remainingBalance = ({ balance }) => `Your balance is $${balance}`;
// finishTransaction :: Account -> String
const finishTransaction = compose(remainingBalance, updateLedger);
// getTwenty :: Account -> Maybe(String)
const getTwenty = compose(map(finishTransaction), withdraw(20));
getTwenty({ balance: 200.00 });
// Just('Your balance is $180')
getTwenty({ balance: 10.00 });
// Nothing当余额不足时withdraw返回Nothing。该设计强制后续流程必须map处理结果,当出现Nothing时后续finishTransaction逻辑将跳过,确保失败后不更新账务数据。这正是预期行为——只有在取款成功时才更新账户状态。
值的释放
经常被忽视的是程序终需产生作用:发送JSON、界面渲染、文件操作等。我们无法直接return输出结果,必须通过特定函数释放容器内的值。这类似于禅宗公案:若程序无任何可观测效果,是否算真正运行?它可能只是空耗CPU周期后归于沉寂...
应用程序应该妥善保管数据直至最终操作。该过程可通过map完成而无需释放值。若试图强行取出Maybe中的值,可能面临值缺失的风险。正如薛定谔的猫处于叠加态,代码逻辑应保持这种状态直至最终操作,以此实现线性控制流。
当然我们也可以选择返回默认值继续流程,这需要maybe工具函数:
// maybe :: b -> (a -> b) -> Maybe a -> b
const maybe = curry((v, f, m) => {
if (m.isNothing) {
return v;
}
return f(m.$value);
});
// getTwenty :: Account -> String
const getTwenty = compose(maybe('You\'re broke!', finishTransaction), withdraw(20));
getTwenty({ balance: 200.00 });
// 'Your balance is $180.00'
getTwenty({ balance: 10.00 });
// 'You\'re broke!'maybe类似于条件判断:map对应if(x !== null)分支,而maybe处理完整的if/else逻辑。
初用Maybe可能稍显繁琐(Swift/Scala开发者会联想到Option(al))。频繁的空值检查看似冗余(尤其在值必定存在时),但习惯后将显著提升安全性——毕竟它能有效避免偷工减料带来的隐患。
编写不安全代码犹如在车流中拋掷彩绘鸡蛋,或使用劣质建材建造养老院。而Maybe为我们的函数安全保驾护航。
容我指出,实际实现通常将Maybe拆分为Just(x)/Nothing两种类型,以保持参数化特性。这也是为何常见到Some(x)/None的命名方式。
纯错误处理

throw/catch并不纯粹——抛错会中断流程而非返回值。使用Either类型可实现更优雅的错误处理:
完整实现参见附录 B
class Either {
static of(x) {
return new Right(x);
}
constructor(x) {
this.$value = x;
}
}
class Left extends Either {
map(f) {
return this;
}
inspect() {
return `Left(${inspect(this.$value)})`;
}
}
class Right extends Either {
map(f) {
return Either.of(f(this.$value));
}
inspect() {
return `Right(${inspect(this.$value)})`;
}
}
const left = x => new Left(x);Left和Right是Either的两种子类型(省略了父类声明)。它们的行为差异显著:
Either.of('rain').map(str => `b${str}`);
// Right('brain')
left('rain').map(str => `It's gonna ${str}, better bring your umbrella!`);
// Left('rain')
Either.of({ host: 'localhost', port: 80 }).map(prop('host'));
// Right('localhost')
left('rolls eyes...').map(prop('host'));
// Left('rolls eyes...')Left会忽略map操作,Right则与Container行为一致。Left的关键在于携带错误信息。
以日期转年龄为例,Either相比Nothing能提供错误详情:
const moment = require('moment');
// getAge :: Date -> User -> Either(String, Number)
const getAge = curry((now, user) => {
const birthDate = moment(user.birthDate, 'YYYY-MM-DD');
return birthDate.isValid()
? Either.of(now.diff(birthDate, 'years'))
: left('Birth date could not be parsed');
});
getAge(moment(), { birthDate: '2005-12-12' });
// Right(9)
getAge(moment(), { birthDate: 'July 4, 2001' });
// Left('Birth date could not be parsed')如同Nothing,返回Left将中断流程。但此时我们获得了错误诊断信息。类型签名中Either(String, Number)表明可能返回错误消息或有效年龄,这种非正式签名已足够传达类型信息。
// fortune :: Number -> String
const fortune = compose(concat('If you survive, you will be '), toString, add(1));
// zoltar :: User -> Either(String, _)
const zoltar = compose(map(console.log), map(fortune), getAge(moment()));
zoltar({ birthDate: '2005-12-12' });
// 'If you survive, you will be 10'
// Right(undefined)
zoltar({ birthDate: 'balloons!' });
// Left('Birth date could not be parsed')当日期有效时输出预测结果,否则返回包含错误信息的Left。这种处理类似于具备错误提示的异常机制,但更冷静优雅。
流程分支由日期有效性决定,但代码保持线性结构。通常我们会将console.log外置并通过map调用,该示例演示了Right分支的特殊处理。下划线表示忽略右侧值(某些环境需用console.log.bind(console)实现一等函数)。
值得注意:fortune函数完全独立于函子系统。这种设计使核心函数可脱离容器使用,需要时通过map*提升(lift)*到适当容器中。这种方式提高了函数复用性,可灵活适配不同函子。
Either既适合验证错误,也能处理文件丢失等严重错误。建议将其替代Maybe以增强错误反馈。
虽然我们仅用Either处理错误,其实它能表达逻辑析取(||),编码范畴论中的*余积(Coproduct)*概念。作为典型和类型,其可能值数目是组成类型可能性之和。尽管主要作为错误处理函子使用,但其他应用也值得探索。
除map外,either工具函数接收两个处理函数:
// either :: (a -> c) -> (b -> c) -> Either a b -> c
const either = curry((f, g, e) => {
let result;
switch (e.constructor) {
case Left:
result = f(e.$value);
break;
case Right:
result = g(e.$value);
break;
// No Default
}
return result;
});
// zoltar :: User -> _
const zoltar = compose(console.log, either(id, fortune), getAge(moment()));
zoltar({ birthDate: '2005-12-12' });
// 'If you survive, you will be 10'
// undefined
zoltar({ birthDate: 'balloons!' });
// 'Birth date could not be parsed'
// undefined此处id函数原样返回Left值用于错误输出。通过maybe和either的对比可见条件分支处理的提升。
Old McDonald司效应...

在讨论纯函数时,我们见过通过包裹副作用实现纯函数的案例:
// getFromStorage :: String -> (_ -> String)
const getFromStorage = key => () => localStorage[key];若不封装,getFromStorage将因外部状态变化成为非纯函数。包裹后始终返回固定功能,虽然未产生实际作用,但宣称其为纯函数便问心无愧。
但此时该函数如同未拆封的手办被束之高阁。要操作其内容,就需要IO容器的介入。
class IO {
static of(x) {
return new IO(() => x);
}
constructor(fn) {
this.$value = fn;
}
map(fn) {
return new IO(compose(fn, this.$value));
}
inspect() {
return `IO(${inspect(this.$value)})`;
}
}IO特殊之处在于其存储的是函数——但应视为延迟执行的副作用包裹器。其of方法实现延迟执行:虽然写入IO(x),实际存储的是() => x以避免立即求值。注意,为便于理解我们示意性展示IO内部值,但实际必须执行才能获取(这将破坏纯度)。
使用示例:
// ioWindow :: IO Window
const ioWindow = new IO(() => window);
ioWindow.map(win => win.innerWidth);
// IO(1430)
ioWindow
.map(prop('location'))
.map(prop('href'))
.map(split('/'));
// IO(['http:', '', 'localhost:8000', 'blog', 'posts'])
// $ :: String -> IO [DOM]
const $ = selector => new IO(() => document.querySelectorAll(selector));
$('#myDiv').map(head).map(div => div.innerHTML);
// IO('I am some inner html')是返回IO的函数。虽然渲染为概念值,但实际始终是{$value: [Function]}。映射操作实则为函数组合,如同摆放多米诺骨牌——构建计算链但不立即执行。这种模式类似于命令模式或任务队列。
一旦领悟函子本质,就能跨越具体实现差异进行映射。函子定律(本章结尾详述)为此提供了理论保障,使我们能在保持纯度的前提下操作副作用。
最终必须执行IO内的副作用——此时纯度将被破坏。但是调用方承担执行责任,核心代码始终保持纯洁。示例演示如何将执行权移交调用方:
// url :: IO String
const url = new IO(() => window.location.href);
// toPairs :: String -> [[String]]
const toPairs = compose(map(split('=')), split('&'));
// params :: String -> [[String]]
const params = compose(toPairs, last, split('?'));
// findParam :: String -> IO Maybe [String]
const findParam = key => map(compose(Maybe.of, find(compose(eq(key), head)), params), url);
// -- Impure calling code ----------------------------------------------
// run it by calling $value()!
findParam('searchTerm').$value();
// Just(['searchTerm', 'wafflehouse'])我们的库仅负责包装请求参数,调用方执行副作用且可能形成多层容器嵌套(如IO(Maybe([x]))),这种结构具有强大的表现力。
必须纠正IO属性名的误导性:$value实为副作用触发器,我们将其更名为unsafePerformIO以警示风险。
class IO {
constructor(io) {
this.unsafePerformIO = io;
}
map(fn) {
return new IO(compose(fn, this.unsafePerformIO));
}
}调用形式改为findParam('searchTerm').unsafePerformIO(),使意图清晰可见。
至此完成IO的介绍。接下来探讨使用场景迥异的Task容器。
异步任务
回调函数如同埃舍尔绘制的螺旋迷宫,层层嵌套令人窒息。好在有更好的异步处理方案,其名字以"F"开头。
由于实现复杂,此处使用Folktale库的Data.Task(曾用名Data.Future)演示:
// -- Node readFile example ------------------------------------------
const fs = require('fs');
// readFile :: String -> Task Error String
const readFile = filename => new Task((reject, result) => {
fs.readFile(filename, (err, data) => (err ? reject(err) : result(data)));
});
readFile('metamorphosis').map(split('\n')).map(head);
// Task('One morning, as Gregor Samsa was waking up from anxious dreams, he discovered that
// in bed he had been changed into a monstrous verminous bug.')
// -- jQuery getJSON example -----------------------------------------
// getJSON :: String -> {} -> Task Error JSON
const getJSON = curry((url, params) => new Task((reject, result) => {
$.getJSON(url, params, result).fail(reject);
}));
getJSON('/video', { id: 10 }).map(prop('title'));
// Task('Family Matters ep 15')
// -- Default Minimal Context ----------------------------------------
// We can put normal, non futuristic values inside as well
Task.of(3).map(three => three + 1);
// Task(4)reject和result对应错误与成功回调。通过map操作未来值,如同直接操作现值——这便是map的复用魅力。
熟悉Promise的读者会发现map类似then,但Task是纯函数式实现。该比喻仅辅助理解,本书不会使用非纯的Promise。
与IO类似,Task延迟执行异步操作。实际上,所有异步副作用都可用Task处理而无需额外IO。map操作如同编写未来指令集——这种科技拖延术精妙绝伦。
运行Task需调用fork方法(类比unsafePerformIO)。该方法以非阻塞方式启动异步流程:
// -- Pure application -------------------------------------------------
// blogPage :: Posts -> HTML
const blogPage = Handlebars.compile(blogTemplate);
// renderPage :: Posts -> HTML
const renderPage = compose(blogPage, sortBy(prop('date')));
// blog :: Params -> Task Error HTML
const blog = compose(map(renderPage), getJSON('/posts'));
// -- Impure calling code ----------------------------------------------
blog({}).fork(
error => $('#error').html(error.message),
page => $('#main').html(page),
);
$('#spinner').show();fork启动后立即返回,页面显示加载图标。最终根据结果渲染内容或报错——全部流程保持线性可读。
注意控制流仍保持从上到下阅读顺序,而非跳转于回调之间,极大提升代码可读性。
Task还整合了Either的错误处理能力——因异步世界的错误无法用常规控制流处理。这种内置的错误处理机制干净强大。
当然Either和IO的地位不可取代。看这个复杂的假设案例:
// Postgres.connect :: Url -> IO DbConnection
// runQuery :: DbConnection -> ResultSet
// readFile :: String -> Task Error String
// -- Pure application -------------------------------------------------
// dbUrl :: Config -> Either Error Url
const dbUrl = ({ uname, pass, host, db }) => {
if (uname && pass && host && db) {
return Either.of(`db:pg://${uname}:${pass}@${host}5432/${db}`);
}
return left(Error('Invalid config!'));
};
// connectDb :: Config -> Either Error (IO DbConnection)
const connectDb = compose(map(Postgres.connect), dbUrl);
// getConfig :: Filename -> Task Error (Either Error (IO DbConnection))
const getConfig = compose(map(compose(connectDb, JSON.parse)), readFile);
// -- Impure calling code ----------------------------------------------
getConfig('db.json').fork(
logErr('couldn\'t read file'),
either(console.log, map(runQuery)),
);虽然Task处理异步读取,但同步验证依然需要Either,数据库连接需要IO——说明各司其职的重要性。
异步流程的复杂性将由后续章节的monad等概念解决。但首先需要奠定数学模型基础。
铺垫至此,立即切入正题。
理论点滴
函子源于范畴论,需满足两大定律:
// identity
map(id) === id;
// composition
compose(map(f), map(g)) === map(compose(f, g));同一律简单但关键。这些法则可用代码验证:
const idLaw1 = map(id);
const idLaw2 = id;
idLaw1(Container.of(2)); // Container(2)
idLaw2(Container.of(2)); // Container(2)运行可见结果相等。再看组合律:
const compLaw1 = compose(map(append(' world')), map(append(' cruel')));
const compLaw2 = map(compose(append(' world'), append(' cruel')));
compLaw1(Container.of('Goodbye')); // Container('Goodbye cruel world')
compLaw2(Container.of('Goodbye')); // Container('Goodbye cruel world')范畴论中,函子将对象与态射映射到另一范畴。新范畴需满足同一律与组合律——而函子定律已确保这点。
可将范畴视为对象与态射构成的网络。函子F将源范畴C中的对象a映射为目标范畴中的F a。参考下图:

例如Maybe将常规类型映射为空值安全范畴。代码中通过map与容器实现,确保原始组合关系不变。技术上说,函子属于自函子(endofunctor),但现阶段可简单理解为跨范畴映射。
通过图表理解态射映射:

无论选择哪条路径,最终结果一致。这种等价性为代码重构提供了数学依据。
// topRoute :: String -> Maybe String
const topRoute = compose(Maybe.of, reverse);
// bottomRoute :: String -> Maybe String
const bottomRoute = compose(map(reverse), Maybe.of);
topRoute('hi'); // Just('ih')
bottomRoute('hi'); // Just('ih')假设有函数链式调用:

根据函子定律我们可以立即重构并确保正确性。
函子支持多层嵌套:
const nested = Task.of([Either.of('pillows'), left('no sleep for you')]);
map(map(map(toUpperCase)), nested);
// Task([Right('PILLOWS'), Left('no sleep for you')])当处理嵌套容器Future<Array<Either<String, Number>>>时,必须执行三次map方能应用函数。此时可通过函子组合简化操作:
class Compose {
constructor(fgx) {
this.getCompose = fgx;
}
static of(fgx) {
return new Compose(fgx);
}
map(fn) {
return new Compose(map(map(fn), this.getCompose));
}
}
const tmd = Task.of(Maybe.of('Rock over London'));
const ctmd = Compose.of(tmd);
const ctmd2 = map(append(', rock on, Chicago'), ctmd);
// Compose(Task(Just('Rock over London, rock on, Chicago')))
ctmd2.getCompose;
// Task(Just('Rock over London, rock on, Chicago'))调用组合函数即可简化操作。函子组合满足结合律,配合恒等函子形成范畴——这种模式展现了数学之美的同时,也带来架构启示。
总结
已介绍的函子仅是冰山一角。树形结构、列表、键值对等可迭代类型都是函子,事件流与响应式编程中的Observable亦然。函子普遍存在于编程世界,后续章节将频繁使用。
如何处理多函子参数调用?如何管理带副作用的异步操作序列?要解决这些问题需要学习monad等新工具,这正是下章重点。
练习题
开始练习!
使用add和map实现容器内值的自增函数
// incrF :: Functor f => f Int -> f Int
const incrF = undefined;
已知用户对象:
const user = { id: 2, name: 'Albert', active: true };开始练习!
使用safeProp和head查找用户首字母
// initial :: User -> Maybe String
const initial = undefined;
已知工具函数:
// showWelcome :: User -> String
const showWelcome = compose(concat('Welcome '), prop('name'));
// checkActive :: User -> Either String User
const checkActive = function checkActive(user) {
return user.active
? Either.of(user)
: left('Your account is not active');
};开始练习!
编写函数结合checkActive和showWelcome实现权限控制
// eitherWelcome :: User -> Either String String
const eitherWelcome = undefined;
现有以下函数:
// validateUser :: (User -> Either String ()) -> User -> Either String User
const validateUser = curry((validate, user) => validate(user).map(_ => user));
// save :: User -> IO User
const save = user => new IO(() => ({ ...user, saved: true }));开始练习!
编写validateName验证用户名长度,并结合either、showWelcome、save实现用户注册
注意either的两个参数需返回同类型值
// validateName :: User -> Either String ()
const validateName = undefined;
// register :: User -> IO String
const register = compose(undefined, validateUser(validateName));