Monads(三)

在这个系列中我们将会从范畴学理论或函数式编程语言的实践开始,尝试通过分析各种模式来自底向上的了解"Monads",在上一篇中我指出了五个经常在C#中被用到的类型,今天我们试着找找他们除了泛型之外还有那些共性

在我们开始之前,我要提出三点以便于理解并提高本文的清晰度:

  1. 上次我提到了一个泛型委托(generic delegate type)Func<T>用于表示一个可以"随机应变"的T类型的值。这里有一个小小的问题,就是当我们开始从高阶函数的角度来探讨Monads性质的时候,我需要使用Func<A, B>来表示一个一元函数,而这将会和Func<T>产生歧义,所以从现在开始我会假设我们有一个这样的泛型委托:delegate T OnDemand<T>();并将其作为Monads类型的示例之一(dc注: 不能把OnDemand<T>当成Func<T>Func<T>可以表述一个生成任意T的函数,而OnDemand<T>表示的是在自身被调用的那一刻才能"随机应变"的产生一个独一无二的值的函数)

  2. 我注意到上次们讲到了由于历史原因导致Nullable<T>的泛型参数必须是不可空的值类型(non-nullable value types),在接下来的文章中我将会忽略这一点并假设Nullable<T>可以用在任何类型上

  3. 在接下来的文章中我还会忽略掉Lazy<T>拥有不同的线程安全模式(thread safety mode),也就是忽略使用旧的Lazy<T>来生成新的应当继承保留旧的线程安全模式这一点(dc注: 详见msdn)。

说清楚上面的三点以后我们就可以开始了,首先我要提醒一下,所有这五个类型(Nullable<T>Lazy<T>OnDemand<T>IEnumerable<T>Task<T>)都会或多或少的生产一些值(produce values): 可空类型也许不会生产值,而序列类型也许生产一大堆值,但无论如何,生产出来的这些值都遵循泛型参数,也就是说这五个类型在某种意义上可以被看成一个围绕着其内部的数个值的"包装类型"。事实上我们可以更进一步:只要你拥有一个符合上述类型的泛型参数的值,你就可以很轻松的创建一个可以产生该值的包装类型: (如果你对于下面代码不理解,请前往msdn自行查阅相关语法和关键字)

    static Nullable<T> CreateSimpleNullable<T>(T item)
    {
        return new Nullable<T>(item);
    }

    static OnDemand<T> CreateSimpleOnDemand<T>(T item)
    {
        return () => item;
    }

    static IEnumerable<T> CreateSimpleSequence<T>(T item)
    {
        yield return item;
    }

我们把生成Lazy<T>Task<T>的包装方法作为课后作业

事实上,上述代码就是我们向Monad Pattern迈出的第一步:如果一个M<T>是monadic(可以理解为"Monad风格的类型,比如上文所述的五种类型")的,那么一定会有一个简单的方法可以把任意一个T转换为M<T>

现在你也许会自信满满的说"第二步一定是'必须也能用简单的方法将其转换回去'",大致正确,但是仍有瑕疵。我们的第二步将会更加微妙,为此我将会谨慎的一点点去探究这一步;从一个简单的问题开始:你可以给一个int加一,那么,你该如何给一个包装了int的monadic类型"加一"呢?我们在之前的数个章节(dc注: 这是另一个系列,以后也会翻译)中说过,C#编译器本身已经知道了如何对一个Nullable<int>这么做,这项特性已经集成在了语言中,你只需要直接输入"+ 1"而不用管剩下的,编译器会帮你处理好一切事情。然而我们必须知道这个过程该如何实现,下面的代码也许不那么简洁,但是清晰的阐述了每一步:

    static Nullable<int> AddOne(Nullable<int> nullable)
    {
        if (nullable.HasValue)
        {
            int unwrapped = nullable.Value;
            int result = unwrapped + 1;
            return CreateSimpleNullable<int>(result);
        }
        else
        {
            return new Nullable<int>();
        }
    }

注意一点:对一个Nullable<int>加一得到的一定是另一个Nullable<int>,因为原始的值可能是null。不过这个过程倒是非常直观:如果内部包装的值不为空那么我们就直接取出来(unwrap it),然后对这个取出来的值执行"加一"操作,然后把执行完操作的值包装起来并返回。问题来了:这是一个通用的模式吗?让我们看看把这个模式应用在其他类型上面会怎么样:

    static OnDemand<int> AddOne(OnDemand<int> onDemand)
    {
        int unwrapped = onDemand();
        int result = unwrapped + 1;
        return CreateSimpleOnDemand<int>(result);
    }

看上去一点问题都没,能通过编译也能正常运行,但是他的语义真的正确吗?我们尝试一下,如果把这个操作应用在如下的委托上:

    () => DateTime.Now.Seconds

那么你并不会得到你想要的

    () => DateTime.Now.Seconds + 1

而是

    () => 一些其他的常量

到底在哪一步错了?注意,在上述的"加一"操作中,我们原先的OnDemand<T>的"随机应变性(on-demand-ness)"已经消失了:在你执行onDemand()的那一刻,其内部的值就已经确定了,这就导致我们之后对于result的包装也随之失去了"随机应变性(因为result一定是unwrapped + 1,调用onDemand()时unwrapped就被确定了,因此result也是确定的)"。对于OnDemand<int>"加一"的正确方法应该是这样:

    static OnDemand<int> AddOne(OnDemand<int> onDemand)
    {
        return () =>
        {
            int unwrapped = onDemand();
            int result = unwrapped + 1;
            return result;
        };
    }

(上述代码同样臃肿但是清晰的阐述了该过程的每一步)
这样写就保证了正确的语义,现在,旧的OnDemand<int>的值只有在新的OnDemand<int>被调用时才会确定。现在你会发现在修改后的正确代码里,我们确实用到了"提取出包装的值并且对其进行某些操作"这个模式,对比Nullable<int>OnDemand<int>的"加一"实现,他们的区别在于如何去创建包装类型,在Nullable<int>这个monad上,我们可以非常简单的提取出值,进行计算之后再重新包装,而对于OnDemand<int>这个monad,则需要在如何创建包装类型上多动点脑子:他并不是一个简单的值包装器,而是产生一个内容需要根据将要发生的一系列操作最终才能确定的"随机应变"的值(原文: it produces an object whose structure encodes the sequence of operations that are going to happen on demand)。(这是Monad的一个重要特性,在以后的文章中我们会讲到这一点),在本例中,它生产了一个能够执行旧委托的新委托。

接下来让我们快速浏览一下其他几种类型的"加一"操作:

    static Lazy<int> AddOne(Lazy<int> lazy)
    {
        return new Lazy<int>(() =>
        {
            int unwrapped = lazy.Value;
            int result = unwrapped + 1;
            return result;
        });
    }

意料之中,给一个Lazy<int>加一看上去像是给一个Nullable<int>加一和给一个OnDemand<int>加一的组合,注意只有在新的Lazy<int>被求值的时候,旧的Lazy<int>才会被求值,惰性求值的语义在这个过程中被保留了下来。

    async static Task<int> AddOne(Task<int> task)
    {
        int unwrapped = await task;
        int result = unwrapped + 1;
        return result;
    }

Task<int>的例子看上去甚至可能比Nullable<int>的还要更简单,当然,这得益于C# 5编译器帮我们在背后生成了许多其他有用的代码,作为练习,请使用C# 4的异步语法来实现一个异步的AddOne

    static IEnumerable<int> AddOne(IEnumerable<int> sequence)
    {
        foreach (int unwrapped in sequence)
        {
            int result = unwrapped + 1;
            yield return result;
        }
    }

我们又一次借用了C#编译器帮助我们生成大量代码,如果你能够不借助yieldforeach来实现,那么你的理解会大大加深(dc注: 用迭代器甚至是for循环都可以很轻松地实现)。当然,上述代码可以再进一步的简写:

    static IEnumerable<int> AddOne(IEnumerable<int> sequence)
    {
        return from unwrapped in sequence select unwrapped + 1; // dc注: 这是LINQ的语法
    }

那么到目前为止,总结一下,我们都学了些什么呢?
* Monad Pattern的第一原则是我们必须可以把一个简单的int类型给包装起来
* Monad Pattern的第二原则是一个包装了的int的"加一"操作应该通过某种手段生成另一个包装了的int,并且应当保持其原有的语义
第二条看上去似乎可以做很多有用的工作,在下一章节中我们会从int开始,推广到任意类型,借此一步步完善Monad Pattern的更多原则


乱我心者,今日之日多烦忧