就OOP程序员所能理解的(没有任何功能编程背景)而言,什么是monad?

它解决了什么问题,最常用的地方是什么?

使现代化

为了澄清我所寻求的理解,假设您正在将一个具有monad的FP应用程序转换为OOP应用程序。如何将monad的职责移植到OOP应用程序?


当前回答

更新:这个问题是一个非常长的博客系列的主题,你可以在Monads上阅读-谢谢你提出的问题!

就OOP程序员所能理解的(没有任何功能编程背景)而言,什么是monad?

monad是一种类型的“放大器”,它遵守某些规则,并提供某些操作。

首先,什么是“类型放大器”?我指的是某种系统,它可以让你选择一种类型,并将其转换为更特殊的类型。例如,在C#中,考虑Nullable<T>。这是一种类型的放大器。它允许您接受一个类型,比如int,并为该类型添加一个新的功能,即现在它可以在以前不能为null时为null。

作为第二个例子,考虑IEnumerable<T>。这是一种类型的放大器。它允许您获取一个类型,例如字符串,并为该类型添加一个新功能,即您现在可以从任意数量的单个字符串中创建一个字符串序列。

什么是“特定规则”?简言之,对于基础类型上的函数来说,有一种合理的方法来处理放大的类型,从而使它们遵循函数组合的正常规则。例如,如果你有一个关于整数的函数,比如

int M(int x) { return x + N(x * 2); }

则Nullable<int>上的相应函数可以使其中的所有运算符和调用“以与之前相同的方式”一起工作。

(这是难以置信的模糊和不精确;你要求的解释没有假设任何关于功能成分的知识。)

什么是“操作”?

有一个“单元”操作(有时被混淆地称为“返回”操作),它从普通类型中获取值并创建等效的一元值。本质上,这提供了一种获取未放大类型值并将其转换为放大类型值的方法。它可以作为OO语言中的构造函数实现。有一个“绑定”操作,它接受一个一元值和一个可以转换该值的函数,并返回一个新的一元值。Bind是定义monad语义的关键操作。它允许我们将未放大类型上的操作转换为放大类型的操作,这符合前面提到的函数组合规则。通常有一种方法可以使未放大的类型从放大的类型中恢复出来。严格来说,这个操作不需要有monad。(虽然如果你想有一个伴侣,这是必要的。我们不会在本文中进一步考虑这些。)

同样,以Nullable<T>为例。可以使用构造函数将int转换为Nullable<int>。C#编译器为您处理大多数可为空的“提升”,但如果没有,提升转换很简单:,

int M(int x) { whatever }

转化为

Nullable<int> M(Nullable<int> x) 
{ 
    if (x == null) 
        return null; 
    else 
        return new Nullable<int>(whatever);
}

通过Value属性可以将Nullable<int>转换回int。

关键是函数转换。请注意,在转换中如何捕获可为null的操作的实际语义(即对null的操作传播null)。我们可以概括这一点。

假设你有一个从int到int的函数,就像我们最初的M一样。你可以很容易地将它转换成一个接受int并返回Nullable<int>的函数,因为你可以通过可空构造函数来运行结果。现在假设你有一个更高阶的方法:

static Nullable<T> Bind<T>(Nullable<T> amplified, Func<T, Nullable<T>> func)
{
    if (amplified == null) 
        return null;
    else
        return func(amplified.Value);
}

看看你能用它做什么?任何接受一个int并返回一个int,或者接受一个整型并返回一一个Nullable<int>的方法现在都可以应用可空语义。

此外:假设您有两种方法

Nullable<int> X(int q) { ... }
Nullable<int> Y(int r) { ... }

并且您希望编写它们:

Nullable<int> Z(int s) { return X(Y(s)); }

也就是说,Z是X和Y的合成,但不能这样做,因为X取一个int,Y返回一个Nullable<int>。但是,由于您有“绑定”操作,因此您可以执行以下操作:

Nullable<int> Z(int s) { return Bind(Y(s), X); }

monad上的绑定操作使放大类型上的函数组合工作。我在上面写的“规则”是,monad保留了正常函数组合的规则;使用同一函数的合成产生原始函数,合成是关联的,等等。

在C#中,“Bind”被称为“SelectMany”。看看它是如何在序列monad上工作的。我们需要做两件事:将一个值转换为一个序列,并在序列上绑定操作。作为奖励,我们还可以“将序列转换回值”。这些操作包括:

static IEnumerable<T> MakeSequence<T>(T item)
{
    yield return item;
}
// Extract a value
static T First<T>(IEnumerable<T> sequence)
{
    // let's just take the first one
    foreach(T item in sequence) return item; 
    throw new Exception("No first item");
}
// "Bind" is called "SelectMany"
static IEnumerable<T> SelectMany<T>(IEnumerable<T> seq, Func<T, IEnumerable<T>> func)
{
    foreach(T item in seq)
        foreach(T result in func(item))
            yield return result;            
}

可为null的monad规则是“将产生可为null值的两个函数组合在一起,检查内部函数是否产生null值;如果产生null值,则产生null值。如果没有,则调用外部函数产生null值”。这就是nullable的理想语义。

序列monad规则是“将两个生成序列的函数组合在一起,将外部函数应用于内部函数生成的每个元素,然后将所有生成的序列连接在一起”。monad的基本语义在Bind/SelectMany方法中被捕获;这是告诉monad真正含义的方法。

我们可以做得更好。假设您有一个int序列,以及一个接受int并生成字符串序列的方法。我们可以推广绑定操作,以允许组合接受和返回不同放大类型的函数,只要其中一个的输入与另一个的输出匹配:

static IEnumerable<U> SelectMany<T,U>(IEnumerable<T> seq, Func<T, IEnumerable<U>> func)
{
    foreach(T item in seq)
        foreach(U result in func(item))
            yield return result;            
}

现在我们可以说“将这一组单独的整数放大成一系列整数。将这一特定的整数转换成一系列字符串,放大成一组字符串。现在将这两个操作放在一起:将这一系列整数放大成所有字符串序列的串联。”单子允许您合成放大。

它解决了什么问题,最常用的地方是什么?

这相当于问“单例模式解决了什么问题?”,但我会尝试一下。

Monad通常用于解决以下问题:

我需要为这种类型创建新的功能,并且仍然在这种类型上组合旧的功能以使用新的功能。我需要在类型上捕获一堆操作,并将这些操作表示为可组合对象,构建越来越大的组合,直到表示出正确的一系列操作,然后我需要开始获得结果我需要用讨厌副作用的语言清晰地表示副作用操作

C#在设计中使用monad。如前所述,可空模式与“可能是monad”非常相似。LINQ完全由monad构建;SelectMany方法是操作组合的语义工作。(Erik Meijer喜欢指出,每个LINQ函数实际上都可以由SelectMany实现;其他一切都只是为了方便。)

为了澄清我所寻求的理解,假设您正在将一个具有monad的FP应用程序转换为OOP应用程序。如何将monad的职责移植到OOP应用程序中?

大多数OOP语言没有足够丰富的类型系统来直接表示monad模式本身;您需要一个支持高于泛型类型的类型的类型系统。所以我不会这么做。相反,我将实现表示每个monad的泛型类型,并实现表示您需要的三个操作的方法:将值转换为放大值,(可能)将放大值转换为值,以及将未放大值上的函数转换为放大的值上的函数。

一个好的开始是我们如何在C#中实现LINQ。研究SelectMany方法;这是理解序列monad如何在C#中工作的关键。这是一个非常简单的方法,但非常强大!


建议进一步阅读:

为了对C#中的单子进行更深入、理论上更合理的解释,我强烈推荐我(埃里克·里佩尔)的同事韦斯·戴尔(Wes Dyer)关于这个主题的文章。这篇文章是当他们最终为我“点击”时向我解释单子的原因。蒙得斯的奇迹这是一个很好的例子,说明了为什么您可能需要一个monad(在示例中使用Haskell)。你本可以发明修道院的!(也许你已经有了。)丹·皮波尼有点像,将上一篇文章“翻译”为JavaScript。詹姆斯·科格兰(James Coglan)所读过的monads最佳简介精选部分从Haskell翻译成JavaScript


其他回答

如果您曾经使用过Powershell,Eric描述的模式听起来应该很熟悉。Powershell cmdlet是monad;功能组成由管道表示。

杰弗里·斯诺弗对埃里克·梅杰的采访更为详细。

看到我对“什么是monad?”的回答了吗

它从一个激励性的例子开始,通过这个例子,导出一个monad的例子,并正式定义“monad”。

它假设不了解函数式编程,并且使用带有函数(参数):=表达式语法的伪代码和最简单的表达式。

这个C++程序是伪代码monad的一个实现。(仅供参考:M是类型构造函数,feed是“绑定”操作,wrap是“返回”操作。)

#include <iostream>
#include <string>

template <class A> class M
{
public:
    A val;
    std::string messages;
};

template <class A, class B>
M<B> feed(M<B> (*f)(A), M<A> x)
{
    M<B> m = f(x.val);
    m.messages = x.messages + m.messages;
    return m;
}

template <class A>
M<A> wrap(A x)
{
    M<A> m;
    m.val = x;
    m.messages = "";
    return m;
}

class T {};
class U {};
class V {};

M<U> g(V x)
{
    M<U> m;
    m.messages = "called g.\n";
    return m;
}

M<T> f(U x)
{
    M<T> m;
    m.messages = "called f.\n";
    return m;
}

int main()
{
    V x;
    M<T> m = feed(f, feed(g, wrap(x)));
    std::cout << m.messages;
}

按照OOP程序员将理解(没有任何功能编程背景),什么是莫纳德?它解决了什么问题是最常用的地方吗?是最常用的地方吗?

就OO编程而言,monad是一个接口(或者更可能是一个mixin),由一个类型参数化,具有两个方法,return和bind,它们描述:

如何注入值以获得注入值的一元值类型如何使用从非一元值。

它解决的问题与您期望的任何接口的问题类型相同,“我有很多不同的类,它们做不同的事情,但似乎以一种具有潜在相似性的方式来做这些不同的事情。即使这些类本身不是比‘Object’类本身更接近的子类,我如何描述它们之间的相似性?”

更具体地说,Monad“接口”与IEnumerator或IIterator相似,因为它采用的类型本身也采用的类型。然而,Monad的主要“点”是能够连接基于内部类型的操作,甚至可以连接到具有新的“内部类型”的点,同时保持-甚至增强-主类的信息结构。

monad是一种封装值的数据类型,本质上可以对其应用两个操作:

返回x创建封装x的monad类型的值m>>=f(读作“绑定运算符”)将函数f应用于monad m中的值

这就是monad。还有一些技术问题,但基本上这两个操作定义了monad。真正的问题是,“monad做什么?”,这取决于monad-列表是monad,Maybes是monad;IO操作是monad。当我们说这些东西是monad时,这意味着它们具有返回和>>=的monad接口。

为了尊重快速阅读者,我首先从精确的定义开始,继续快速的“简单的英语”解释,然后转到示例。

这里有一个简洁而精确的定义,稍微改写了一下:

monad(在计算机科学中)是一个正式的地图,它:将某些给定编程语言的每个类型X发送到一个新的类型T(X)(称为“值为X的T计算类型”);配备有一个规则,用于组合表单的两个功能f: X->T(Y)和g:Y->T(Z)到函数g∘f:X->T;以一种在明显意义上是关联的并且与称为pure_X:X->T(X)的给定单位函数是单位的方式,可以被认为是将一个值带到纯计算,而纯计算只是返回该值。

因此,简单地说,monad是从任何类型X传递到另一类型T(X)的规则,也是从两个函数f:X->T(Y)和g:Y->T(Z)(您想合成但不能合成)传递到新函数h:X->T(Z)的规则。然而,这并不是严格数学意义上的作文。我们基本上是在“弯曲”函数的组成或重新定义函数的组成方式。

此外,我们需要monad的合成规则来满足“显而易见”的数学公理:

联想性:先用g合成f,然后用h(从外部)合成,应该与先用h合成g,然后用f(从内部)合成相同。酉性质:用任意一侧的单位函数合成f应得到f。

同样,简单地说,我们不能随心所欲地重新定义函数组合:

我们首先需要关联性,以便能够在一行中组合多个函数,例如f(g(h(k(x))),而不用担心指定组合函数对的顺序。由于monad规则只规定了如何组成一对函数,如果没有这个公理,我们需要知道哪个函数对是首先组成的,依此类推。第二,我们需要单位性质,也就是简单地说,恒等式以我们期望的方式构成。因此,只要可以提取这些标识,我们就可以安全地重构函数。

简单地说:monad是类型扩展和组合函数的规则,满足两个公理——结合性和单位性质。

实际上,您希望monad由负责编写函数的语言、编译器或框架为您实现。因此,您可以专注于编写函数的逻辑,而不用担心它们的执行是如何实现的。

简而言之,这就是它的本质。


作为一名职业数学家,我倾向于避免将h称为f和g的“组合”,因为在数学上,它不是。将其称为“组成”错误地假定h是真正的数学组成,但它不是。它甚至不是由f和g唯一决定的,而是我们的monad新的“组成函数的规则”的结果。这可能与实际的数学组成完全不同,即使后者存在!


为了使它不那么干燥,让我试着用例子来说明我正在用小部分进行注释,因此您可以直接跳到要点。

作为Monad示例引发异常

假设我们要组成两个函数:

f: x -> 1 / x
g: y -> 2 * y

但未定义f(0),因此引发异常e。那么如何定义组成值g(f(0))?当然,再次抛出异常!可能是相同的e.可能是新更新的异常e1。

这里到底发生了什么?首先,我们需要新的异常值(不同或相同)。你可以称它们为nothing或null或其他任何东西,但本质保持不变——它们应该是新的值,例如,在我们这里的示例中,它不应该是数字。我宁愿不将它们称为null,以避免混淆null如何在任何特定语言中实现。同样,我更倾向于避免任何事情,因为它经常与null联系在一起,原则上,这是null应该做的,然而,无论出于什么实际原因,这个原则经常会被扭曲。

例外到底是什么?

这对任何有经验的程序员来说都是一件小事,但我想说几句话来消除任何困惑:

异常是一个封装有关执行的无效结果如何发生的信息的对象。

这可以包括丢弃任何细节并返回单个全局值(如NaN或null),或者生成一个长日志列表或发生了什么,将其发送到数据库并在分布式数据存储层上进行复制;)

这两种极端例外情况之间的重要区别在于,在第一种情况下,没有副作用。第二个是。这就引出了(千美元)问题:

纯函数中是否允许异常?

简短的回答:是的,但前提是它们不会导致副作用。

更长的答案。为了保持纯粹,函数的输出必须由其输入唯一确定。因此,我们通过将0发送到我们称为异常的新抽象值e来修改函数f。我们确保值e不包含外部信息,这些信息不是由我们的输入(即x)唯一确定的。因此,这里是一个没有副作用的异常示例:

e = {
  type: error, 
  message: 'I got error trying to divide 1 by 0'
}

这里有一个副作用:

e = {
  type: error, 
  message: 'Our committee to decide what is 1/0 is currently away'
}

事实上,只有当这种信息在未来可能改变时,它才会产生副作用。但如果它被保证永远不会改变,那么这个值就会变得唯一可预测,因此不会有副作用。

让它更傻。返回42的函数显然是纯的。但如果有人疯狂地决定将42作为一个变量,这个值可能会改变,那么在新的条件下,同样的函数就不再是纯函数。

注意,为了简单起见,我使用了对象文字符号来演示其本质。不幸的是,在JavaScript这样的语言中,错误并不是一种我们想要的函数组合方式,而像null或NaN这样的实际类型并不是这种方式,而是经过一些人工的、不总是直观的类型转换。

类型扩展名

当我们想要改变异常内部的消息时,我们实际上是在为整个异常对象声明一个新的类型E,然后这就是may数的作用,除了它令人困惑的名称,它要么是类型number,要么是新的异常类型E,所以它实际上是number和E的并数|E。特别是,它取决于我们想要如何构造E,这既不是建议的,也不是反映在名称may数中。

什么是功能成分?

这是取函数的数学运算f: X->Y和g:Y->Z和构造它们的组成作为满足h(X)=g(f(X))的函数h:X->Z。当结果f(x)不允许作为g的参数时,就会出现这个定义的问题。

在数学中,没有额外的工作,这些函数是无法合成的。对于我们上面的f和g的例子,严格的数学解决方案是从f的定义集合中删除0。有了新的定义集合(新的更严格的x类型),f变得可以与g组合。

然而,在编程中这样限制f的定义集是不太实际的。相反,可以使用异常。

或者作为另一种方法,人工值被创建为NaN、undefined、null、Infinity等。因此,您可以计算1/0到Infinity和1/-0到Infinity。然后将新值强制返回到表达式中,而不是引发异常。导致您可能会或可能无法预测的结果:

1/0                // => Infinity
parseInt(Infinity) // => NaN
NaN < 0            // => false
false + 1          // => 1

我们又回到了常规数字,准备继续前进;)

JavaScript允许我们以任何代价继续执行数值表达式,而不会像上面的示例那样抛出错误。这意味着,它还允许组合函数。这正是monad的意义所在——这是一条规则,可以组成满足本答案开头定义的公理的函数。

但是,由JavaScript处理数字错误的实现所产生的组成函数的规则是一个monad吗?

要回答这个问题,你只需要检查公理(这里作为练习而不是问题的一部分;)。

抛出异常可以用于构造monad吗?

事实上,一个更有用的monad应该是规定如果f对某个x抛出异常,那么它与任何g的组合也是如此。Plus使异常E全局唯一,只有一个可能的值(范畴理论中的终端对象)。现在这两个公理可以立即检查,我们得到了一个非常有用的monad。其结果就是众所周知的可能是莫纳德。