当前位置: 首页 > news >正文

【函数式编程】【C#/F#】第四讲:单子与函子 - 抽象的编程模式

在第二讲中我们探讨了一个诚实的函数应该要做到什么事,并运用了一种方法,让我们可以去准确的描述数据。

不过有一种情况让我们始料未及,例如网站需要收集一些信息,但有些信息不是必须的,是可有可无的。如果我们要去准确地描述这个情况应该怎么做呢。你可能会说,"我们有null可以解决这个问题"

图片

我们在一开始就强调,null是非常可怕的一个魔王,他的存在让世界上无数的代码存在了安全隐患。为了不让他有任何的反抗余地,在函数式编程的世界中,我们永远不会使用null。没错,只要不用null就可以避免null带来的问题,函数式编程就是这么简单暴力的解决了这个问题。

我们介绍过了在C#中存在NRT这个机制想要缓解这个问题,但也仅仅是缓解了这个问题。尤其null本就不是一个正常类型让他显得尤为异类。

为了弥补null的缺席,也为了避免重蹈覆辙,我们需要一种更准确,更具表现力的方式来表达数据缺失这一个状态

如果你还看了我们的F#系列的第三讲,你一定会对可区分联合影响深刻,这是一个目前C#(C#13)没有原生支持的类型,这也是一种和类型(Sum Type), 顾名思义,和类型即是将多个类型的取值范围相加的一种类型,而积类型(例如元组)则是取值范围相乘

Option,这就是我们接下来要用到的类型,它可以用来表达一个可能缺失的数据,其定义是

Option<T> = None | Some(T), 代表它可以取值为None或者是Some(T), 代表一个值可能存在,其取值范围数是类型T的取值范围+1

在F#中我们天然就拥有了这个类型,而C#却没有,不过我们依然可以用第三方库(LanguageExt)帮我们引入这个类型。但是在此之前,我们希望自己实现一下这个类型,以帮助我们更好地牢记这一概念。(可以的话,请读者也可以跟上我们的脚步,一起来实现一次)

interface Option<T>;
record Some<T>(T Value): Option<T>;
record None<T>(): Option<T>;

这便是C#中最接近Option定义的一种实现方法, Some和None都是option的一种实现,所以Option的取值范围就是Some+None, 也算是一种和类型,不过遗憾的是,我们没有办法阻止有其他的Option实现。

接下来当我们使用Option的时候,就可以使用switch表达式来实现模式匹配

string AddMark(Option<string> t) => t switch
{
    Some<string> s => s.Value + "!",
    None<string> _ => "None"
};

这时候细心的你注意到,编译器提示我们switch没有详尽的处理所有可能的情况,正如上面所说,我们无法阻止其他的Option实现,所以编译器好心提醒了我们这件事,但事实上我们不需要其他任何实现。

这时候我们可以自己适配一个新的函数

public static R Match<T, R> (this Option<T> t, Func<T, R> Some, Func<R> None) => t switch
    {
        Some<T> (var s) => Some(s),
        None<T> _ => None(),
        _ => throw new Exception("Invalid Option")
    }; 

var t = new Some<string>("Hello");
t.Match(
    Some: s => s + "!",
    None: () => "None"
);

这样我们便获得了一个优雅的方式去使用Option,并且这时候也会强制用户去处理为None的情况(否则便无法通过编译),这也是为什么说默认行为对一个语言的重要性

不过我们这里的定义稍显麻烦,事实上有办法可以获得更简单的定义方式,不过第三方库已经帮我们完成了这些工作,在本讲中就不再做这些优化了。例如:var str = Some("Hello");

在同一抽象级上编码

现在想象一下,我们想要使用这个Option类型与正常的类型参与一些运算,我们肯定不会希望每次都通过match转换为一个正常的值和一个特殊的none值,最后再通过特判重新构建为Option, 这样使用Option就没有意义了, 我们当然希望它可以直接返回为一个Option, 便有了如下代码

t.Match<string, Option<string>>(
    Some: s => new Some<string>(s + "!"),
    None: () => new None<string>()
);
t.Match<string, Option<int>>(
    Some: s => new Some<int>(s.Length),
    None: () => new None<int>()
);

我们仔细的观察了这些函数,发现了一些共性,None的时候,我们总是不会做任何事情,而对于Some来说,我们总是提供一个函数,将类型T转换为类型R, 最后得到一个Option<R>, 还记得第二讲的函数签名吗?尝试写出这个函数的函数签名

(Option<T>, (T -> R)) -> Option<R>

接受一个Option<T>和一个(接受T输出R的函数),最后输出一个Option<R>, 像这样函数签名的函数,我们称之为Map

public static Option<R> Map<T, R> (this Option<T> t, Func<T, R> f) => t.Match<T, Option<R>>(
        Some: s => new Some<R>(f(s)),
        None: () => new None<R>()
);

到这里,你有没有觉得有一丝熟悉感呢?如果还没有的话,看到下面这个代码你一定会恍然大悟!

int[] arr = {1, 2, 3, 4, 5};
var doubleArr = arr.Select(i => i * 2);

图片

对啊!这不就是我们的Select函数吗!

是的,在FP的语言中,我们更喜欢叫这类函数为Map,相比Select也更好理解, 对于这个例子Select的函数签名是

(IEnumerable<T>, (T -> R)) -> IEnumerable<R>

等等,我怎么感觉,这两者有点相似,我们都是将一个函数应用到了这个类型的内部值,而且就算集合为空,我们对其的函数操作也不会导致任何报错,会返回一个同样是空的集合!

恭喜你!你发现了其中的奥秘,事实上这IEnumerable<T>和Option<T>这两者都是一个/类值的容器,是一个更高级别的类型(类型论内容,理发师悖论),或者说他是一个更抽象的类型,而输入的函数,也是代表着将要做什么,而不是已经做了什么。自然没有元素的时候,也就无事可做。就像Option是一个盒子,Some 是里面装了值的盒子,None 是没有装任何东西的盒子。而 Map 就是将盒子里的值转换成另一个形式的操作。// 需要吗 中国有句古话说得好,巧妇难为无米之炊,这其实也是揭示Map的思想,巧妇就是那个Map

写出Map更通用的形式

Map: (C<T>, (T -> R)) -> C<R>

当一个类型合理实现Map函数之后, 函子(functor),我们是这么称呼它们的。当然,Map函数本身的实现不应该有副作用。理所当然的,Option和IEnumerable都是函子。可能你会觉得这有点像接口,不过事实上我们在C#/F#中无法,或者说很难用接口做到这一点。

现在回想起第二期中我们所希望诚实的函数,我们现在可以通过如下方式重新构建我们的Age,这时候便不再抛出错误,转而返回一个Option

record Age
{
    public int Value { get; }
    private Age(int value) => Value = value;
    public static Option<Age> Create(int value)
    {
        if (value < 1 || value > 120)
            return new None<Age>();
        else
        {
            return new Some<Age>(new Age(value));
        }
    }
}

现在我们想将一个字符串转换为Age,并且也不会直接抛出错误。我们应该怎么做呢?

图片

 这我会,放着我来,我们只需要将int.TryParse用适配器函数修改一下

然后再简单的map一下即可

string ageStr = "20";
var ageInt = ageStr.ParseInt();
var age = ageInt.Map(Age.Create);

轻松!

真的是这样吗?

图片

 ....?

我们这里使用了var,所以编译器自动帮我们推断了类型,如果我们将类型全部写完整就会发现

Option<int> ageInt = ageStr.ParseInt();
Option<Option<Age>> age = ageInt.Map(Age.Create);

图片

确实啊,ageInt 到还是正常,但最后的age,居然嵌套了两层Option,这也太抽象了

Option<Option<T>>表达了一个值存在的可能性有没有可能存在,好像绕口令一样,显然绝大数情况,我们都不希望有这种复杂嵌套的描述,并且事实上,最终我们想要知道的也只是一个可能存在的Age,即Option<Age>,现在的Map显然不符合我们的需求。这个时候,我们根据需求再写出一个函数签名,我们需要一个Option和一个将T类型转换为Option<R>的函数,最后输出一个Option<R>

(Option<T>, (T -> Option<R>)) -> Option<R>

接受一个Option<T>和一个(接受T输出Option<R>的函数),最后输出一个Option<R>, 像这样函数签名的函数,我们称之为Bind

public static Option<R> Bind<T, R> (this Option<T> t, Func<T, Option<R>> f) => t.Match<T, Option<R>>(
        s => f(s),
        () => new None<R>()
);

这个函数可能大家没这么熟悉,但是我们照猫画虎,直接把我们的Option替换为IEnumerable

(IEnumerable<T>, (T -> IEnumerable<R>)) -> IEnumerable<R>

图片

.............难道是SelectMany

厉害,给你想到了,如果你比较熟悉Linq的话,SelectMany正好是与Bind有着同样的签名

int[] ints = { 1, 2, 3, 4, 5 };
var doubled2  = ints.SelectMany(i => new [] {i, i * 2}).ToArray();
System.Console.WriteLine(string.Join(", ", doubled2));
// 1, 2, 2, 4, 3, 6, 4, 8, 5, 10

不过SelectMany和Bind的"意义"可能会有所不同,尽管他们函数签名都是一样的,但SelectMany更多时候是为了去平铺列表。不过当(T -> IEnumerable<R>)中IEnumerable<R>的元素只有一个的时候,他看起来就更像是常规的Bind了,毕竟IEnumerable,是一个有着更多特殊性质的抽象类型。

现在我们提供准确的Bind定义

Bind: (C<T>, (T -> C<R>)) -> C<R>

定义了Bind函数的类型,我们称之为单子(Monad),不过事实上单子还有一个函数是必须的,这个函数较为简单,我们称之为Return

Return: T -> C<T>

将一个T值提升到C<T>, 完整的单子需要有这两个函数实现。(事实上,单子还需要满足单子定律,不过单子定律其实很难被违反,这一点我们以后的内容会提到,这些函数其实也有背后的一些理论基础范畴论,类型论复杂的理论基础)

Where,ForEach

接下来还有两个Linq里常见的函数,Where与ForEach,这两个在IEnumerable的操作中经常大显神威,这两个函数也可以用到Option上吗?

当然可以,Where用于过滤容器中的值,尽管Option中最多只有一个值,但也可以被过滤

而ForEach往往是用来执行副作用的,尽管Option中最多只有一个值,但他当然也可以被执行,只不过最多只会被执行一次,这可能会略微的有一些反直觉。

另外ForEach值得我们更多的说一些内容,也是因为它涉及到了我们函数式编程想要极力避免的副作用,由于ForEach没有返回值的特性,他往往会在一个调用链的最后去执行。

public record Unit;
public static void ForEach<T> (this Option<T> t, Func<T, Unit> f) => t.Match<T, Unit>(
        s => { f(s); return new Unit(); },
        () => new Unit()
);
    //or
public static void ForEach<T> (this Option<T> t, Action<T> f) => t.Match<T, Unit>(
        Some: s => { f(s); return new Unit(); },
        None: () => new Unit()
);

正好,副作用往往是在代码的最后去执行,在这之前,理应都是纯函数的操作。不仅是ForEach,其他的所有操作都应该类似于此。(有一些)

在更高的抽象级别中编程

掌握了单子与函子,接下来我们就可以在一个更高的层次去看待一些问题,不过这件事我们其实早就在做了,毕竟对于IEnumerable,我们早就驾轻就熟了,现在只需要将知识迁移到新的单/函子即可

想象一下,下面这段代码,如果是在普通状态下求出最后的结果,我们需要多少个if判断才能写完整个计算过程?但是当我们提高抽象层次,只关注其类型的可能性,这个问题便迎刃而解。我们不需要再关心某一步计算有没有可能失败,毕竟我们只会在成功时,数据才会流向我们声明的函数。

"70".ParseInt()
    .Map(s => s * 2)
    .Bind(Age.Create)
    .Map(s => s <= 18)
    .ForEach(s => {
        System.Console.WriteLine(s ? "Under 18" : "Over 18");
    });

当然,我们也不希望会看到一大堆嵌套A<B<C<D>>>的抽象结构,或是很简单的一些直观的计算却用了更复杂的方式。我们要灵活的选择我们应该在哪一个层次去解决我们的问题,在正确的抽象级别中编程,才是我们应该做的事。

回顾

接下来我们再回头看一看上面的代码,有发现什么吗?

我们首先将一个常规值提升(Return)到了一个抽象的值,接下来我们便在抽象层进行了一步一步的运算,Map和Bind都是运行在抽象层的函数,他们都是接受一个抽象的值然后输出一个抽象的值。最后我们使用ForEach使用了我们的结果,如果使用的是Match或是Sum这类的,我们可能会将抽象的值下降为一个普通的值,不过这并不是总是能很好的做到的(比如Option的None)

我们解决问题的过程总是类似于如下情况,常规->抽象*n->常规

实心点代表常规值,而虚圈代表抽象值,黄线则是两者的分界(所以Return和最后的下降的函数,也可以叫做跨界函数)

图片

良好的利用这些特性和方法,我们能够非常轻松的解决Null,复杂的循环等等非常麻烦的判断或者计算或是超长的代码。

至此,我们已经开始接触函数式编程最有趣的地方了,不过单子函子能做到的事情绝对超乎你的想象。那么让我们发挥想象力,你可以再设计一些单子让我们编程变得更轻松的单子吗?

最后是课后作业

1. bind可以直接用来连接两个输出抽象值的函数,请试着实现一下

2. 单子一定是一个函子吗?

3. Option总是可以提升到IEnumerable吗?

4. IEnumerable的Return是如何编写的?

这些思考题,大家有兴趣的话也可以试一试证明以及实现一下。

图片

微信公众号: @scixing的炼丹炉

Bilibili: @无聊的年【函数式编程】【C#/F#】(四) 抽象的编程模式 - 函子与单子_哔哩哔哩_bilibili单子与函子,抽象的编程, 视频播放量 584、弹幕量 2、点赞数 22、投硬币枚数 6、收藏人数 9、转发人数 2, 视频作者 无聊的年, 作者简介 今天是学习.NET的好日子,相关视频:我可能发明了世界上最极端的编程语言...,只有vs code能做到,深入学习C#中的函数式编程模式,Rust 开发 - 完整教程,微软用Go重写TypeScript编译器,不要在集合上浪费内存 .NET技巧 1,太优雅辣!C#13,更新了啥?,静态语言恩情课文《C#爷爷用拆箱抛出非法转换异常》,函数式朋友对我做的编程语言赞不绝口,力软.NET低代码开发平台(Vue3) - V3.2.3更新说明https://www.bilibili.com/video/BV16MQSYGEnW

相关文章:

  • 重生之我在学Vue--第18天 Vue 3 项目功能扩展
  • “Failed to Load SteamUI.dll” 错误详解:全面解析与高效解决方案,助你快速修复 Steam 客户端问题
  • React 18 并发更新的工作原理与实战应用
  • 【软件工程】06_软件设计
  • AI学习第二天--监督学习 半监督学习 无监督学习
  • Unitest和pytest区别
  • LeetCode[59]螺旋矩阵Ⅱ
  • K8S学习之基础三十五:k8s之Prometheus部署模式
  • 【算法题解答·七】哈希
  • 安科瑞分布式光伏监测系统:推动绿色能源高效发展
  • Word 小黑第26套
  • DeepSeek 助力 Vue3 开发:打造丝滑的表格(Table)之添加导出数据功能
  • 痉挛性斜颈护理宝典:重拾生活平衡
  • Python数据可视化——生成数据(一)
  • LabVIEW 中的曲线拟合模型与方法概述
  • Windows Server中的NTP服务器部署(NTP Srver Deployment in Windows Server)
  • 考研专业课复习方法:如何高效记忆和理解?
  • stm32第五天按键的基础知识
  • 基于k3s部署Nginx、MySQL、PHP和Redis的详细教程
  • Useage of Generic in Java
  • 著名连环画家庞邦本逝世
  • 哲学新书联合书单|远离苏格拉底
  • 西北大学副校长范代娣成陕西首富?系家庭财富,本人已从上市公司退出
  • 某博主遭勒索后自杀系自导自演,成都警方立案调查
  • 泽连斯基批准美乌矿产协议
  • 老人将房产遗赠给外孙,三个女儿却认为遗嘱应无效,法院判了