vlambda博客
学习文章列表

【翻译】函数式编程和monads

本文翻译自kyle simpson的https://github.com/getify/monio。一个js实现的monad库。相信看完你会对monad有更多认识。



如果你已经适应了函数式编程,尤其是了解副作用和纯函数,那么你可以跳到"展开讲讲monads"

函数式编程

函数式编程是一个经常有相当多的“包袱”的话题,对monads(单子)来说更是如此。当被这些主题后面的形式化术语或数学轰炸时,就很容易在google搜索中迷失,尤其是很多函数式编程迷都认为形式化术语和数学是了解FP/monads的最基本要求。

听我说:你不需要计算机或者数学的学位证书就可以沉浸于函数式编程,并进一步采用monads相关的思维方式。形式术语和数学在你需要深入了解FP/monads时可以提供更丰富和深入的经验知识,但你并不需要一开始就从它们开始来学习FP/monads(除非你想这样!)

你曾写过这样的代码吗?

const FPBookNames = [];

for (const record of data) {
    if (record.topic == "FP") {
        FPBookNames.push(record.bookName);
    }
}

这就是我们通常意义上说的“命令式”代码风格。这对大多数人来说是熟悉的。但它只关注于如何完成一个任务。为了理解这段代码发生了什么或者为什么这么写,你必须在大脑中执行这段代码并推测它的含义。只有读完这段代码后,你才可能了解到:“这段代码的意思是把主题为FP的记录筛选出来并把它们的bookName放到数组里面去”

如果你的代码可以更“声明式”,并且状态的是什么为什么可以一瞥下来就更清楚,也不再强调为什么为什么只作为一个不那么重要的实现细节,会怎么样呢?

这就是函数式编程存在的意义。

从循环到Map和Filter

举个例子,想象你有一个字符串值,你想把它转为大写。

function uppercase(strreturn str.toUpperCase(); }

var greeting = "Hello, friend!";

console.log( uppercase(greeting) );   // HELLO, FRIEND!

这个实现相当直接。当假设你现在有多个字符串要转为大写。你可以手动对每个字符串调用uppercase(..)。但是当我们有多个值,把它们放到数组里面,才是最方便的。处理字符串数组的命令式方式:

// assumed: function uppercase(str) { .. }
// assumed: `listOfStrings` (array of string values)

for (let i = 0; i < listOfStrings.length; i++) {
    listOfStrings[i] = uppercase(listOfStrings[i]);
}

这里,我们修改数组中的原始字符串条目,用大写版本替换它们。在函数式编程中,我们通常更希望不去修改或者重新赋值,而是创建的值。创建新的值是一种减少那些可以导致程序出现bug的意外的副作用的方式。让我们用这种方式来创建一个list:

// assumed: function uppercase(str) { .. }
// assumed: `listOfStrings` (array of string values)

let listOfUpperStrings = [];

for (let i = 0; i < listOfStrings.length; i++) {
    listOfUpperStrings[i] = uppercase(listOfStrings[i]);
}

这代码相当。但是当你第一次遇到它,为了了解这段代码是什么,你不得不在脑海中执行该代码并推测它的目的。然后,你可以断言,“这段代码取一个数组中的每个字符串,然后产生一个新的大写版本的字符串数组。”

给数组的每一个值执行一个相同的操作,是一个相当通用的编程任务。以至于我们为这个任务命名并为它发明了众所周知的工具程序map(..)。我们来看一下:

// assumed: function uppercase(str) { .. }
// assumed: `listOfStrings` (array of string values)

const listOfUpperStrings = listOfStrings.map(uppercase);

数组的map(..)操作只有一个单独的函数作为输入,这个函数接受一个单独的值,函数返回一个值。uppercase(..)适合这个参数格式,所以我们就直接传递给map(..)map(..)给我们一个新的数组,数组的值是所有调用提供的函数uppercase(..)后从原始值生成的新的值。

一个通常的对FP的断言是:一个循环调用数组的机制,这个机制对数组的每个值调用一个函数来处理,众所周知的是这种方式(循环处理)不需要在代码中显式编写。

FP已经识别出了大量这种公共的任务,并将它们命名和实现为了公用的程序。

举个例子,filter(..)做了和map(..)相似的操作,但是它不产出新的值,它只判定一个值是否应该保存在新的数组中。

// assumed: `listOfStrings` (array of string values)

function isLongEnough(strreturn str.length > 50; }

const listOfLongStrings = listOfStrings.filter(isLongEnough);

listOfLongStrings是一个新数组,仅包含原始listOfStrings数组中那些长度大于50的字符串。这个结果应该和我们用for循环命令式地实现等效操作相比而言,更易阅读。

我们甚至可以“组合”map(..)filter(..)操作:

// assumed: function uppercase(str) { .. }
// assumed: function isLongEnough(str) { .. }
// assumed: `listOfStrings` (array of string values)

listOfStrings.filter(isLongEnough).map(uppercase);

现在我们有一个足够长的大写字符串数组!

这就是进入FP带给你的一些早期的被采用的思维模型。这是巨型冰山的一角。

更多了解函数式编程的方式

如果你好奇,但是FP概念对你来说是新的,我推荐你看一下-至少前几章-我的免费在线FP书Functional-Light JavaScript。

这里有一些high-quality video courses about FP on Frontend Masters,包括:

  • Bianca Gandolfo's "From Fundamentals To Functional JS"

  • Anjana Vakil's "Functional JavaScript First Steps"

  • My "Functional-Light JavaScript" -- a companion course to my book of the same name

我认为你的第一个大目标应该是理解FP并对这些主题感到舒适:

  • Side Effects 副作用
  • Pure Functions 纯函数
  • Higher-order Functions 高阶函数
  • Function Composition 函数组合
  • Currying 柯里化
  • Basic List Operations (filter/map/reduce) 基础的数组操作

我怎么知道我学对了呢?

你怎么知道你是从FP到monads转变过程中走在正确的路线上并且感到舒适?这个指南上,我没有好的方式来回答读者的这个问题。但是我至少希望能够提供一些简要概览或者一些提示给你,而不是留下一些不满意的话,“看情况而定”。

当然,在FP方式中,一开始有很多方法来实现完成FPBookNames代码片段的处理。我不会去断言有一个“最正确的方式”。

但是在FP中有些常见的方式,它依赖链式表达式和组合(柯里化)函数,被称为“point-free风格”,可能就像这样:

const FPBookNames = data
    .filter( 
        compose(
            eq("FP"),
            getProp("topic")
        )
    )
    .map( getProp("bookName") );

再次声明,不要去说这是“最正确”的方式来处理这个逻辑,但是,像这样的代码代表了来自FP的思想组合,我认为这将帮助你准备接受monads的概念。特别是我在接下来的部分中将贯通展示它们。

当代码写得就像“和你说话”,我认为是时候带你进入monads的海洋了。

展开讲讲monads

在这份指导之外,我推荐看一下这个视频 recording of my conference talk, "Mo'problems, Mo'nads".

Monad是"范畴论"广泛概念中的一个小概念。你可以简要地、不完整地描述范畴论为一个基于事物在组合和转换中的表现来分类和分组事物的方法。

Monad类型是一种在程序中表示值或者操作的方法,它将某些特性行为和那个值或者操作相关联。通过对原始值增加操作,这些额外的行为作为一种“保证”,规定如何与程序中其他Monad表示的值/操作来进行可预测的交互。

这是概念上定义monads是什么。但你可能想看一些代码。

最简单的js演示

我们在js中能实现这种概念的最简要方式是什么?这个怎么样:

function Identity(v{
  return { val: v };
}

function chain(m,fn{
  return fn(m.val);
}

这就是monad最基础的实现。特别的是,“Identity”这个monad,意味着它会保留一个值,在你想要用的时候,不用去直接使用这个值。(而是通过Identity来操作)

const myAge = Identity(41);   // { val: 41 }

我们只在一个对象容器中放置一个值,这样我们就可以识别该值为单子化(monadically)的。这个容器是一个方便的实现单子的方式,但是实际上这不是必须的。

chain(..)函数提供了一个最小的兼容操作monad实例的方式。比如,想象我们想要41的一元表示,并且产生另一个从41增加到42的monad实例。

const myAge = Identity(41);   // { val: 41 }

const myNextAge = chain( myAge, v => Identity(v + 1) );   // { val: 42 }

值得注意的一个重点是,即使我在这里使用Identitychain,但这只是简单的一个选择而已。Monad对我们用什么命名事物而言,并没有明确的要求。但是如果我们使用他人常用的名字来命名,它有助于创造一个提升我们交流方式的熟悉环境。仅此而已。

chain(..)函数看起来非常基础,但是他是非常重要的(不管你用什么名字来表示它)。我们将深入了解一下它。

我敢肯定这段代码似乎让大多数读者都感到印象深刻。为什么不简单的使用4142,而要用{val: 41}{val: 42}呢?使用mondas的为什么好像不是很明确。你必须和我一起再聊聊其他的才能开始谈为什么

但希望我至少从根本上已经展示给你看了,monad并不是什么神秘或复杂的东西

构建monads

Monads以前被用各种听起来愚蠢的比喻来描述,比如墨西哥卷饼。有的叫monads为“包装盒”或者“盒子”,或者“数据结构”...事实是,这些描述monad的方式都是片面的。就想看一个魔方,你可以从一个面看到方块,然后转向后看另一个不同的面,以得到更整体的认知。

一个完整的理解需要熟悉各个方面,但是完整的理解并不是一个单一的原子事件。它通常是基于许多小的理解建立起来的,就像每次只能看到魔方的一个面一样。

现在,我只想你专注于这个事:你可以给一个值42或者一个操作console.log("Hello, friend")增加/关联额外的行为来增强它们,给他们超能力。

这有另外一个可能的阐释monads的方式,使用Monio带来的能力。

const myAge = Just(41);

Monio相关文档看: Just

上面的代码展示了一个Just(..)函数,它和Identity(..)之前展示的函数很像。Just这个monad的行为就像一个构造器(单元)

并且

const printGreeting = IO(() => console.log("Hello, friend!"));

我们看另一个Monio的IO(..)函数,它表现得像是一个IOmonad的构造器(保留函数)

回想我们之前的草稿,你可以大体认为myAge就像{value: 41}printGreeting就像{ val: () => console.log("Hello, friend!") }。Monio的表示方式比仅仅一个对象更加复杂,但它的底层并没有什么不同。

我将在这个指南的剩余部分使用Monio。这是很方便来利用的例子,并且更容易来说明。但是请记住在这所有展示之外,我们可以做更直接的事情,比如定义一个对象{val: 41}

深入Map

考虑数组的map(..)方法的概念。它的任务是对所有关联数组的值应用一个映射(值转换)操作。

123 ].map(v => v * 2);   // [ 2, 4, 6 ]

注意:这个功能(map)相关的技术术语是函子(Functor)。事实上,所有的monads都是Functors,但是目前不要太担心这个术语。只需要你脑海有这个概念即可。

这个映射操作在我们数组只有一个元素的时候也当然成立,对吧?

41 ].map(v => v + 1);   // [ 42 ]

一个很容易被我们忽略的极其重要的细节就是,map(..) 函数并不产生42,而是产生[42]给我们。为什么?因为map(..)的任务是产生一个它调用的原始容器相同类型的实例。从这个意义上说,如果你使用数组的map(..),你就是总会得到一个数组。

但是如果我们的容器是一个monad实例呢?如果这个monad下只有一个值41?因为monad也是一个Functor(可以被映射),我们应该可以期待一个相同类型的输出吧,对吗?

const myAge = Just(41);

const myNextAge = myAge.map(v => v + 1);   // Just(42)

希望这里有实际的意义,myNextAge应该是另一个Just的实例,这个新的Just表示有一个底层的值42

还记得上一章中的这个简单示例吗?

// assumed: function Identity(val) { .. }
// assumed: myAge ==> { val: 41 }

const myNextAge = chain( myAge, v => Identity(v + 1) );   // { val: 42 }

用Monio的实现来替换就是:

const myNextAge = myAge.chain(v => Just(v + 1));

所以map(..)chain(..)之间的关系是什么?让我们把它们写到一起来看看:

myAge.map(   v =>      v + 1  );    // Just(42)
myAge.chain( v => Just(v + 1) );    // Just(42)

现在你明白了吗?map(..)设定它的返回值需要在一个容器实例中被自动包装,而chain的返回值需要是一个已经被包装为正确容器类型的值。

map(..)函数根本不需要被命名来满足monad实例的Functor行为。实际上,你甚至根本不需要一个map(..)函数,如果你有一个chain(..)函数的话,可以实现一个map(..)

function JustMap(m,fnreturn m.chain(v => Just(fn(v))); }

fortyOne.map(    v => v + 1);   // Just(42)
JustMap(fortyOne,v => v + 1);   // Just(42)

有一个map(..)函数(不论它真名叫什么)比起chain(..)函数本身来说,是很方便的事情,但map(..)函数并不是必须的。

单子链(monadic chain)

chain(..)函数有时候是另外的名字(在其他库或者语言中对monad概念进行实现时),比如flatMap(..)bind(..)。在Monio的monads中,所有的这三个方法是等效的,任意选一个来用就行了。

命名flatMap(..)可以加强这个操作和map(..)之间关系的理解。(map(..)自动装箱,flatMap(..)手动装箱)

Just(41).map(     v => Just(v + 1) );    // Just(Just(42)) -- oops!?

Just(41).flatMap( v => Just(v + 1) );    // Just(42) -- phew!

如果我们从map(..)返回一个Just实例,它就会在外层还有一层Just包装,所有我们就得到一个嵌套两层的monad值。这个是合法的操作,有时候我们就希望如此,但通常情况下我们不希望这样。如果我们用flatMap(..)返回相同的类型(也叫chain(..)/bind(..)),这就没有嵌套。本质上,flatMap(..)函数将返回值扁平化了一层!

chain(..)方法旨在为提供的函数返回一个与调用该方法的函数相同类型的monad。然而,Monio通常不执行显示类型强制,因此没有严格组织这种monad类型较差的情况。(比如JustMaybe)是否遵循这些机制的隐含类型特征取决于开发者自己。

我已经断言chain(..)(不论你称为什么名字)是作为单子(monadic things)最重要的概念。然而,即使像chain(..)看起来实现起来一样简单(see earlier),它却提供了一种特别的方式来保障一个monad如何和其他的monad实例作交互。这些相互作用和转换对于构建一个没有混乱的monads程序而言是至关重要的。

Monad魔方的另一面是这些保证。它们被确保为定律一样的东西,所有符合monad实现的事物都必须遵守:

  1. Left Identity 左交换律
  2. Right Identity 右交换律
  3. Associativity 结合律

这些定律的形式和数学重要性现在并不重要,但是用我们微不足道的Monio的Just(identity monad)来非常简单地说明一下这些定律:

// helpers:
const inc = v => Just(v + 1);
const double = v => Just(v * 2);

// (1) "left identity" law
Just(41).chain(inc);                        // Just(42)

// (2) "right identity" law
Just(42).chain(Just);                       // Just(42)

// (3) "associativity" law
Just(20).chain(inc).chain(double);          // Just(42)
Just(20).chain(v => inc(v).chain(double));  // Just(42)

这里我用Monio的chain方法,这仅仅是个方便的演示说明。monad的定律是用chain操作来表述的,不论你选择怎么称呼它。

回归Monad的核心问题

归根结底的是,Monad类型只是要求做这两件事情:

  1. 一个函数作为构造器来构建monad类型(单元构造器,比如 Just  Maybe
  2. 一个函数来实现 chain操作,满足交换律和结合律

你在本指南中的代码片段中看到的,比如包装monad实例、指定方法名称、"friends of monads" behaviors等,所有的方便提供的类型都来自于Monio。

但从这个狭隘的角度来看,一个monad并不需要是一个“容器”(比如包装一个object或class实例),也并不需要一个具体的值(比如42)。一个“包装着一个值的容器”是一个可观察的潜在有用的魔方面(包装的容器是观察理解monad的一个例子),它并不是monad的全部样貌。在你的思维中不要有太多“包装”相关的想法!

但是,我怎么拿到数据?

你可能仍然奇怪:我们如何提取值(比如基本数据类型42)--或者实际上,如何把monad所代表的一切--从单子表示(monadic representation)中提取出来?目前看起来好像每个单子操作都只是产生另一个monad实例。从某种意义上说,我们可能需要一个实际的数字42来打印,或者插入数据库,对吧?!

一个FP的重要概念(尤其是monads里面),是延迟我们对底层值的需求到最后的时刻。对于monads,我们更希望尽可能长地保持所有的“提升”在单子空间(monadic space)。

但又是我们确实需要转换一个monad到“真实”的值。有很多方式可以完成这个操作,但是这有一个方式,预览我们将在talk about later in the guide中说的。为了从一个monad(比如Just) 中“提取”值,我们可以使用一个Monio提供的方法fold(..)

const identity = v => v;

const myAge = Just(41);
const myNextAge = myAge.map(birthday);

// later:
const ageIsJustANumber = myNextAge.fold(identity);
console.log(`I'm about to be ${ ageIsJustANumber } years old!`);
// I'm about to be 42 years old!

所以,这里有一个“逃逸值”(fold(identity)),让我们可以从我们的Just中退出。

但是记住:monads可以和其他monads很好的交互(and their friends!),所以最好的方式是尽可能地保持值在单子空间。让我们推迟丢弃monad表示的方式,直到我们必须这么做!

同时记住:fold(..)方法提供给Monio的众多monads,它并不是Monad类型的行为。它来自于from a friend called Foldable

Maybe单子更进一步

Monio 相关文档看: Maybe

Just单子可能并不那么神奇。它很可爱,也许有点聪明,但它有点不起眼。实践中,我们几乎不会直接创建一个Just单子实例。

这些只是基础。它本身并不应该看起来具有革命性,因为这将呈现出太高的一个悬崖,而我们可能高不可攀。如果您小时候第一个数字不是2或者3,而是 √2或π,你可能会发现在小时候学习基础数学非常难!

如果Just对你来说看起来太简单了,那可能是一件好事!你可能正在“学会”monads。但是不要让它简单到让你觉得枯燥,觉得没有值得你花时间的知识。是有这样的知识的!

在这个基础的monad概念之上,有很多变体/增强的概念,会让事情变得更加有趣。我可以花很多时间和很多页来详细说明他们的例子。但是让我通过简单的演示另外的一些建立在Identity单子之上的monads的例子来逐步进行。

你可能之前命令式的程序里面写过这种样式的代码:

const shippingLabel = (
    (record != null && record.address != null && record.address.shipping != null) ?
        formatLabel(record.address.shipping) :
        null
);

当我们对这些期望他们不是null的值进行操作时,我们必须在整个程序中使用!= null检查来避免js异常。

然而,最新的js(ES2020)增加了一个“可选链”(optional chaining)操作,可以简单地做这些事情:

const shippingLabel = (
    (record?.address?.shipping != null) ?
        formatLabel(record.address.shipping) :
        null
);

?.操作(而不是.),是一个短路操作符,当左值为null时就跳过操作。这意味着我们不需要record != null或者record.address != null检查,因为?.帮我们做了这个事。

我们的操作现在就被保护起来了,不会在null上操作,但record.address.shipping仍然可能是空的,我们想要在这种场景下跳过formatLabel(..)操作,因此最后我们还有一个!= null的检查。

Maybe单子,有时候在其他库和语言内被称为Option或者Optional,允许我们定义一个行为来讲这些!= null完全委托给monad的行为,将我们从这些行为中解放出来。

为了了解Maybe,让我们先在我们之前讨论过的Just Identity单子之外增加一个monad类型:琐碎而不起眼的monad,我们称为Nothing(表示空)。NothingJust做的事情还少:它使得你在它上面调用的任意方法短路。就像一个方法黑洞,可以安全地跳过有危害的操作。Nothing是安全的,它就像是单子化的null或者undefined

Maybe是一个Sum Type,它表示这两种monad类型(Just Nothing)的对偶性。这并不是说Maybe同时是两种类型,而是说它要么是其中一个,要么是其中的另一个。

注意:大多数monad实现不会暴露JustNothing作为单独的monad类型,而是只有一个Maybe类型。Monio选择分开表示他们同时也可以用Maybe结合他们,是为了方便演示的目的。

Maybe:JustMaybe:Nothing之间做选择在一开始可能会有一些迷惑性。你可能期望决策本身来源于一个单元构造器Maybe(..)。实际上,大多数受欢迎的monad教程/博客都是这么做的,因为这样令Maybe的演示更加方便也令人满意。

但这并不是合适的monad方式。Monio做了更恰当的事情,并将决策从Maybe(..)/Maybe.of(..)构造器转移到一个名为Maybe.from(..)的独立帮助函数中。Maybe.from(null)将得到一个Maybe:Nothing实例,Maybe.from(42)将得到一个Maybe:Just实例。

相比之下,调用Maybe(..)/Maybe.of(..)不会做任何条件选择,而只会将任何非Maybe的值表示为Maybe:Just

此外,Maybe.from(..)Nothing.isEmpty(..)代理了这个问题,“它是空的吗?”。Nothing.isEmpty(..)函数会执行== null检查,但是你可以覆盖他以重新定义什么值你想定义为Maybe.from(..)的空。

所以我们用Maybe.from(..)来创建Maybe:JustMaybe:Nothing实例。下面的代码中,.chain(..)调用将因此带来副作用令一个和另一个相对(Maybe本身仅作为一个薄弱的传递包装器)

const shippingLabel = (
    Maybe.from(record)
    .chain( record => Maybe.from(record.address) )
    .chain( address => Maybe.from(address.shipping) )
    .chain( shipping => Maybe.from(formatLabel(shipping)) )
);

shippingLabel就是一个Maybe类型。它要么表示Maybe:Just,有一个值,要么表示Maybe:Nothing,为空。

但是不论它表示哪个,都不影响我们写monad风格的代码串!如果Maybe.from(..)在这个链的任意位置产生一个Maybe:Nothing,任意子序列的chain(..)操作将跳过,因此把我们的代码从因bull造成异常的情况下保护起来。

Maybe安全抽象了我们之前关于对保护操作不引发异常的条件决策逻辑的担心。

上面的代码可能有些繁琐,让我们用一些帮助函数来清理它:

// assumed:
// function formatLabel(label) { .. }

// helpers:
const getPropSafe = prop => obj => Maybe.from(obj[prop]);
const formatLabelSafe = v => Maybe.from(formatLabel(v));

const shippingLabel = (
    Maybe.from(record)
    .chain( getPropSafe("address") )
    .chain( getPropSafe("shipping") )
    .chain( formatLabelSafe )
);

这代码看起来更棒一些。而且这里没有让我们代码显得杂乱的!= null检查。

我们的shippingLabel单子现在准备好和其他monads行为交互了,并且这些交互会安全可预期,不论它具体是Maybe:Just还是Maybe:Nothing

我们代码更进一步的提升可以用Monio提供的便利。一个monad的帮助函数,pipe(..)

const shippingLabel = (
    Maybe.from(record)
    .chain.pipe(
        getPropSafe("address"),
        getPropSafe("shipping"),
        formatLabelSafe
    )
);

我们看到,chain.pipe(..)允许你组合多个.chain(..)序列为一个按序排列参数的调用。在Monio的monads上,你可以用.map.pipe(..).ap.pipe(..) .concat.pipe(..)做同样的事情。除了增加便利性之外,在某种程度上,它也稍微更有效率/性能更好。

顺便提一下,Maybe也是Foldable,所以可以用fold(..)退出,获得逃逸值。但因为Maybe是一个Sum Type,这里的fold(..)有两个函数,第一个Maybe:Nothing时调用,第二个Maybe.Just时调用。更多看:using Foldable and other adjacent behaviors later

你可能希望开始看一些monad表示值和表达式带来的更多好处。

我知道,IO很重要

Monio Reference: IO (and variants)

目前为止,我们已经看到那些表示直接基本数据类型比如42或者表示对象的例子。但是monads不仅仅是“值包装器”。

monads也可以被认为是一种“行为包装器”,表示一些操作,比如那些我们认为不可靠的,可能产生副作用的方法或者函数。这就是IO单子所做的事!

Monio的核心是IO单子的实现。它被设计为一个超级强大的Sum Type,它包含了各种有用的行为,有点像Scala's ZIO。我认为Monio的IO是js中最强大的IO实现。但我知道这只是一个令人生畏的主张。

别担心,为了继续,我们现在要专注于一小块IO功能,这样我们就不会不知所措。

你在IO中传入的那个函数,在执行时将开始一些副作用操作。它并不必须是副作用操作。它可以是静态的、纯函数,就像普通函数一样返回一个值。

最重要的概念是IO单子类型是惰性的,它并不即时做任何事情(就像你调用普通方法一样自动执行并返回值)。你必须评估IO能执行的操作。

For example:

const greeting = IO(() => console.log("Hello, friend!"));

// later (nothing has happened yet!)
greeting.run();
// Hello, friend!

An IO, once evaluated, can also produce a value:

一个IO,一但运行了,可以产生一个值。

const customerName = IO(() => (
    document.getElementById("customer-name-input").value
));

customerName.run();  // "Kyle"

run(..)方法可以认为有点像JustMaybe上的fold(..)。它帮助你执行IO单子,并把副作用行为从单子空间逃逸出来。

就像我们已经断定过几次的那样,“最佳实践”的重要目标是保持我们程序的副作用为IO操作,并只在程序需要的最后一刻消耗他们(逃逸/运行/执行)。

这有个复杂的例子串联一些IO在一起

// helpers:
const getProp = prop => obj => obj[prop];
const assignProp = prop => val => obj => obj[prop] = val;

const getElement = id => IO(() => document.getElementById(id));
const getInputValue = id => getElement(id).map( getProp("value") );
const renderTextValue = id => val => (
    getElement(id).map( assignProp("innerText")(val) )
);


const renderCustomerNameIO = (
    getInputValue("customer-name-input")
    .chain( renderTextValue("customer-name-display") )
);

// later:
renderCustomerNameIO.run();

你可以看到,这里我们组合了一些副作用操作在一起,就像我们之前组合数字和对象一样可预测。monads确实是一种变革性的、革命性的思考我们程序的方式。

为了方便,IO.of(..)通常是IO(() => ..)的等效操作。在两种情况下,你都得到一个惰性的IO。但是请注意细微的差别/陷阱:在IO.of(..)下,任何被提供的表达式都会被立即执行,然而当你用IO(() => ..),你需要手动装箱你的表达式,不论是什么,都要装箱为一个函数,这样它就不会被立即执行。

这样情况下,IO.of(..)应该仅当你已经有一个固定的非副作用的值表达式时使用,当你的表达式是一个副作用时你应该总是使用IO(() => ..)

但是为什么要IO单子?

为什么要把所有副作用操作都放到IO实例里面?

最根本的回答是:我们需要在不同副作用之间的到一个可预测的交互!(和保证!)。

当副作用直接且同步的,比如从DOM中的到一个元素,或者注入元素内容,可预测性和保证性似乎对我们没有太大的好处。

所以我最想放在这里的问题是:为什么Monio的IO单子是特别的?

我强烈认为我们代码中最复杂的副作用来源于异步操作,比如ajax请求,执行一个定时器,监听一个事件,执行一段动画等等。一个像Monio这样的IO实现可以提供一个处理代码中异步操作的通用模型,因此得到一个在异步操作中可预测和得到保证的程序是一个改变规则的处理方式。

Monio的IO自动转换和消化js的Promise,并在任何异步操作发生时提升一个IO链为一个Promise。对事件流(单Promise不能适用的场景),IOx就像是IOObservable.

如果这还不足以引起您的兴趣,那么程序面临的另一个挑战(无论您是否意识到),Monio 的 IO 像冠军一样给出:如何将一组操作与环境隔离(如 DOM 等),以便您可以为代码运行提供环境/上下文?这对于防止程序中出现意外副作用至关重要,但它也是创建 TESTABLE 副作用代码的最有效方法。

Monio 的 IO 也包含 Reader monad 类型的行为。这意味着IO(无论链有多长/涉及多长时间)携带一个提供的“环境”,作为参数传递给run(..)

您可以将整个程序定义为一个“IO”实例,并且如果您调用“run(window)”,您将在浏览器的 DOM 上下文中运行您的程序。但是在您的测试套件中,您可以在同一个 IO 上调用 run(fakeDOMglobal),现在所有代码和副作用都会自动通过该替代环境进行线程化。

It's effectively passing the entire "global" (aka, universe/scope-of-concern) into your program, whatever appropriate value that is, instead of the program automatically assuming which "global" it should apply against.

它有效地将整个“全局”(global)(又名,universe全局/scope-of-concern关联作用域)传递到您的程序中,无论是什么适当的值,而不是程序自动假设它应该适用于哪个“全局”。

但最终,Monio 的 IO 的真正力量 甚至没有包含在我们迄今为止讨论的内容中。 pièce de résistance 是 IO 提供了一座桥梁,让您回到熟悉和舒适的更命令式编码。

你喜欢使用 if 和 try..catch 和 for..of 循环吗?您可能已经注意到 FP 和 monads 似乎将所有这些东西都扔到了窗外,有利于长链的柯里化和组合函数调用。如果您可以获得“IO”的所有功能,但在有帮助的情况下选择使用更典型的命令式代码风格会怎样?

IO.do(..) 需要一个 JS 生成器,其代码看起来像大多数 JS 开发人员非常熟悉的 async..await 样式。当你 yield 一个值时,如果它是一元的,它会自动链接和解包(就像你有一个 IO 到 chain(..) 的来源)。如果结果是异步的(承诺),生成器内的代码会自动暂停以“等待”完成。

结合其所有方面,Monio 的 IO(和 IOx 超集)是“一个统治它们的单子”。

还有一些其他的monads(作为我们的朋友)

好吧,如果你已经做到了这一步,请深呼吸。说真的,也许去散步让其中的一些安顿下来。也许重新阅读它,几次。“一个统治他们所有人的单子”。

我们已经看到了单子概念的一个不错的(如果是基本的)说明。而且我们没有涵盖 Either —— 另一个 Sum Type 像 Maybe,但它在双方都持有值。 Either 通常用于表示同步的 try..catch 样式异常处理。我们也没有介绍 AsyncEither,它扩展了 Either 以异步操作(通过 Promise),与 IO 转换/处理它们的方式相同。 AsyncEither 本质上是 Monio 对 Promise/Future 类型的表示。

但是与 monad 适合的范畴论的广阔相比,它本身就是一个相当狭窄的概念。有许多来自范畴论的相邻(并且有些相关)概念,更具体地说,是“代数数据类型”(ADT)——或者至少是从它的一部分改编而来的。这些“朋友”包括:

  • Foldable 可折叠 - 用于逃逸值
  • Concatable (aka, Semigroup) 可连接 幺半群 - 链式操作
  • Applicative 可应用 - 应用其他monad的方法

那里还有很多很多其他主题,但这是 Monio 关注的主要三个“朋友”(并与它的 monad 混合)

需要明确的是,这三个不是单子行为。我称它们为“单子的朋友”,因为我发现单子与这些其他行为混合在我的 JS 代码中比单独的单子(或任何其他类型)更有用/实用;我认为正是这些类型行为的组合使 monad 对我们的程序具有吸引力和强大的解决方案。

我知道 FP 领域的许多人更喜欢完全独立地考虑每种类型。如果它对他们有效,那没关系。但我发现这些组合更具吸引力。

Foldable可折叠

混入(大部分)Monio 的 monad 中的 fold(..) 方法正在实现“可折叠”类型的行为。值得注意的是,IO 及其变体不是直接可折叠的,但那是因为当您调用 run(..) 时,IO 的本质已经在做某种 fold(..)

我们已经看到了前面几次引用的 fold(..)。这只是 Monio 提供的名称,但就像 chain(..) vs flatMap(..) vs bind(..),名称本身并不重要,只有预期的行为.

我们没有谈论 List 类型的 monad(因为 Monio 不提供这样的),但当然可以存在。在这种 List monad 的上下文中可折叠将在列表中的所有值上应用提供的函数,通过将每个值折叠到累加器中来逐步累加单个结果(任何类型)。JS 数组有一个 reduce(..) 方法,它基本上是 List 的可折叠的实现(Foldable)。

相比之下,在单值 monad(如 Just)的上下文中,Foldable 使用其单个关联/基础值执行提供的函数。它可以被认为是通用 List 可折叠的一个特例,因为它不需要跨多次调用“累积”其结果。

类似地,像 Maybe 和 Either 这样的 Sum Types 在 Monio 中也是可折叠的;这是进一步的特化,这里的 fold(..) 有两个函数,但只会执行其中一个。如果关联值为“Maybe:Nothing”,则应用第一个函数,否则(当关联值为“Maybe:Just”时),应用第二个函数。 Either:Left 调用第一个函数和 Either:Right 调用第二个函数也是如此。

但是我们如何实际使用 Foldable 呢?

正如我在本指南前面几次暗示的那样,一种这样的转换是通过将标识函数(例如,v => v)传递给 fold (..)

Just(42).fold(identity);   // 42

Rather than using Foldable to extract the value itself, we'll more often prefer to use fold(..) to define a natural transformation from one kind of monad to another. To illustrate, let's revisit this example from earlier:

现在,如果你仔细观察,像 Justfold(..) 和 chain(..) 这样的单值 monad 似乎具有相同的行为(甚至实现几乎相同)。你可能想知道为什么我们应该在 Just 上提供看似重复的 fold(..) 而不是仅提供 chain(..)

正如我在单子链里解释的,chain(..)函数总是返回一个和调用container相同的container类型(函数签名)。换句话说,haskell风格的类型签名本质上是chain: m a -> (a -> m b) -> m b。类型/种类 m 的 monad 可能在内部与在 chain(..) 调用之前到之后的不同类型值(a 与 b)相关联,但它仍然应该是一个m 种单子。

所以调用 Just(42).chain(identity) 得到逃逸值违反了这个隐含的类型签名——尽管 Monio 没有强制执行它并且操作可以正常工作。另一方面,fold(..) 没有那种隐含的类型签名,因为它旨在让您“折叠”(取到内部值)到任何任意类型,而不仅仅是另一个 monad 实例。 fold(..) 是一个更灵活的路径,它允许我们“提取”关联/基础值。

此外,Foldable 在 Sum Types Maybe 和 Either 上的 fold(..) 与它们的 chain(..) 方法有一个非常不同的签名,因此它们根本不会相互重复.

与其使用 Foldable 来提取值本身,我们更喜欢使用 fold(..) 来定义从一种 monad 到另一种的自然转换。为了说明,让我们回顾一下之前的这个例子:

// assumed:
// function formatLabel(label) { .. }

// helpers:
const getPropSafe = prop => obj => Maybe.from(obj[prop]);
const formatLabelSafe = v => Maybe.from(formatLabel(v));

const shippingLabel = (
    Maybe.from(record)
    .chain.pipe(
        getPropSafe("address"),
        getPropSafe("shipping"),
        formatLabelSafe
    )
);

如果我们想渲染shipping标签,但前提是它实际上是有效/定义的,否则打印默认通知,我们可以像这样安排我们的程序:

// assumed:
// function formatLabel(label) { .. }

// helpers:
const identity = v => v;
const getPropSafe = prop => obj => Maybe.from(obj[prop]);
const assignProp = prop => val => obj => (
    Maybe.from(obj).map(o => o[prop] = val)
);
const getElement = id => IO(() => document.getElementById(id));
const renderTextValue = id => val => (
    getElement(id).map(el => (
        Maybe.from(el).fold(
            IO.of,
            assignProp("innerText")(val)
        )
    ))
);
const formatLabelSafe = v => Maybe.from(formatLabel(v));


// ----

const renderShippingLabel = v => (
    v.fold(
        () => IO.of("--no address--"),
        identity
    )
    .chain( renderTextValue("customer-shipping-label") )
);

const renderIO = renderShippingLabel(
    Maybe.from(record)
    .chain.pipe(
        getPropSafe("address"),
        getPropSafe("shipping"),
        formatLabelSafe
    )
);

renderIO.run();

A key aspect of the snippet is Maybe's fold(..) call in the renderShippingLabel(..) function, which folds down to either a fallback IO value if the shipping address was missing, or the computed IO holding the valid shipping address, and then chain(..)s off whichever IO was folded to. There's a similar thing happening in renderTextValue(..). Both fold(..) calls are expressing a natural transformation from the Maybe monad to the IO monad.

Again, Foldable is distinct from monads. But I think this discussion illustrates how useful it is when paired with a monad. That's why it's an honored friend.

花点时间阅读和分析该代码。它说明了我们的 monad 类型如何以有用的方式进行交互。我保证,即使一开始这段代码看起来令人头晕目眩——它对我来说确实如此!-- 最终你会理解甚至更喜欢这样的代码!

同样,Foldable 与 monad 不同。但我认为这个讨论说明了与 monad 配对时它是多么有用。这就是为什么它是尊敬的朋友

可连接/幺半群

Concatable,正式称为 Semigroup(幺半群),是 monad 的另一个有趣的朋友。您不一定会经常看到它显式使用,但它可能很有用,尤其是在使用 foldMap(..)(它是 reduce(..) 的抽象)时。

Monio 选择在其 monad 上实现 Concatable 作为 concat(..) 方法。该名称不是类型所必需的,当然,Monio 就是这样做的。

这里的基本思想是一个值类型是“可连接的”,如果它的两个或多个值可以连接在一起。例如,原始的、非 monad 值类型(如字符串和数组)是可连接的,实际上它们甚至公开了相同的 concat(..) 方法名称:

"Hello".concat(", friend!");     // "Hello, friend!"
123 ].concat( [ 45 ] );  // [1,2,3,4,5]

由于 Monio 的所有 monad 都是可连接的,它们都有 concat(..) 方法。因此,如果任何这样的 monad 实例与一个值相关联,该值也有一个符合 concat(..) 方法的值——例如,另一个 monad,或者像字符串或数组这样的非 monad 值——然后调用monad 的 concat(..) 方法将委托对关联/基础值调用 concat(..)。这个对底层 concat(..) 的委托是递归的。

For example:

Just("Hello").concat(Just(", friend!"));        // Just("Hello, friend!")
Just([1,2,3]).concat(Just([4,5]));              // Just([1,2,3,4,5])

Just(Just([1,2,3])).concat(Just(Just([4,5])));  // Just(Just([1,2,3,4,5]))

// `fold(..)` and `foldMap(..)` provided in
// Monio's util module
fold(Just("Hello"),Just(", friend!"));          // Just("Hello, friend!")

foldMap(
    v => v.toUpperCase(),
    [
        Just("Hello"),
        Just(", friend!")
    ]
);                                              // Just("HELLO, FRIEND!")

与 chain(..) 一样,Monio 的 concat(..) 应该用于两个相同种类的 monad 之间。但是没有明确的类型强制来防止交叉类型(例如,在 Maybe 和 Either 之间)。

注意: 尽管名称重叠,但 MonioUtil 模块提供的独立 fold(..) 和 foldMap(..) 实用程序 与 [可折叠类型](# foldable) 的 fold(..) 方法出现在 Monio monad 实例上。

单体/Monoid

此外,术语 Monoid 表示可连接/半群加上连接的“空”(恒等式)值。例如,字符串连接是一个带有空 "" 字符串的幺半群。数组连接是一个带有空 [] 数组的幺半群。即使是数字加法也是一个带有“0”“空”数字的幺半群。

将这种幺半群的概念扩展到最初看起来不像“可连接”的东西的一个例子是将多个布尔值组合在一个 && 或 || 逻辑表达式中。对于逻辑与运算,“空”值为“真”,而对于逻辑或运算,“空”值为“假”。这些值的“连接”是计算的逻辑结果(true 或 false)。

Monio 提供 AllIO 和 AnyIO 作为 IO 变体,它们是 monoids —— 同样,“空”值和 concat(..) 方法。特别是,这两个 IO 变体上的 concat(..) 方法旨在计算两个布尔结果 IO 之间的逻辑与/逻辑或(分别)。这使得 AllIO 和 AnyIO 易于与前面提到的 fold(..) 和 foldMap(..) 实用程序一起使用。

Monio Reference: AllIOAnyIO

下面是通过 fold(..) / foldMap(..) 连接这些 monoid 的示例:

const trueIO = IO.of(true);
const falseIO = IO.of(false);

fold( AllIO.fromIO(trueIO), AllIO.fromIO(falseIO) ).run();  // false
fold( AnyIO.fromIO(falseIO), AllIO.fromIO(trueIO) ).run();  // true

const IObools = [
    trueIO,
    trueIO,
    trueIO,
    falseIO,
    trueIO
];

foldMap( AllIO.fromIO, IObools ).run();   // false
foldMap( AnyIO.fromIO, IObools ).run();   // true

作为额外的便利,Monio 的 IOHelpers 模块还提供了 iAnd(..) 和 iOr(..),它会自动应用此逻辑与/逻辑-或 foldMap(. .) 对两个或更多提供的 IO 实例的逻辑:

const trueIO = IO.of(true);
const falseIO = IO.of(false);

iAnd( trueIO, trueIO, falseIO, trueIO ).run();  // false
iOr(  trueIO, trueIO, falseIO, trueIO ).run();  // true

我正在说明使用直接的 true 和 false 值创建 IO 实例,但这并不是您实际使用这些机制的方式。由于它们都是 IO 实例,因此布尔结果(true 或 false)可以在它们各自的 IO 中延迟(并且异步!)计算,即使是由于复杂的副作用。

例如,您可以定义代表 DOM 元素状态的几个 IO 实例的列表,如下所示:

// helpers:
const getElement = id => IO(() => document.getElementById(id));
const getCheckboxState = id => getElement(id).map(el => !!el.checked);

const options = [
    getCheckboxState("option-1"),
    getCheckboxState("option-2"),
    getCheckboxState("option-3")
];

const allOptionsChecked = iAnd( ...options ).run();    // true / false
const someOptionsChecked = iOr( ...options ).run();    // true / false

额外练习:考虑在没有选中任何复选框时如何计算 noOptionsChecked - true

可应用

Applicative 比 Semigroup 更不寻常(根据我的经验,不太常见)。但有时它会有所帮助。 Monio 选择使用 ap(..) 方法在其大多数 monad 上实现此行为。

Applicative 是一种模式,用于在 monad 中保存一个函数,然后“应用”来自另一个 monad 的值作为函数的输入,将结果返回给另一个 monad。如果该功能需要多个输入,则可以多次执行此“应用程序”,一次提供一个输入。

但我认为解释 Applicative 的最佳方式是只显示具体代码:

const add = x => y => x + y;

const addThree = Just(add(3));     // Just(y => 3 + y)
const four = Just(4);              // Just(4)

addThree.ap(four);                 // Just(7)

如果一个 monad 持有一个函数,例如这里显示的 curried/partially-applied add(..),你可以将它“应用”到另一个 monad 中持有的值。注意:我们在源函数持有的 monad (addThree) 上调用 ap(..),而不是在目标值持有的 monad (four) 上调用。

这两个表达式大致等价

addThree.ap(four);

four.map( addThree.fold(fn => fn) );

回想一下 可折叠,将标识函数传递到 Monio monad 的 fold(..) 中,本质上是从 monad 中提取值。如图所示,ap(..) 有点像“提取”第二个 monad 中保存的映射函数,并针对第一个 monad 中保存的值运行它(通过 map(..))。

用单个表达式表达上述内容的另一种方式:

const add = x => y => x + y;

Just(add)               // Just(x => y => x + y)
    .ap( Just(3) )      // Just(y => 3 + y)
    .ap( Just(4) );     // Just(7)

我们将 add(..) 本身放入了 Just 中。第一个 ap(..) 调用“提取”该函数,将 3 传递给它,然后使用返回的 y => 3 + x 函数创建另一个 Just。然后第二个 ap(..) 调用与前面的代码片段执行相同的操作,提取 y => 3 + x 函数并将 4 传递给它。 4 => 3 + 4 的最终结果是 7,然后将其放回到 Just 中。

与 chain(..) 和 concat(..) 一样,Monio 的 ap(..) 应该 传递与调用方法相同类型的 monad。但是没有明确的类型强制来防止交叉类型(例如,在 Maybe 和 Either 之间)。

Monio 的所有非IO monad 都是 Applicative。同样,您可能不会非常频繁地使用这种行为,但希望您现在能够在它出现时认识到需求。

总结

我们现在已经触及了 monad(以及几个 朋友)的表面。这绝不是对该主题的完整探索,但我希望你开始觉得它们不那么神秘或令人生畏了。

monad 是与值(数据值)或操作(函数)相关联的一组狭窄的行为(“几个定律”要求)。范畴论产生了其他相关的行为约束,例如可以增强这种表示的能力的可折叠(Foldable)和可连接(Concatable)。

这组特定的行为改进了其他monad或者按monad约定的数据结构之间的协调/交互操作,使得结果更可预测。这些行为还提供了许多机会来抽象(转变为行为定义)某些通常会使我们的命令式代码混乱的逻辑(例如空值检查)。

Monads 当然不能解决我们在代码中可能遇到的所有问题,但我认为通过进一步探索它们有很多有趣的力量可以解锁。我希望本指南能激励您继续挖掘,也许在您的探索中,您会发现 Monio 库很有帮助。


----- 近期文章 -----