协慌网

登录 贡献 社区

Monad 用简单的英语? (对于没有 FP 背景的 OOP 程序员)

用 OOP 程序员会理解的术语(没有任何函数式编程背景),什么是 monad?

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

编辑:

为了阐明我正在寻找的理解类型,假设您正在将具有 monad 的 FP 应用程序转换为 OOP 应用程序。您将如何将 monad 的责任移植到 OOP 应用程序?

答案

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

用 OOP 程序员会理解的术语(没有任何函数式编程背景),什么是 monad?

monad 是一种类型“放大器”,遵循某些规则提供某些操作

首先,什么是 “类型放大器”?我的意思是说,有一个系统可以让您选择一种类型并将其转换为更特殊的类型。例如,在 C#中考虑Nullable<T> 。这是一种放大器。它使您可以使用一个类型,例如int ,并为该类型添加新功能,即现在可以在以前无法使用时为 null。

作为第二个示例,请考虑IEnumerable<T> 。它是一种类型的放大器。它允许您采用一种类型,例如string ,并为该类型添加新功能,即,您现在可以从任意数量的单个字符串中组成一系列字符串。

什么是 “某些规则”?简而言之,存在一种合理的方式,使基础类型上的功能在放大类型上起作用,以使它们遵循功能组成的正常规则。例如,如果您有一个整数函数,请说

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

那么Nullable<int>上的相应函数可以使所有运算符和其中的调用 “像以前一样” 一起工作。

(这是非常模糊和不精确的;您要求提供一种解释,该解释没有假设任何有关功能组成的知识。)

什么是 “操作”?

  1. 有一个 “单位” 操作(有时也称为 “返回” 操作),该操作从普通类型获取值并创建等效的单价值。本质上,这提供了一种获取未放大类型的值并将其转换为放大类型的值的方法。可以将其实现为 OO 语言的构造函数。

  2. 有一个 “绑定” 操作,它接受一个单子值和一个可以转换该值并返回新的单子值的函数。绑定是定义 monad 语义的关键操作。它使我们可以将未放大类型的操作转换为对放大类型的操作,这要遵循前面提到的功能组成规则。

  3. 通常,有一种方法可以使未放大类型从放大类型中退回。严格来说,此操作不需要具有 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 - 在转型抓获。我们可以对此进行概括。

假设您有一个从intint的函数,就像我们原来的M 。您可以轻松地使它成为一个接受int并返回Nullable<int>的函数,因为您可以通过可为 null 的构造函数运行结果。现在假设您具有以下高阶方法:

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或采用int并返回Nullable<int>都可以对其应用 nullable 语义

此外:假设您有两种方法

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

而您想组成它们:

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

也就是说, ZXY 。但是您不能这样做,因为X接受一个int ,而Y返回一个Nullable<int> 。但是,由于您具有 “绑定” 操作,因此可以进行以下工作:

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

对单声道的绑定操作使放大类型上的功能组合起作用。我上面挥挥手的 “规则” 是单子保留正常功能组成的规则。与身份函数组成的结果将产生原始功能,该组成具有关联性,依此类推。

在 C#中,“绑定” 称为 “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;            
}

可为空的 monad 规则是 “将产生可为空的两个函数组合在一起,检查内部函数是否为 null;如果满足,则产生 null,否则为 null,然后用结果调用外部函数”。这是可为空的所需语义。

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

我们可以做得更好。假设您有一个整数序列,以及一个采用整数并产生字符串序列的方法。我们可以对绑定操作进行一般化,以允许接受和返回不同放大类型的函数的组合,只要一个的输入与另一个的输出匹配即可:

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。如前所述,可为空的模式与 “也许单子” 非常相似。 LINQ 完全由 monad 组成。 SelectMany方法是操作组合的语义工作。 (Erik Meijer 喜欢指出,每个 LINQ 函数实际上都可以由SelectMany实现;其他一切只是为了方便。)

为了阐明我正在寻找的理解类型,假设您正在将具有 monad 的 FP 应用程序转换为 OOP 应用程序。您将如何将 Monad 的责任移植到 OOP 应用程序中?

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

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


建议进一步阅读:

  1. 有关 C#中 monad 的更深入和理论上合理的解释,我强烈建议我( Eric Lippert的同事)Wes Dyer 关于该主题的文章。本文是 monads 最终为我 “点击” 时向我解释的内容。
  2. 很好地说明了为什么您可能想要一个 monad (在示例中使用 Haskell)
  3. 上一篇文章对 JavaScript 的 “翻译”。

为什么我们需要单子?

  1. 我们只想使用函数进行编程。 (毕竟 - FP 是 “功能性编程”)。
  2. 然后,我们遇到了第一个大问题。这是一个程序:

    f(x) = 2 * x

    g(x,y) = x / y

    我们如何说要先执行什么?我们如何仅使用函数就可以形成一个有序的函数序列(即程序 )?

    解决方案: 编写函数 。如果要先g然后是f ,则只需写f(g(x,y)) 。好的但是 ...

  3. 更多问题:某些函数可能会失败 (即g(2,0) ,除以 0)。 FP 中没有 “例外” 。我们该如何解决?

    解决方案:让函数返回两种东西 :让g : Real,Real -> Real | Nothing代替g : Real,Real -> Real (从两个实数转换为实数的函数) g : Real,Real -> Real | Nothing (从两个实数转换为(实数或无数)的功能)。

  4. 但是函数应该(为了简化)只返回一件事

    解决方案:让我们创建一种新的要返回的数据类型,即 “ 装箱类型 ”,其中可能包含实数,也可能根本没有任何东西。因此,我们可以有g : Real,Real -> Maybe Real 。好的但是 ...

  5. 现在f(g(x,y))什么? f尚未准备好消耗Maybe Real 。而且,我们不想更改可以与g连接的每个函数来消耗Maybe Real

    解决方案:让我们有一个特殊的功能来 “连接” /“组合” /“链接” 功能 。这样,我们可以在幕后调整一项功能的输出以提供下一项功能。

    在我们的例子中: g >>= f (将g连接 / 组成f )。我们希望>>=得到g的输出,检查它,如果它为Nothing ,则不调用f并返回Nothing ;或相反,提取装箱的Real并用它输入f 。 (此算法只是Maybe类型的>>=的实现)。

  6. 使用此相同的模式可以解决许多其他问题:1. 使用 “框” 来整理 / 存储不同的含义 / 值,并具有诸如g函数来返回这些 “框值”。 2. 使用作曲者 / 链接者g >>= f来帮助将g的输出连接到f的输入,因此我们根本不必更改f

  7. 使用此技术可以解决的显着问题是:

    • 具有全局状态,函数序列中的每个函数(“程序”)可以共享:解决方案StateMonad

    • 我们不喜欢 “不纯函数”:对于相同输入产生不同输出的函数。因此,让我们标记这些函数,使其返回标记 / 装箱的值: IO monad。

完全幸福!

我会说最接近于 monad 的 OO 类比是 “ 命令模式 ”。

在命令模式中,将普通语句或表达式包装在命令对象中。该命令对象公开了一个执行方法,该方法执行包装的语句。因此,语句变成了可以随意传递和执行的一流对象。可以组合命令,因此您可以通过链接和嵌套命令对象来创建程序对象。

这些命令由单独的对象( 调用程序)执行 。使用命令模式(而不是仅执行一系列普通语句)的好处是,不同的调用者可以对应如何执行命令应用不同的逻辑。

该命令模式可用于添加(或删除)宿主语言不支持的语言功能。例如,在无例外的假设 OO 语言中,可以通过向命令公开 “try” 和 “throw” 方法来添加例外语义。当命令调用 throw 时,调用者将在命令列表(或树)中回溯,直到最后一次 “try” 调用为止。相反,您可以通过捕获每个命令抛出的所有异常并将其转换为错误代码,然后将其传递给下一个命令,从某种语言中删除异常语义(如果您认为异常不好 )。

像这样的更加花哨的执行语义,例如事务,非确定性执行或连续性,都可以用本机不支持的语言来实现。如果您考虑一下,这是一个非常强大的模式。

现在,实际上,命令模式并未像这样被用作通用语言功能。将每个语句转换为单独的类的开销将导致大量的样板代码。但是从原理上讲,它可以解决与 monad 用于解决 fp 相同的问题。