在C#中,什么是Monad?

208

现在关于单子(monads)的讨论非常多。我读了一些文章/博客帖子,但是它们的例子没法深入理解概念。原因是单子是函数式语言的概念,而这些例子都是使用我没有深入使用过的语言编写的(因为我没有深入使用过函数式语言)。我无法深入理解语法以完全跟上文章……但我能感觉到那里有值得理解的东西。

然而,我很了解C#,包括lambda表达式和其他函数式特性。我知道C#只具有函数式特性的子集,所以也许无法用C#表达单子。

但是,肯定可以传达其概念吧?至少这么希望。也许你可以提供一个C#示例作为基础,然后描述一个C#开发人员会希望从那里开始做什么但不能做什么,因为该语言缺乏函数式编程特性。这将是很棒的,因为它可以传达单子的意图和好处。所以这是我的问题:您能向C# 3开发人员提供最好的单子解释吗?

谢谢!

(编辑:顺便说一句,我知道至少有3个“什么是单子”问题已经在SO上了。但是我面临着同样的问题……所以我认为这个问题非常必要,因为它专注于C#开发者。谢谢。)


4
值得一提的是,LINQ 查询表达式是 C# 3 中单子行为的一个例子。 - Erik Forbes
1
我仍然认为这是一个重复的问题。http://stackoverflow.com/questions/2366/can-anyone-explain-monads 中的一个答案链接到 http://channel9vip.orcsweb.com/shows/Going+Deep/Brian-Beckman-Dont-fear-the-Monads/,其中一个评论有一个非常好的C#示例。 :) - jalf
5
不过,那只是来自一个答案中的一个SO问题的链接。我认为这个问题对于C#开发人员很有价值。如果我认识一个曾经使用C#的函数式程序员,我会问他这个问题,所以在SO上提出这个问题似乎是合理的。但我也尊重你的意见。 - Charlie Flowers
1
只需要一个答案就够了吧?;) 我的观点只是其他问题之一(现在这个问题也是,所以耶),有一个特定于C#的答案(实际上写得非常好,可能是我见过的最好的解释) - jalf
任何具有C#背景(或任何面向对象的背景)的人都不应错过Eric Lippert的优秀文章,以了解Monad。 - Bruno Brant
显示剩余5条评论
5个回答

161

在编程中,你每天做的大部分工作都是将一些函数组合在一起,以便从它们中构建更大的函数。通常你不仅在你的工具箱中拥有函数,还有其他东西,比如运算符、变量赋值等等,但一般来说,你的程序将许多"计算"组合成更大的计算,这些计算将进一步组合在一起。

单子(Monad)就是某种实现“组合计算”的方式。

通常,将两个计算组合在一起的最基本的“运算符”是;

a; b
当您说这个时,意思是“先执行a,然后再执行b”。结果a; b基本上又是一个可以与更多内容组合在一起的计算。
这是一个简单的单子,它是将小计算组合成大计算的一种方式。;表示“先做左边的事情,然后做右边的事情”。
面向对象语言中另一个可以看作单子的东西是.。通常您会发现像这样的东西:
a.b().c().d()
.基本上意味着“对左侧的计算进行求值,然后在该结果上调用右侧的方法”。这是另一种将函数/计算组合在一起的方式,比;略微复杂。使用.链接事物的概念是一个单子(monad),因为它是一种将两个计算合并到一个新计算中的方式。
另一个相当常见的单子,没有特殊的语法,是这种模式:
rv = socket.bind(address, port);
if (rv == -1)
  return -1;

rv = socket.connect(...);
if (rv == -1)
  return -1;

rv = socket.send(...);
if (rv == -1)
  return -1;

返回值为-1表示失败,但实际上没有真正的方法来抽象化这种错误检查,即使您需要将许多API调用组合在一起。这基本上只是另一个通过规则 "如果左侧函数返回-1,则我们自己返回-1,否则调用右侧函数" 来组合函数调用的单子。如果我们有一个运算符 >>= 来执行此操作,我们可以简单地写成:

socket.bind(...) >>= socket.connect(...) >>= socket.send(...)

使用这种方式可以使得代码更易读,同时也有助于抽象化我们特殊的函数组合方式,这样我们就不需要一遍又一遍地重复自己。

还有很多其他有用的函数/计算组合方式,可以作为一般模式进行抽象化,并且可以在单子中完成所有已使用函数的管理和记录,从而使单子的用户能够编写更加简洁和清晰的代码。

例如,上述的 >>= 可以扩展到"进行错误检查,然后在输入的 socket 上调用右侧的操作",这样我们就不需要显式地指定 socket 很多次了:

new socket() >>= bind(...) >>= connect(...) >>= send(...);

正式定义稍微有点复杂,因为您需要考虑如何将一个函数的结果作为下一个函数的输入,如果该函数需要该输入,并且您希望确保您组合的函数符合您在单子中尝试组合它们的方式。但是基本概念就是您可以规范不同的组合函数的方式。


31
好的回答!我会引用Oliver Steele的一句话,试图将单子(Monads)与C++或C#中的运算符重载联系起来:单子允许您重载 ';' 运算符。 - Jörg W Mittag
9
@JörgWMittag,我以前读过那句话,但感觉很晦涩难懂。现在我理解了单子并阅读了这个“;”是单子的解释,我明白了。但我认为对于大多数命令式开发者来说,这是一个不合理的说法。对于大多数人来说,“;”不再被视为运算符,就像“//”一样。 - Jimmy Hoffa
2
你确定你知道单子是什么吗?单子不是一个“函数”或计算,它有自己的规则。 - Luis
在你的 ; 的例子中:; 映射了哪些对象/数据类型?(类比于 ListT 映射为 List<T> 如何映射对象/数据类型之间的态射/函数?对于 ;purejoinbind 是什么? - Micha Wiedenmann
分号(;)映射语句,即按顺序应用thunks。 - Sandra

48

我发表这个问题已经一年了。在发表后的几个月里,我深入研究了Haskell。我非常喜欢它,但是当我准备深入研究单子时,我把它放在了一边。我回到工作中,专注于我的项目所需的技术。

昨晚,我回来重新阅读了这些回答。最重要的是,我重新阅读了Brian Beckman视频某人在上面提到的具体C#示例的文本评论。它非常清晰和启迪性,以至于我决定直接在这里发布它。

因为这个评论,我不仅感觉自己完全理解了单子……我意识到我实际上已经用C#写了一些单子……或者至少非常接近,并且正在努力解决同样的问题。

那么,这是评论 - 这一切都是sylvan此处的评论中的直接引语:

This is pretty cool. It's a bit abstract though. I can imagine people who don't know what monads are already get confused due to the lack of real examples.

So let me try to comply, and just to be really clear I'll do an example in C#, even though it will look ugly. I'll add the equivalent Haskell at the end and show you the cool Haskell syntactic sugar which is where, IMO, monads really start getting useful.

Okay, so one of the easiest Monads is called the "Maybe monad" in Haskell. In C# the Maybe type is called Nullable<T>. It's basically a tiny class that just encapsulates the concept of a value that is either valid and has a value, or is "null" and has no value.

A useful thing to stick inside a monad for combining values of this type is the notion of failure. I.e. we want to be able to look at multiple nullable values and return null as soon as any one of them is null. This could be useful if you, for example, look up lots of keys in a dictionary or something, and at the end you want to process all of the results and combine them somehow, but if any of the keys are not in the dictionary, you want to return null for the whole thing. It would be tedious to manually have to check each lookup for null and return, so we can hide this checking inside the bind operator (which is sort of the point of monads, we hide book-keeping in the bind operator which makes the code easier to use since we can forget about the details).

Here's the program that motivates the whole thing (I'll define the Bind later, this is just to show you why it's nice).

 class Program
    {
        static Nullable<int> f(){ return 4; }        
        static Nullable<int> g(){ return 7; }
        static Nullable<int> h(){ return 9; }


        static void Main(string[] args)
        {
            Nullable<int> z = 
                        f().Bind( fval => 
                            g().Bind( gval => 
                                h().Bind( hval =>
                                    new Nullable<int>( fval + gval + hval ))));

            Console.WriteLine(
                    "z = {0}", z.HasValue ? z.Value.ToString() : "null" );
            Console.WriteLine("Press any key to continue...");
            Console.ReadKey();
        }
    }

Now, ignore for a moment that there already is support for doing this for Nullable in C# (you can add nullable ints together and you get null if either is null). Let's pretend that there is no such feature, and it's just a user-defined class with no special magic. The point is that we can use the Bind function to bind a variable to the contents of our Nullable value and then pretend that there's nothing strange going on, and use them like normal ints and just add them together. We wrap the result in a nullable at the end, and that nullable will either be null (if any of f, g or h returns null) or it will be the result of summing f, g, and h together. (this is analogous of how we can bind a row in a database to a variable in LINQ, and do stuff with it, safe in the knowledge that the Bind operator will make sure that the variable will only ever be passed valid row values).

You can play with this and change any of f, g, and h to return null and you will see that the whole thing will return null.

So clearly the bind operator has to do this checking for us, and bail out returning null if it encounters a null value, and otherwise pass along the value inside the Nullable structure into the lambda.

Here's the Bind operator:

public static Nullable<B> Bind<A,B>( this Nullable<A> a, Func<A,Nullable<B>> f ) 
    where B : struct 
    where A : struct
{
    return a.HasValue ? f(a.Value) : null;
}

The types here are just like in the video. It takes an M a (Nullable<A> in C# syntax for this case), and a function from a to M b (Func<A, Nullable<B>> in C# syntax), and it returns an M b (Nullable<B>).

The code simply checks if the nullable contains a value and if so extracts it and passes it onto the function, else it just returns null. This means that the Bind operator will handle all the null-checking logic for us. If and only if the value that we call Bind on is non-null then that value will be "passed along" to the lambda function, else we bail out early and the whole expression is null. This allows the code that we write using the monad to be entirely free of this null-checking behaviour, we just use Bind and get a variable bound to the value inside the monadic value (fval, gval and hval in the example code) and we can use them safe in the knowledge that Bind will take care of checking them for null before passing them along.

There are other examples of things you can do with a monad. For example you can make the Bind operator take care of an input stream of characters, and use it to write parser combinators. Each parser combinator can then be completely oblivious to things like back-tracking, parser failures etc., and just combine smaller parsers together as if things would never go wrong, safe in the knowledge that a clever implementation of Bind sorts out all the logic behind the difficult bits. Then later on maybe someone adds logging to the monad, but the code using the monad doesn't change, because all the magic happens in the definition of the Bind operator, the rest of the code is unchanged.

Finally, here's the implementation of the same code in Haskell (-- begins a comment line).

-- Here's the data type, it's either nothing, or "Just" a value
-- this is in the standard library
data Maybe a = Nothing | Just a

-- The bind operator for Nothing
Nothing >>= f = Nothing
-- The bind operator for Just x
Just x >>= f = f x

-- the "unit", called "return"
return = Just

-- The sample code using the lambda syntax
-- that Brian showed
z = f >>= ( \fval ->
     g >>= ( \gval ->  
     h >>= ( \hval -> return (fval+gval+hval ) ) ) )

-- The following is exactly the same as the three lines above
z2 = do 
   fval <- f
   gval <- g
   hval <- h
   return (fval+gval+hval)

As you can see the nice do notation at the end makes it look like straight imperative code. And indeed this is by design. Monads can be used to encapsulate all the useful stuff in imperative programming (mutable state, IO etc.) and used using this nice imperative-like syntax, but behind the curtains, it's all just monads and a clever implementation of the bind operator! The cool thing is that you can implement your own monads by implementing >>= and return. And if you do so those monads will also be able to use the do notation, which means you can basically write your own little languages by just defining two functions!


3
就我个人而言,我更喜欢 F# 版本的单子,但无论哪种情况下它们都很棒。 - ChaosPandion
3
感谢您回到这里并更新您的帖子。这些跟进内容有助于正在研究特定领域的程序员真正了解同行们对该领域的看法,而不仅仅是依靠“我如何在某项技术中做x”的问题。你真棒! - kappasims
我基本上走了你走过的路,最终理解了单子,话虽如此,这是我见过的最好的关于命令式开发者单子绑定行为的解释。尽管我认为你没有完全涉及单子的所有内容,这在上面有更详细的解释。 - Jimmy Hoffa
@Jimmy Hoffa - 毫无疑问,你是正确的。我认为要更深入地理解它们,最好的方法是开始大量使用它们并获得经验。我还没有这个机会,但我希望很快能够做到。 - Charlie Flowers
在编程方面,单子似乎只是更高层次的抽象,或者它只是数学中连续且不可微分的函数定义。无论哪种方式,它们都不是新概念,特别是在数学中。 - liang
现在几乎所有的链接都失效了,很遗憾。 - Gert Arnold

11

单子(Monad)本质上是延迟处理。如果您在一种不允许有副作用(例如I/O)的语言中编写有副作用的代码,并且只允许纯计算,那么一个技巧就是说:“好吧,我知道您不会为我执行副作用,但是否可以请您计算一下如果执行了副作用会发生什么?”

这有点像作弊。

现在,这个解释将帮助您理解单子的大局意图,但细节才是关键。如何精确地计算后果呢?有时候,这并不美丽。

向习惯于命令式编程的人概述如何做到这一点的最佳方法是说,它将您置于DSL中,在其中使用外部单子看起来语法类似的操作来构建一个函数,如果您可以(例如)写入输出文件,它将执行您想要的操作。几乎(但并不真正)就像在字符串中构建代码以便稍后进行eval。


1
就像《我,机器人》这本书中的情节一样?科学家让计算机计算太空旅行并要求它们跳过某些规则?:):):):) - OscarRyz
3
Monad 可以用于延迟处理和封装副作用函数,实际上这是它在 Haskell 中的第一个真正的应用,但它其实是一种更通用的模式。其他常见用途包括错误处理和状态管理。语法糖(Haskell 中的 do,F# 中的 Computation Expressions,C# 中的 Linq 语法)仅仅是为了方便使用 Monad 而已,而不是 Monad 的本质特征。 - Mike Hadlow
@MikeHadlow:对于错误处理(MaybeEither e)和状态管理(State sST s)而言,单子实例让我想到了“请计算如果您为我执行[副作用]会发生什么”的特定实例。另一个例子是非确定性([])。 - isekaijin
这是完全正确的;只有一个(好吧,两个)补充说明,它是一种E DSL,即嵌入式DSL,因为每个“单子”值都是您的“纯”语言本身的有效值,代表着潜在的不纯“计算”。此外,在您的纯语言中存在单子“绑定”结构,允许您链接这些值的纯构造函数,其中每个函数将使用其前面计算的结果进行调用,整个组合计算被“运行”时。这意味着我们有能力在未来结果上进行分支(或在任何情况下,独立的“运行”时间轴)。 - Will Ness
但对于程序员来说,这意味着我们可以在EDSL中编程,同时将其与纯语言的纯计算混合。一堆多层三明治是一个多层三明治。就是这么简单。 - Will Ness

1

你可以把单子(monad)看作是C#接口,类必须实现。这是一个实用的答案,忽略了所有范畴论数学背后的原因,以及为什么你想要在接口中声明这些内容和为什么你想要在试图避免副作用的语言中使用单子的所有原因,但对于理解(C#)接口的人来说,我认为这是一个很好的开始。


你能详细说明一下吗?接口与单子有什么关系? - Joel Coehoorn
2
我认为这篇博客文章花费了几个段落来探讨那个问题。 - hao

0

请查看我的答案,里面讲解了“什么是单子”。

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

它假设读者没有函数式编程的知识,并使用带有 function(argument) := expression 语法的伪代码及最简单的表达式。

这段 C# 程序是伪代码单子的一个实现。(参考: M 是类型构造器, feed 是“bind”操作,wrap 是“return”操作。)

using System.IO;
using System;

class Program
{
    public class M<A>
    {
        public A val;
        public string messages;
    }

    public static M<B> feed<A, B>(Func<A, M<B>> f, M<A> x)
    {
        M<B> m = f(x.val);
        m.messages = x.messages + m.messages;
        return m;
    }

    public static M<A> wrap<A>(A x)
    {
        M<A> m = new M<A>();
        m.val = x;
        m.messages = "";
        return m;
    }

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

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

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

    static void Main()
    {
        V x = new V();
        M<T> m = feed<U, T>(f, feed(g, wrap<V>(x)));
        Console.Write(m.messages);
    }
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接