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

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

使现代化

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


当前回答

如果您曾经使用过Powershell,Eric描述的模式听起来应该很熟悉。Powershell cmdlet是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。其结果就是众所周知的可能是莫纳德。

你最近有一篇演讲《Monadologie——关于类型焦虑的专业帮助》(Christopher League,2010年7月12日),这篇演讲对延续和monad的话题非常有趣。这个(幻灯片)演示的视频实际上可以在vimeo上获得。Monad部分开始于37分钟左右,在这段一小时的视频中,从58张幻灯片中的第42张幻灯片开始。

它被称为“函数式编程的主要设计模式”,但示例中使用的语言是Scala,它既是面向对象的又是函数式的。您可以在Debasish Ghosh(2008年3月27日)的博客文章“Monads-在Scala中抽象计算的另一种方法”中阅读更多关于Monad的内容。

如果类型构造函数M支持以下操作,那么它就是monad:

# the return function
def unit[A] (x: A): M[A]

# called "bind" in Haskell 
def flatMap[A,B] (m: M[A]) (f: A => M[B]): M[B]

# Other two can be written in term of the first two:

def map[A,B] (m: M[A]) (f: A => B): M[B] =
  flatMap(m){ x => unit(f(x)) }

def andThen[A,B] (ma: M[A]) (mb: M[B]): M[B] =
  flatMap(ma){ x => mb }

例如(在Scala中):

选项是monad

    def unit[A] (x: A): Option[A] = Some(x)

    def flatMap[A,B](m:Option[A])(f:A =>Option[B]): Option[B] =
      m match {
       case None => None
       case Some(x) => f(x)
      }

列表为Monad

    def unit[A] (x: A): List[A] = List(x)

    def flatMap[A,B](m:List[A])(f:A =>List[B]): List[B] =
      m match {
        case Nil => Nil
        case x::xs => f(x) ::: flatMap(xs)(f)
      }

Monad在Scala中非常重要,因为它是为了利用Monad结构而构建的方便语法:

对于Scala的理解:

for {
  i <- 1 to 4
  j <- 1 to i
  k <- 1 to j
} yield i*j*k

由编译器翻译为:

(1 to 4).flatMap { i =>
  (1 to i).flatMap { j =>
    (1 to j).map { k =>
      i*j*k }}}

关键抽象是flatMap,它通过链接绑定计算。flatMap的每次调用都返回相同的数据结构类型(但值不同),作为链中下一个命令的输入。

在上面的代码段中,flatMap将闭包(SomeType)=>List[AanotherType]作为输入,并返回List[Aanother Type]。需要注意的一点是,所有flatMap都采用相同的闭包类型作为输入,并返回与输出相同的类型。

这就是“绑定”计算线程的原因——为了理解,序列中的每一项都必须遵守相同的类型约束。


如果您执行两个操作(可能失败)并将结果传递给第三个,例如:

lookupVenue: String => Option[Venue]
getLoggedInUser: SessionID => Option[User]
reserveTable: (Venue, User) => Option[ConfNo]

但如果不利用Monad,你会得到复杂的OOP代码,比如:

val user = getLoggedInUser(session)
val confirm =
  if(!user.isDefined) None
  else lookupVenue(name) match {
    case None => None
    case Some(venue) =>
      val confno = reserveTable(venue, user.get)
      if(confno.isDefined)
        mailTo(confno.get, user.get)
      confno
  }

而使用Monad,您可以像所有操作一样使用实际类型(地点、用户),并隐藏选项验证内容,这都是因为for语法的平面图:

val confirm = for {
  venue <- lookupVenue(name)
  user <- getLoggedInUser(session)
  confno <- reserveTable(venue, user)
} yield {
  mailTo(confno, user)
  confno
}

只有当所有三个函数都具有Some[X]时,才会执行屈服部分;任何“无”将直接返回以确认。


So:

Monad允许在函数编程中进行有序计算,这允许我们以一种很好的结构化形式(有点像DSL)对动作序列进行建模。最大的能力来自于将服务于不同目的的monad组合成应用程序中的可扩展抽象的能力。monad对动作的排序和线程化由语言编译器完成,该语言编译器通过闭包的魔力进行转换。


顺便说一句,Monad不是FP中使用的唯一计算模型:

范畴理论提出了许多计算模型。其中计算的Arrow模型莫纳德计算模型计算的应用模型

典型用法中的Monad在功能上等同于过程编程的异常处理机制。

在现代过程语言中,在一系列语句周围放置一个异常处理程序,其中任何语句都可能引发异常。如果任何语句引发异常,则语句序列的正常执行将停止并转移到异常处理程序。

然而,函数式编程语言在哲学上避免了异常处理特性,因为它们的“goto”性质类似。函数编程的观点是,函数不应该有“副作用”,比如中断程序流的异常。

实际上,由于I/O的原因,在现实世界中不能排除副作用。函数式编程中的monad用于处理这一问题,方法是获取一组链式函数调用(其中任何一个都可能产生意外结果),并将任何意外结果转换为封装数据,这些数据仍然可以安全地通过剩余的函数调用。

控制流被保留,但意外事件被安全地封装和处理。

我想说,与monads最接近的OO类比是“命令模式”。

在命令模式中,将普通语句或表达式包装在命令对象中。命令对象公开执行包装语句的执行方法。所以,语句被转换为可以随意传递和执行的第一类对象。可以组合命令,以便通过链接和嵌套命令对象来创建程序对象。

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

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

甚至更花哨的执行语义(如事务、非确定性执行或延续)也可以用本机不支持的语言实现。如果你仔细想想,这是一个非常强大的模式。

实际上,命令模式并没有像这样作为通用语言特性使用。将每个语句转换为单独的类的开销将导致无法忍受的样板代码。但原则上,它可以用于解决与在fp中使用monad解决的问题相同的问题。

可选/可能是最基本的一元类型

单子是关于功能组成的。如果函数f:可选<A>->可选<B>,g:可选<B>->可选<C>,h:可选<C>->可选<D>。然后你可以创作它们

optional<A> opt;
h(g(f(opt)));

monad类型的好处是,您可以改为组合f:A->可选<B>、g:B->可选<C>、h:C->可选<D>。他们可以这样做,因为monadic接口提供了绑定运算符

auto optional<A>::bind(A->optional<B>)->optional<B>

并且可以写作文

optional<A> opt
opt.bind(f)
   .bind(g)
   .bind(h)

monads的好处是我们不再需要处理if(!opt)return nullopt的逻辑;在f、g、h中的每一个中,因为该逻辑被移动到绑定运算符中。

ranges/lists/iterables是第二种最基本的monad类型。

范围的一元特征是我们可以变换然后变平,即从一个整数范围内编码的表示开始[36,98]

我们可以转换为[[m','a','c','h','i','n','e',''],['l','','r','n','i','n','g','.']]

然后压平[am','a','c','h','i','n','e','l',''e'

而不是编写此代码

vector<string> lookup_table;
auto stringify(vector<unsigned> rng) -> vector<char>
{
    vector<char> result;
    for(unsigned key : rng)
       for(char ch : lookup_table[key])
           result.push_back(ch);
       result.push_back(' ')
    result.push_back('.')
    return result
}

我们可以写这个

auto f(unsigned key) -> vector<char>
{
    vector<char> result;
    for(ch : lookup_table[key])
        result.push_back(ch);
    return result
}
auto stringify(vector<unsigned> rng) -> vector<char>
{
    return rng.bind(f);
}

monad将for循环(无符号键:rng)向上推到绑定函数中,从而允许理论上更容易推理的代码。毕达哥拉斯三元组可以在范围-v3中使用嵌套绑定生成(而不是我们看到的可选的链式绑定)

auto triples =
  for_each(ints(1), [](int z) {
    return for_each(ints(1, z), [=](int x) {
      return for_each(ints(x, z), [=](int y) {
        return yield_if(x*x + y*y == z*z, std::make_tuple(x, y, z));
      });
    });
  });