欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

拿 C# 搞函数式编程 - 2

程序员文章站 2022-07-02 12:23:44
前一阵子在写 CPU,导致一直没有什么时间去做其他的事情,现在好不容易做完闲下来了,我又可以水文章了哈哈哈哈哈。 有关 FP 的类型部分我打算放到明年再讲,因为现有的 C# 虽然有一个 pattern matching expressions,但是没有 discriminated unions 和  ......

前一阵子在写 cpu,导致一直没有什么时间去做其他的事情,现在好不容易做完闲下来了,我又可以水文章了哈哈哈哈哈。

有关 fp 的类型部分我打算放到明年再讲,因为现有的 c# 虽然有一个 pattern matching expressions,但是没有 discriminated unions 和 records,只能说是个半残废,要实现 fp 那一套的类型异常的复杂。西卡西,discriminated unions 和 records 这两个东西官方已经定到 c# 9 了,所以等明年 c# 9 发布了之后我再继续说这部分的内容。

另外,conceptstype classes)、traits 、intersect & sum types 和高阶类型也可能会随着 c# 9、10 一并到来。因此到时候再讲才会讲得更爽。另外吹一波 traits类型系统,同样是图灵完备的类型系统,在表达力上要比oop强太多,欢迎大家入坑,比如 rust 和未来的 c#。

这一部分我们介绍一下 functorapplicative和 monad 都是些什么。

本文试图直观地讲,目的是让读者能比较容易的理解,而不是准确知道其概念如何,因此会尽量避免使用一些专用的术语,如范畴学、数学、λ 计算等等里面的东西。感兴趣的话建议参考其他更专业的资料。

functor

functor 也叫做函子。想象一下这样一件事情:

现在我们有一个纯函数 isodd

bool isodd(int value) => (value & 1) == 1;

这个纯函数只干一件事情:判断输入是不是奇数。

那么现在问题来了,如果我们有一个整数列表,要怎么去做上面这件事情呢?

可能会有人说这太简单了,这样就可:

var list = new list<int>();
return list.select(isodd).tolist();

上面这句干了件什么事情呢?其实就是:我们将 isodd 函数应用到了列表中的每一个元素上,将产生的新的列表返回。

现在我们做一次抽象,我们将这个列表想象成一个箱子m,那么我们的需要干的事情就是:把一个装着 a 类型东西的箱子变成一个装着 b 类型东西的箱子(ab类型可相同),即 fmap函数,而做这个变化的方法就是:进入箱子m,把里面的a变成b

它分别接收一个把东西从a变成b的函数、一个装着am,产生一个装着bm

m<b> fmap(this m<a> input, func<a, b> func);

你暂且可以简单地认为,判断一个箱子是不是 functor,就是判断它有没有 fmap这个操作。

maybe

我们应该都接触过 c# 的 nullable<t>类型,比如 nullable<int> t,或者写成 int? t,这个t,当里面的值为 null 时,它为 null,否则他为包含的值。

此时我们把这个 nullable<t>想象成这个箱子 m。那么我们可以这么说,这个m有两种形式,一种是 just<t>,表示有值,且值在 just 里面存放;另一种是 nothing,表示没有值。

用 haskell 写这个nullable<t>类型定义的话,大概长这个样子:

data nullable x = just x | nothing

而之所以这个nullable<t>既可能是 nothing,又可能是 just<t>,只是因为 c# 的 bcl 中包含相关的隐式转换而已。

由于自带的 nullable<t>不太好具体讲我们的各种实现,且只接受值类型的数据,因此我们自己实现一个maybe<t>

public class maybe<t> where t : notnull
{
    private readonly t innervalue;
    public bool hasvalue { get; } = false;
    public t value => hasvalue ? innervalue : throw new invalidoperationexception();

    public maybe(t value)
    {
        if (value is null) return;
        innervalue = value;
        hasvalue = true;
    }

    public maybe(maybe<t> value)
    {
        if (!value.hasvalue) return;
        innervalue = value.value;
        hasvalue = true;
    }

    private maybe() { }

    public static implicit operator maybe<t>(t value) => new maybe<t>(value);
    public static maybe<t> nothing() => new maybe<t>();
    public override string tostring() => hasvalue ? value.tostring() : "nothing";
}

对于 maybe<t>,我们可以写一下它的 fmap函数:

public static maybe<b> fmap<a, b>(this maybe<a> input, func<a, b> func)
    => input switch
    {
        null => maybe<b>.nothing(),
        { hasvalue: true } => new maybe<b>(func(input.value)),
        _ => maybe<b>.nothing()
    };

maybe<int> t1 = 7;
maybe<int> t2 = maybe<int>.nothing();
func<int, bool> func = x => (x & 1) == 1;
t1.fmap(func); // just true
t2.fmap(func); // nothing

applicative

有了上面的东西,现在我们说说 applicative 是干什么的。

你可以非常容易的发现,如果你为 maybe<t>实现一个 fmap,那么你可以说 maybe<t>就是一个 functor

那 applicative 也差不多,首先applicative是继承自functor的,所以applicative本身就具有了 fmap。另外在 applicative中,我们有两个分别叫做pure和 apply的函数。

pure干的事情很简单,就是把东西装到箱子里:

m<t> pure<t>(t input);

那 apply 干了件什么事情呢?想象一下这件事情,此时我们把之前所说的那个用于变换的函数(func<a, b>)也装到了箱子当中,变成了m<func<a, b>>,那么apply所做的就是下面这件事情:

m<b> apply(this m<a> input, m<func<a, b>> func);

看起来和 fmap没有太大的区别,唯一的不同就是我们把func也装到了箱子m里面。

以 maybe<t>为例实现 apply

public static maybe<b> apply<a, b>(this maybe<a> input, maybe<func<a, b>> func)
    => (input, func) switch
    {
        _ when input is null || func is null => maybe<b>.nothing(),
        ({ hasvalue: true }, { hasvalue: true }) => new maybe<b>(func.value(input.value)),
        _ => maybe<b>.nothing()
    };

然后我们就可以干这件事情了:

maybe<int> input = 3;
maybe<func<int, bool>> isodd = new func<int, bool>(x => (x & 1) == 1);

input.apply(isodd); // just true

我们的这个函数 isodd本身可能是 nothing,当 inputisodd任何一个为nothing的时候,结果都是nothing,否则是just,并且将值存到这个 just里面。

monad

monad 继承自 applicative,并另外包含几个额外的操作:returnsbindthen

returns干的事情和上面的applicativepure干的事情没有区别。

public static maybe<a> returns<a>(this a input) => new maybe<a>(input);

bind干这么一件事情 :

m<b> bind<a, b>(this m<a> input, func<a, m<b>> func);

它用一个装在 m中的a,和一个a -> m<b>这样的函数,产生一个m<b>

then用来充当胶水的作用,将一个个操作连接起来:

m<b> then(this m<a> a, m<b> b);

为什么说这是充当胶水的作用呢?想象一下如果我们有两个 monad,那么使用 then,就可以将上一个 monad和下一个monad利用函数组合起来将其连接,而不是写为两行语句。

实现以上操作:

public static maybe<b> bind<a, b>(this maybe<a> input, func<a, maybe<b>> func)
    => input switch
    {
        { hasvalue: true } => func(input.value),
        _ => maybe<b>.nothing()
    };

public static maybe<b> then<a, b>(this maybe<a> input, maybe<b> next) => next;

完整maybe<t>实现

public class maybe<t> where t : notnull
{
    private readonly t innervalue;
    public bool hasvalue { get; } = false;
    public t value => hasvalue ? innervalue : throw new invalidoperationexception();

    public maybe(t value)
    {
        if (value is null) return;
        innervalue = value;
        hasvalue = true;
    }

    public maybe(maybe<t> value)
    {
        if (!value.hasvalue) return;
        innervalue = value.value;
        hasvalue = true;
    }

    private maybe() { }

    public static implicit operator maybe<t>(t value) => new maybe<t>(value);
    public static maybe<t> nothing() => new maybe<t>();
    public override string tostring() => hasvalue ? value.tostring() : "nothing";
}

public static class maybeextensions
{
    public static maybe<b> fmap<a, b>(this maybe<a> input, func<a, b> func)
        => input switch
        {
            null => maybe<b>.nothing(),
            { hasvalue: true } => new maybe<b>(func(input.value)),
            _ => maybe<b>.nothing()
        };

    public static maybe<b> apply<a, b>(this maybe<a> input, maybe<func<a, b>> func)
        => (input, func) switch
        {
            _ when input is null || func is null => maybe<b>.nothing(),
            ({ hasvalue: true }, { hasvalue: true }) => new maybe<b>(func.value(input.value)),
            _ => maybe<b>.nothing()
        };

    public static maybe<a> returns<a>(this a input) => new maybe<a>(input);

    public static maybe<b> bind<a, b>(this maybe<a> input, func<a, maybe<b>> func)
        => input switch
        {
            { hasvalue: true } => func(input.value),
            _ => maybe<b>.nothing()
        };

    public static maybe<b> then<a, b>(this maybe<a> input, maybe<b> next) => next;
}

以上方法可以自行柯里化后使用,以及我调换了一些参数顺序便于使用,所以可能和定义有所出入。

有哪些常见的 monads

  • maybe
  • either
  • try
  • reader
  • writer
  • state
  • io
  • list
  • ......

c# 中有哪些 monads

  • task<t>
  • nullable<t>
  • ienumerable<t>+selectmany
  • ......

为什么需要 monads

想象一下,现在世界上只有一种函数:纯函数。它接收一个参数,并且对于每一个参数值,给出固定的返回值,即 f(x)对于相同参数恒不变。

那现在问题来了,如果我需要可空的值 maybe或者随机数random等等,前者除了值本身之外,还带有一个是否有值的状态,而后者还跟计算机的运行环境、时间等随机数种子的因素有关。如果我们所有的函数都是纯函数,那么我们如何用一个函数去产生 maybe 和 random 呢?

前者可能只需要给函数增加一个参数:是否有值,然而后者呢?牵扯到时间、硬件、环境等等一切和产生随机数种子有关的状态,我们当然可以将所有状态都当作参数传入,然后生成一个随机数,那更复杂的,io如何处理?

这类函数都是与环境和状态密切相关的,状态是可变的,并不能简单的由参数做映射产生固定的结果,即这类函数具有副作用。但是,我们可以将状态和值打包起来装在箱子里,这个箱子即 monad,这样我们所有涉及到副作用的操作都可以在这个箱子内部完成,将可变的状态隔离在其中,而对外则为一个单体,仍然保持了其不变性。

以随机数 random为例,我们想给随机数加 1。(下面的代码我就用 haskell 放飞自我了)

我们现在已经有两个函数,nextrandom用于产生一个 random intplusone用于给一个 int 加 1:

nextrandom :: random int // 返回值类型为 random int
plusone :: int -> int // 参数类型为 int,返回值类型为 int

然后我们有 bindreturns操作,那我们只需要利用着两个操作将我们已有的两个函数组合即可:

bind (nextrandom (returns plusone))

利用符号表示即为:

nextrandom >>= plusone

这样我们将状态等带有副作用的操作全部隔离在了 monad 中,我们接触到的东西都是不变的,并且满足 f(g(x)) = g(f(x))

当然这个例子使用monadbind操作纯属小题大做,此例子中只需要利用functor的 fmap操作能搞定:

fmap plusone nextrandom

利用符号表示即为:

plusone <$> nextrandom