Skip to content

第09章:单子的洋葱结构

带点的函子工厂

在继续深入之前,我必须承认一个事实:关于我们在每个类型上使用的of方法,从前我并未完全如实交代。实际上,它的作用不是避免使用new关键字,而是将值放置在所谓的默认最小上下文中。是的,of并非构造函数的替代品——它是我们称为*带点的(Pointed)*的重要接口组成部分。

带点的函子是指具有of方法的函子

这里的关键能力是将任意值置入类型中并立即开始映射操作。

js
IO.of('tetris').map(concat(' master'));
// IO('tetris master')

Maybe.of(1336).map(add(1));
// Maybe(1337)

Task.of([{ id: 2 }, { id: 3 }]).map(map(prop('id')));
// Task([2,3])

Either.of('The past, present and future walk into a bar...').map(concat('it was tense.'));
// Right('The past, present and future walk into a bar...it was tense.')

回忆可知,IOTask的构造函数期望函数作为参数,而MaybeEither则不需要。设计这个接口的动机,是希望通过统一的方式将值置入函子,规避构造函数的复杂性和特定要求。"默认最小上下文"这一术语虽不够精确,却能很好表达核心理念:我们希望将值提升至类型,并通过函子的预期行为常规进行map操作。

此刻必须做一个重要修正(双关语警告):Left.of没有实际意义。每个函子只能以单一方式封装值,对于Either而言这个方式就是new Right(x)。我们选择用Right定义of是因为,只要类型能够映射,它就应该映射。观察前面例子可以看出,of通常有直观的运作模式,而Left打破了这个模式。

您可能听说过purepointunitreturn这些函数。它们都是of方法在不同门派中的别名,类似神秘的通用接口函数。当开始使用单子时,of将变得非常重要——因为如后续所见,此时需要人工将值重新置入类型中。

为避免使用new关键字,JavaScript有多种标准技巧和工具库。从现在起让我们负责任地使用of。推荐使用来自folktaleramdafantasy-land的函子实例,它们提供正确的of方法及无需new的优雅构造函数。

隐喻的混用

onion

除了太空墨西哥卷饼(如果您听说过这个传闻),单子还像洋葱。请允许我通过常见场景来演示:

js
const fs = require('fs');

// readFile :: String -> IO String
const readFile = filename => new IO(() => fs.readFileSync(filename, 'utf-8'));

// print :: String -> IO String
const print = x => new IO(() => {
  console.log(x);
  return x;
});

// cat :: String -> IO (IO String)
const cat = compose(map(print), readFile);

cat('.git/config');
// IO(IO('[core]\nrepositoryformatversion = 0\n'))

这里IO被困在另一个IO中,因为在map过程中print引入了第二个IO。要继续处理字符串,必须使用map(map(f));要观察效果则需要连续调用unsafePerformIO().unsafePerformIO()

js
// cat :: String -> IO (IO String)
const cat = compose(map(print), readFile);

// catFirstChar :: String -> IO (IO String)
const catFirstChar = compose(map(map(head)), cat);

catFirstChar('.git/config');
// IO(IO('['))

虽然能打包两个效果并在应用中随时使用是好事,但这种操作感觉像穿着两层防护服工作,最终得到的API笨拙得令人不适。我们再观察另一个场景:

js
// safeProp :: Key -> {Key: a} -> Maybe a
const safeProp = curry((x, obj) => Maybe.of(obj[x]));

// safeHead :: [a] -> Maybe a
const safeHead = safeProp(0);

// firstAddressStreet :: User -> Maybe (Maybe (Maybe Street))
const firstAddressStreet = compose(
  map(map(safeProp('street'))),
  map(safeHead),
  safeProp('addresses'),
);

firstAddressStreet({
  addresses: [{ street: { name: 'Mulburry', number: 8402 }, postcode: 'WC2N' }],
});
// Maybe(Maybe(Maybe({name: 'Mulburry', number: 8402})))

再次出现的嵌套函子场景中,函数内部可能存在三个失败点,但期望调用方连续三次map才能获取值的设定显然傲慢——毕竟我们方才结识。这种模式会反复出现,正是我们需要将强力的单子符号闪耀于夜空的主要情境。

我将单子喻为洋葱,是因为用map一层层剥离嵌套函子时总会催人泪下。这时我们可以擦干眼泪深呼吸,使用名为join的方法。

js
const mmo = Maybe.of(Maybe.of('nunchucks'));
// Maybe(Maybe('nunchucks'))

mmo.join();
// Maybe('nunchucks')

const ioio = IO.of(IO.of('pizza'));
// IO(IO('pizza'))

ioio.join();
// IO('pizza')

const ttt = Task.of(Task.of(Task.of('sewers')));
// Task(Task(Task('sewers')));

ttt.join();
// Task(Task('sewers'))

当存在两层相同类型时,可以通过join合并它们。这种将函子结合的联姻能力,正是单子的本质。让我们通过更精确的定义逐渐靠近本质:

单子是可展开的带点函子

任何定义join方法、具备of方法且遵守特定定律的函子,都是单子。join的定义并不困难,以Maybe为例演示:

js
Maybe.prototype.join = function join() {
  return this.isNothing() ? Maybe.of(null) : this.$value;
};

如此简单,犹如在母体中融合双胞胎。对于Maybe(Maybe(x))的情形,.$value将去除多余封装层,随后即可安全进行map。其他情形则维持单一Maybe,因最初就无映射操作发生。

现在有了join方法,让我们对firstAddressStreet示例施以单子魔法并观察效果:

js
// join :: Monad m => m (m a) -> m a
const join = mma => mma.join();

// firstAddressStreet :: User -> Maybe Street
const firstAddressStreet = compose(
  join,
  map(safeProp('street')),
  join,
  map(safeHead), safeProp('addresses'),
);

firstAddressStreet({
  addresses: [{ street: { name: 'Mulburry', number: 8402 }, postcode: 'WC2N' }],
});
// Maybe({name: 'Mulburry', number: 8402})

每当遇到嵌套Maybe时都添加join以控制层级。让我们在IO上实施同样处理以获得直观感受:

js
IO.prototype.join = function() {
  const $ = this;
  return new IO(() => $.unsafePerformIO().unsafePerformIO());
};

我们只是将两层IO操作按顺序绑定:外层先于内层。需注意这并未破坏纯洁性,只是将冗余的层层包装重组为更易开启的形式。

js
// log :: a -> IO a
const log = x => new IO(() => {
  console.log(x);
  return x;
});

// setStyle :: Selector -> CSSProps -> IO DOM
const setStyle =
  curry((sel, props) => new IO(() => jQuery(sel).css(props)));

// getItem :: String -> IO String
const getItem = key => new IO(() => localStorage.getItem(key));

// applyPreferences :: String -> IO DOM
const applyPreferences = compose(
  join,
  map(setStyle('#main')),
  join,
  map(log),
  map(JSON.parse),
  getItem,
);

applyPreferences('preferences').unsafePerformIO();
// Object {backgroundColor: "green"}
// <div style="background-color: 'green'"/>

getItem返回IO String,故通过map进行解析。由于logsetStyle自身返回IO,必须用join控制嵌套层级。

链式连环击

chain

你可能注意到模式:常在map后立即调用join。将其抽象为chain函数。

js
// chain :: Monad m => (a -> m b) -> m a -> m b
const chain = curry((f, m) => m.map(f).join());

// or

// chain :: Monad m => (a -> m b) -> m a -> m b
const chain = f => compose(join, map(f));

将映射与连接的组合打包成单独函数。若曾研习单子,或见过>>=(称bind)或flatMap——这些均为该概念的不同名称。个人认为flatMap最准确,但采用JavaScript界更普遍的chain作为规范名称。现在用chain重构前两个例子:

js
// map/join
const firstAddressStreet = compose(
  join,
  map(safeProp('street')),
  join,
  map(safeHead),
  safeProp('addresses'),
);

// chain
const firstAddressStreet = compose(
  chain(safeProp('street')),
  chain(safeHead),
  safeProp('addresses'),
);

// map/join
const applyPreferences = compose(
  join,
  map(setStyle('#main')),
  join,
  map(log),
  map(JSON.parse),
  getItem,
);

// chain
const applyPreferences = compose(
  chain(setStyle('#main')),
  chain(log),
  map(JSON.parse),
  getItem,
);

通过替换所有map/join为新的chain函数,代码更加规整。整洁固然重要,但chain的作用远非如此——它更像是龙卷风而非吸尘器。因其能无缝嵌套副作用,我们得以用纯函数方式捕获顺序变量赋值的关系。

js
// getJSON :: Url -> Params -> Task JSON
getJSON('/authenticate', { username: 'stale', password: 'crackers' })
  .chain(user => getJSON('/friends', { user_id: user.id }));
// Task([{name: 'Seimith', id: 14}, {name: 'Ric', id: 39}]);

// querySelector :: Selector -> IO DOM
querySelector('input.username')
  .chain(({ value: uname }) =>
    querySelector('input.email')
      .chain(({ value: email }) => IO.of(`Welcome ${uname} prepare for spam at ${email}`))
  );
// IO('Welcome Olivia prepare for spam at olivia@tremorcontrol.net');

Maybe.of(3)
  .chain(three => Maybe.of(2).map(add(three)));
// Maybe(5);

Maybe.of(null)
  .chain(safeProp('address'))
  .chain(safeProp('street'));
// Maybe(null);

虽然可用compose实现这些示例,但需辅助函数,而这种风格更适合通过闭包进行显式变量赋值。实际上我们使用中缀版chain,它可通过mapjoin自动派生:t.prototype.chain = function(f) { return this.map(f).join(); }。当然也可手动定义以获取虚假的优化感,但必须保持正确性——即等同于先mapjoin。有趣的是,若已定义chain,可通过of复用值来实现map。同时,也能用chain(id)定义join。这过程如同与钻石魔术师玩德州扑克,看似随手变出各种概念,但正如多数数学原理,这些结构相互关联。更多推导详见规范JavaScript代数数据类型的Fantasyland仓库。

回到前面示例。在第一个示例中,两个Task形成异步操作链——先获取user,再用其ID查找好友。通过chain避免了Task(Task([Friend]))的结构。

接着用querySelector查找输入框并创建欢迎信息。注意最内层函数如何同时访问unameemail——此乃函数式变量赋值的典范。因IO慷慨借出值,我们有责任妥当归还——不可辜负其信任(及程序稳定性)。IO.of是绝佳工具,这也说明带点(Pointed)作为单子接口前提的重要性。但也可用map返回正确类型:

js
querySelector('input.username').chain(({ value: uname }) =>
  querySelector('input.email').map(({ value: email }) =>
    `Welcome ${uname} prepare for spam at ${email}`));
// IO('Welcome Olivia prepare for spam at olivia@tremorcontrol.net');

最后是两个使用Maybe的例子。因chain底层使用映射,遇到任null值都将立即终止后续计算。

初学时若觉困难不必困扰。多做练习、拆解重组、反复实践。当返回"常规"值时用map,返回其他函子则用chain。下一章我们将使用应用函子使此类表达式更优雅可读。

特别注意:此方法不适用于两种不同嵌套类型。函子组合及之后的单子变换器可解此困局。

威力展示

容器式编程时有困惑。常需厘清值的嵌套层级,在mapchain间举棋不定(后续更多容器方法)。通过实现inspect调试技巧可提升效率,也能构建应对各类副作用的"栈",但有时仍会犹豫是否值得如此周折。

此刻请容我挥动单子的炽焰之剑,展现此编程方式的威力。

读取文件后立即上传:

js
// readFile :: Filename -> Either String (Task Error String)
// httpPost :: String -> String -> Task Error JSON
// upload :: Filename -> Either String (Task Error JSON)
const upload = compose(map(chain(httpPost('/uploads'))), readFile);

代码存在多个分支。观察类型签名可知防范了三种错误:readFile通过Either验证输入(确保文件名存在),其首类型参数表示文件访问可能的失败,httpPostError表示上传失败。通过chain轻松完成双重嵌套的异步操作。

整个过程以纯声明式风格从左到右线性完成,保持等式推导和可靠属性。无需混杂多余变量名,upload函数基于通用接口而非特制API实现。这完美单行实现值得称道。

对比标准命令式写法:

js
// upload :: Filename -> (String -> a) -> Void
const upload = (filename, callback) => {
  if (!filename) {
    throw new Error('You need a filename!');
  } else {
    readFile(filename, (errF, contents) => {
      if (errF) throw errF;
      httpPost('/uploads', contents, (errH, json) => {
        if (errH) throw errH;
        callback(json);
      });
    });
  }
};

这魔鬼算术!我们在疯狂的迷宫中东奔西突。设想典型应用中还存在变量突变——可谓自陷焦油坑。

理论部分

首要定律是结合性,但或与常规理解相左。

js
// associativity
compose(join, map(join)) === compose(join, join);

这些定律针对单子的嵌套特性,故结合律关注先合并内层或外层类型以获得相同结果。图示更直观:

monad associativity law

从左上角下移,可先合并M(M(M a))的外层两M,之后joinM a。亦可深入内部用map(join)合并内层两M。无论何种顺序,终得相同M a,此即结合律精髓。注意map(join) != join——中间值可能不同,但最终join结果一致。

第二条定律类似:

js
// identity for all (M a)
compose(join, of) === compose(join, map(of)) === id;

定律表明:对任何单子Mofjoin等价于id。也可通过内部map(of)实现。我们称之为"三角恒等式",因其可视化呈三角形:

monad identity law

从左上右移可见ofM a置入另一M层。下移join则等同于直接调用id。从右往左看,若潜入内部对普通值调用of,终得M (M a),经join回归原点。

需注意此处虽写of,但实际应为当前单子的M.of方法。

此刻觉得这些定律(同一性与结合性)似曾相识。是了!这正是范畴(Category)公理。这意味着需组合函数完成定义:

js
const mcompose = (f, g) => compose(chain(f), g);

// left identity
mcompose(M, f) === f;

// right identity
mcompose(f, M) === f;

// associativity
mcompose(mcompose(f, g), h) === mcompose(f, mcompose(g, h));

确实符合范畴论公理。单子构成名为"Kleisli范畴"的结构,其中对象为单子,态射为链式函数。我无意抛洒零散的范畴理论碎片,而是欲勾勒其轮廓以展示实际相关性,同时聚焦日常实用属性。

总结

单子使我们能穿透嵌套计算。无需构建末日金字塔即可完成变量赋值、顺序执行、异步操作。当值困于同型多层结构时,它们便来解围。在得力助手"带点的"辅佐下,单子既能安全交出个值,也确保物归原位。

单子虽强,仍需其他容器函数辅助。若要并行多个API调用并收集结果——用单子须等待每个完成。又比如组合多个验证时——我们希望收集所有错误,但单子遇首个Left即止。

下章我们将探索应用函子的世界,解析其相较单子更具优势的场景。

第十章:应用函子

练习题

假设用户对象如下:

js
const user = {
  id: 1,
  name: 'Albert',
  address: {
    street: {
      number: 22,
      name: 'Walnut St',
    },
  },
};

开始练习!

使用safePropmap/joinchain安全获取指定用户的街道名称 // getStreetName :: User -> Maybe String const getStreetName = undefined;


现需处理以下条目:

js
// getFile :: IO String
const getFile = IO.of('/home/mostly-adequate/ch09.md');

// pureLog :: String -> IO ()
const pureLog = str => new IO(() => console.log(str));

开始练习!

使用getFile获取文件路径,去除目录仅保留基本文件名, 通过纯函数记录日志。提示:可用splitlast从路径提取基本名。 // logFilename :: IO () const logFilename = undefined;


本题使用以下辅助函数(签名为):

js
// validateEmail :: Email -> Either String Email
// addToMailingList :: Email -> IO([Email])
// emailBlast :: [Email] -> IO ()

开始练习!

使用validateEmailaddToMailingListemailBlast创建函数: 若邮箱有效则加入邮件列表,并通知全体成员。 // joinMailingList :: Email -> Either String (IO ()) const joinMailingList = undefined;