C# Monads的实现(一)

时间:2022-05-18 03:36:22

了解Haskell语言的朋友都知道它是一门纯函数式编程,输出结果只跟输入参数相关,这导致Haskell不能有输入输出函数,因为不同的环境下,输入相同,但输出可能有所不同。Haskell语言中,变量的值一旦被赋值,则不会再被修改,所以这也导致了它就无法输出——因为输出会改变屏幕。那Haskell中如何实现输入输出功能?

Haskell中输入输出的功能是通过Monad方法实现的。本篇不讨论Haskell语言,只讨论基于C#下的Monad实现。

主要参考资料

一、介绍

Monads来自范畴论。在一篇优秀的论文The Essence of Functional Programming中,Wadler展示了monads在计算程序中非常有用,因为能够组合不同的函数,而这些函数具有不同的对数值处理的逻辑。

Monads在Haskell语言中获得非常大的成功,跟在特殊领域能发挥很好作用的actor一样,monads已经深入程序员脑海,普遍认为它在纯延迟函数式语言中才有用。这是很不幸的,事实上monads应用可以更宽广。

二、控制复杂度

组合是软件中控制复杂度的关键点。在 The Structure and Interpretation of Computer Programs中,Abelson和Sussman认为好的组合能把复杂的系统已简单的模式显示出来。

其中一种组合为函数组合,简单地描述了函数调用之间的依赖关系。比如,将两个函数作为输入参数,并把第二个函数的结果值作为第一个函数的输入参数,因此形成一个函数如下

public static Func<T, V> Compose<T, U, V>(this Func<U, V> f, Func<T, U> g)
{
return x => f(g(x));
}

如果不是先将函数g应用到值x上,然后对函数g的结果值应用函数f,那么,可以先组合函数f和函数g,将组合的结果应用到值x上,这两种方式的关键区别是函数f和g之间依赖关系的抽象

var r = f(g(x));         // without function composition
var r = f.Compose(g)(x); // with function composition

假设有如下Identity函数,那函数组合必须遵循以下三条规则

public static T Identity<T>(this T value)
{
return value;
}

1. 左恒等律

 Identity.Compose(f) = f

2. 右恒等律

f.Compose(Identity) = f

3. 结合律

f.Compose(g.Compose(h)) = (f.Compose(g)).Compose(h)

当然,不仅仅是值,结构化类型扩展了值类型。如,IEnumerable<T>表示延时计算的T类型列表,Nullable<T>表示类型T的可为空(null)值,而Func<Func<T, Answer>, Answer>表示一个函数,这个函数有一个continuation输入,并返回一个Answer,而这个continuation则有一个T类型参数并返回Answer。这些类型都扩展了T类型。

这里约定,M<T>类型扩展了类型T,并且我们统一称之为T的包装类型,为M<T>,而T称为未包装类型。

假定不是组合返回类型为T的函数,而是组合这样一种函数,这些函数以未包装类型为输入参数,返回包装类型,如下代码所示

public static Func<T, M<V>> Compose<T, U, V>(this Func<U, M<V>> f, Func<T, M<U>> g)
{
return x => f(g(x)); // error, g(x) returns M<U> and f takes U
}

这个组合会失败,原因如代码注释所说。我们增加一个"Bind"函数用以访问M<U>的去包装类型U,并将这个值传递给f函数,如下

public static Func<T, M<V>> Compose<T, U, V>(this Func<U, M<V>> f, Func<T, M<U>> g)
{
return x => Bind(g(x), f);
}

根据输入和输出可以决定Bind函数的签名,g函数的输出为M<U>,故Bind函数签名为

public static M<V> Bind<U, V>(this M<U> m, Func<U, M<V>> k)

除了Bind函数,我们还可以定义包装一个未包装类型参数使其成为包装类型,称此函数为"Unit",

public static M<T> Unit<T>(this T value)

Bind函数和Unit函数可以使用包装类型进行函数组合。

三、满足Monads

Monads由三部分组成:类型、Unit函数以及Bind函数。当然要成为一个monad,这三部分必须满足以下三条定律:

1. 左恒等律

Bind(Unit(e), k) = k(e)

2. 右恒等律

Bind(m, Unit) = m

3. 结合律

Bind(m, x => Bind(k(x), y => h(y)) = Bind(Bind(m, x => k(x)), y => h(y))

第一条和第二条定律显然易见故不再赘述。

第三条定律其实也容易理解,“=”左边是外层Bind函数对m进行去包装化,内层Bind函数对k(x)进行去包装化,而“=”右边是内层Bind函数对m进行去包装化,外层Bind函数对k(x)进行去包装化。

这些定律与函数组合类似。这不是一个巧合。这些定律保证了monad的行为正确并能良好的组合。

Identity Monad

最简单的Monad是Identity monad,用于包装一个值。

class Identity<T>
{
public T Value { get; private set; }
public Identity(T value) { this.Value = value; }
}

有了类型T和T的包装类型Identity之后,我们可以上面那些只有函数签名没有函数体的函了。

Unit函数输入一个T类型值,返回Identity的实例,如下

static Identity<T> Unit<T>(T value)
{
return new Identity<T>(value);
}

Bind函数有一个Identity类型参数,去包装这个参数为一个值,然后调用第二个参数,第二个参数是个委托类型,将去包装后的值传入这个委托,结果是Identity的另一个实例,如下

static Identity<U> Bind<T,U>(Identity<T> id, Func<T,Identity<U>> k)
{
return k(id.Value);
}

考虑这样一个简单的程序:创建两个Identity包装器,并对包装类型执行一个操作。首先,绑定x到5的包装类型上,然后绑定y到6的包装类型上。最后,将5和6相加,结果是11的包装类型,如下

var r = Bind(Unit(), x =>
Bind(Unit(), y =>
Unit(x + y))); Console.WriteLine(r.Value);

当然,这段代码看起来很不简洁,最好能有一个处理monad的语法。幸运的是,事实上真有这样的语法。

C#3.0引入了查询理解(query comprehensions),其本质上是monad理解(comprehensions)。我们可以使用LINQ重写identity monad。也许,可以把它称之为LINM(Language INtegrated Monads)。

把Unit重命名为ToIdentity,把Bind重命名为SelectMany,这两个重命名后的函数均为扩展方法,如下

public static Identity<T> ToIdentity<T>(this T value)
{
return new Identity<T>(value);
} public static Identity<U> SelectMany<T, U>(this Identity<T> id, Func<T, Identity<U>> k)
{
return k(id.Value);
}

于是,刚才的使用代码可以改写为如下

var r = .ToIdentity().SelectMany(
x => .ToIdentity().SelectMany(
y => (x + y).ToIdentity())); Console.WriteLine(r.Value);

与此相对应的方法正是LINQ中定义的标准查询操作符。然而,为了性能考虑,标准查询操作符还包含了一个稍有不同的SelectMany版本。这个SelectMany版本组合Bind和Unit,所以lambda不需要嵌入的很深,不像上面这段代码。先给出这个版本的SelectMany的方法定义,看看能从中发现什么。

public static Identity<V> SelectMany<T, U, V>(this Identity<T> id, Func<T, Identity<U>> k, Func<T,U,V> s)
{
return id.SelectMany(x => k(x).SelectMany(y => s(x, y).ToIdentity()));
}

这个SelectMany版本的定义中有SelectMany的重载版本,这个重载版本可参见上一个SelectMany方法定义。

当然,我们也可以利用已有的代码改写这个版本的SelectMany为如下

public static Identity<V> SelectMany<T, U, V>(this Identity<T> id, Func<T, Identity<U>> k, Func<T,U,V> s)
{
return s(id.Value, k(id.Value).Value).ToIdentity();
}

于是,调用代码不需要嵌套的lambda表达式了,

var r = .ToIdentity()
.SelectMany(x => .ToIdentity(), (x, y) => x + y); Console.WriteLine(r.Value);

使用这个定义,C#程序员们则可以使用查询理解语法,如下

var r = from x in .ToIdentity()
from y in .ToIdentity()
select x + y;

记住,查询语法中使用的是后一个版本的SelectMany。

Maybe Monad

前面介绍的Identity仅是对一个类型的最简单的monad化包装。如果我们需要某包装类型,这个包装类型中包含的值可能为一个值,也可能为一个空值(null),此时我们就可以使用Maybe monad。

首先定义Maybe类型,如下

class Maybe<T>
{
public readonly static Maybe<T> Nothing = new Maybe<T>();
public T Value { get; private set; }
public bool HasValue { get; private set; }
Maybe()
{
HasValue = false;
}
public Maybe(T value)
{
Value = value;
HasValue = true;
}
}

相应的Unit函数有一个未包装类型输入参数,构造一个Maybe的实例并返回,

public static Maybe<T> ToMaybe<T>(this T value)
{
return new Maybe<T>(value);
}

相应的Bind函数有一个Maybe类型输入参数,如果这个参数有值,则将这个值传入Bind的委托类型参数,否则返回Nothing,

public static Maybe<U> SelectMany<T, U>(this Maybe<T> m, Func<T, Maybe<U>> k)
{
if (!m.HasValue)
return Maybe<U>.Nothing;
return k(m.Value);
}

下面给出调用示例

var r = from x in .ToMaybe()
from y in Maybe<int>.Nothing
select x + y; Console.WriteLine(r.HasValue ? r.Value.ToString() : "Nothing");

List Monad

还有一个重要的包装类型是list类型。事实上,list monad是LINQ的核心。IEnumerable<T>表示延迟计算list。

相应的Unit函数为,

public static IEnumerable<T> ToList<T>(this T value)
{
yield return value;
}

Bind函数则有两个输入参数,分别为IEnumerable<T>类型和委托类型,这个委托类型有一个输入参数类型T,返回类型为IEnumerable<U>,最终Bind函数的返回类型为IEnumerable<U>,

public static IEnumerable<U> SelectMany<T, U>(this IEnumerable<T> m, Func<T, IEnumerable<U>> k)
{
foreach (var x in m)
foreach (var y in k(x))
yield return y;
}

现在,给出一个使用示例,

var r = from x in new[] { , ,  }
from y in new[] { , , }
select x + y; foreach (var i in r)
Console.WriteLine(i);

Continuation Monad

如何更好的写出CPS(Continuation-Passing Style)代码?

Continuation monad, K, 的类型是一个委托类型,当给定一个continuation,这个continuation本身有一个T类型输入参数并返回Answer类型结果,那么,委托最终返回Answer类型,签名如下

delegate Answer K<T,Answer>(Func<T,Answer> k);

注意,这个类型与上面所说的Identity<T>,Maybe<T>和IEnumerable<T>都不同。上面的那些monads表示了一个类型的包装类型,计算也是针对未包装类型而不是包装类型。但是,这个continuation monad与众不同,它不包含值,它所做的事情就是将用户写的continuation组合起来。

要成为一个monad,必须有相应的Unit函数,这个函数输入参数为T,返回参数为T的包装类型,即K<T, Answer>,签名如下

public static K<T, Answer> ToContinuation<T, Answer>(this T value);

分析这个函数签名,输入参数为T类型,输出为另一个函数(参见上面的委托签名),称为F,这个函数F自身的参数为Func<T, Answer>,形式如下

return (Func<T, Answer> c) => ...

根据上面的那个委托类型,故这个F函数的返回类型为Answer,其所需的T类型参数正是value值,故F函数体可以写成如下

return (Func<T, Answer> c) => c(value);

为了成为一个monad,Bind函数必须有一个K<T, Answer>输入参数,以及一个函数参数,这个函数用于从T类型转换到K<U, Answer>类型,最终Bind函数的返回类型为K<U, Answer>,函数签名如下

public static K<U, Answer> SelectMany<T, U, Answer>(this K<T, Answer> m, Func<T, K<U, Answer>> k);

但是函数体如何实现?返回类型必须是K<U, Answer>,但是这个返回类型如何形成?

我们再次看看SelectMany的组成(将K类型展开):

返回类型为K,K是一个输入为Func<U, Answer>,输出为Answer的函数,故返回类型如下,

Func<Func<U, Answer>, Answer>

m的类型也为K,只是泛型参数从U变为T,m类型如下,

Func<Func<T, Answer>, Answer>

k的类型,是一个函数,输入为T,输出为K,可以写成其类型如下

Func<T, Func<Func<U, Answer>, Answer>>

分析:

将k应用到T上返回一个类型K<U, Answer>的值,但是T类型值从哪里来?首先,根据返回类型K<U, Answer>可以构造出SelectMany函数体类似如下,

return (Func<U,Answer> c) => ...

根据K的类型可以推断return关键字后面的lambda函数体的返回类型必须为Answer,而m可以应用到一个从T转为Answer的函数上,m的返回类型正好为Answer,如此,SelectMany函数体类似如下,

return (Func<U,Answer> c) => m(...)

m调用的内部表达式必须是Func<T, Answer>,目前还没有这个类型,所以我们构造这样一个函数类型,即创建一个参数为T的lambda表达式,如下,

return (Func<U,Answer> c) => m((T x) => ...)

m调用的内部lambda表达式的函数体返回类型为Answer,所以我们现在要做的事情就是如何得到这个Answer类型。

考虑如果应用k到x上,则返回类型为Func<Func<U, Answer>, Answer>,要想得到类型Answer类型,则将这个返回结果再次应用到Func<U, Answer>上即可。前面我们已经有Func<U, Answer>的参数c,故现在SelectMany函数体可写成如下,

return (Func<U,Answer> c) => m((T x) => k(x)(c));

构建一个计算,调用值为7的continuation,将这个continuation传入另一个计算,这个计算调用值为6的continuation。再讲这个continuation传入另一个计算,这个计算调用的计算用来将前两个continuation的结果值相加。最后,传入一个continuation,用于将"1"替换为"a",如下

var r = from x in .ToContinuation<int,string>()
from y in .ToContinuation<int,string>()
select x + y; Console.WriteLine(r(z => z.ToString().Replace('', 'a'))); // displays a3

注:以上这段测试代码还无法通过编译,提示SelectMany函数的参数不对,如果对前面Identity Monad真正理解了的话,应该知道原因是什么。这里先不处理,下一篇关于Monads的实现中会解决这个问题。

三、Monad的魔力

对包装类型的组合需要monads。Identity,Maybe和IEnumerablemonads作为包装类型证明了monads的强大功能。Continuation monad,K,说明了monad是如何表现复合计算的。