【函数式编程】【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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.rhkb.cn/news/35128.html

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

【vue2 + Cesium】使用Cesium、添加第三方地图、去掉商标、Cesium基础配置、地图放大缩小事件、获取可视区域、层级、高度

参考文章&#xff1a; vue2 使用 cesium 篇【第一篇】 vue2 使用 cesium 【第二篇-相机视角移动添加模型】 vue2 项目模版&#xff1a; vue2-common 安装 cesium npm install cesium --save这个就很简单&#xff0c;只需要一句简简单单的命令就可以实现在 vue 项目中安装 ce…

vllm-openai多服务器集群部署AI模型

服务器配置是两台ubantu系统电脑,每台电脑安装两张4090-48G显存的显卡,共计192G显存。 服务器1 服务器2 准备工作: 1.两台电脑都已经安装了docker 2.两台电脑都已经安装了nvidia驱动 参考vllm官方资料 https://docs.vllm.ai/en/latest/serving/distributed_serving.html…

【电源】斩波电路

文章目录 前言定义概念 缩写降压斩波电路使用步骤总结参考文献 前言 进行大创项目开发的学习 bilibili 定义概念 缩写 斩波电路&#xff1a;分为降压&#xff0c;电荷泵&#xff0c;升压&#xff0c;升降压&#xff0c;Cuk&#xff0c;Speic&#xff0c;Zeta 等等 降压斩…

Hadoop集群组成

&#xff08;一&#xff09;Hadoop的组成 对普通用户来说&#xff0c; Hadoop就是一个东西&#xff0c;一个整体&#xff0c;它能给我们提供无限的磁盘用来保存文件&#xff0c;可以使用提供强大的计算能力。 在Hadoop3.X中&#xff0c;hadoop一共有三个组成部…

c++基础知识-图论进阶

一、拓扑排序 1、基础知识 1&#xff09;什么是拓扑排序 对一个有向无环图G进行拓扑排序&#xff0c;是将G中所有顶点排成一个线性序列&#xff0c;使得图中任意一对顶点u和v&#xff0c;若&#xff0c;则u在线性序列中出现在v之前。 2&#xff09;拓扑排序的操作方法 重复执行…

从Scaling Laws中解析大模型训练的边际递减临界点

前言 当我们拆解GPT-4到DeepSeek的演进路径&#xff0c;会发现一个反直觉的真相&#xff1a;​AI的智能跃迁不依赖参数堆砌&#xff0c;而取决于对"结构-能量-信息"三元关系的精准把控。就像人类大脑在进化中通过皮层折叠而非单纯增大体积来实现智能突破&#xff0c…

Word 小黑第20套

对应大猫21 特定一页设为横向 上下用分页符

【从0到1搞懂大模型】RNN基础(4)

先说几个常用的可以下载数据集的地方 平台&#xff1a;kaggle&#xff08;https://www.kaggle.com/datasets&#xff09; 和鲸社区&#xff08;https://www.heywhale.com/home&#xff09; 阿里天池&#xff08;https://tianchi.aliyun.com/&#xff09; 其他&#xff1a;海量公…

openEuler24.03 LTS下安装MySQL8

前提条件 拥有openEuler24.03 LTS环境&#xff0c;可参考&#xff1a;Vmware下安装openEuler24.03 LTS 步骤 卸载原有mysql及mariadb sudo systemctl stop mysql mysqld 2>/dev/null sudo rpm -qa | grep -i mysql\|mariadb | xargs -n1 sudo rpm -e --nodeps 2>/dev/…

如何在Odoo 18中实现OWL通知服务

如何在Odoo 18中实现OWL通知服务 OWL&#xff08;Odoo Web Library&#xff09;是Odoo的前端框架&#xff0c;用于构建现代化的动态响应式用户界面。在早期版本中&#xff0c;Odoo 前端设计与开发使用的是诸如 QWeb 这类较为老旧的框架&#xff0c;而随着 Odoo 每发布一个新版本…

Unet nn-Unet

Unet && nn-Unet&#xff1a; 文章题目&#xff1a;U-Net: Convolutional Networks for Biomedical Image Segmentation 代码&#xff1a;https://lmb.informatik.uni-freiburg.de/people/ronneber/u-net/ 文章题目&#xff1a;nnU-Net: Self-adapting Framework for U…

【扩散模型入门】Latent Diffusion

1. 概述 扩散模型为公众所知的一个主要原因是Stable Diffusion(SD)的推出展现出了远超以往的图像合成效果,而SD的主要技术就是Latent Diffusion Model(LDM)。 实际上,LDM的核心idea非常简单: 为了确保生成质量,LDM尽可能提升去噪模型的规模。提升模型规模往往也会同步…

搭建主从服务器

任务需求 客户端通过访问 www.nihao.com 后&#xff0c;能够通过 dns 域名解析&#xff0c;访问到 nginx 服务中由 nfs 共享的首页文件&#xff0c;内容为&#xff1a;Very good, you have successfully set up the system. 各个主机能够实现时间同步&#xff0c;并且都开启防…

SAP HANA on AWS Amazon Web Services

SAP HANA on AWS Amazon Web Services

vue项目如何实现条件查询?

目录 1.前端 2.后端 3.mybatis的sql语句 结语 1.前端 说白了就是&#xff0c;无论该参数是否是空字符串&#xff0c;都会传递到后端。&#xff08;反正不是null就行&#xff09;。 2.后端 在controller层中&#xff0c;使用RequestParam注解接收名为registerName的参数&…

C++:类对象的存储方式

如何计算类对象的大小 class A { public: void PrintA() { cout<<_a<<endl; } private: char _a; }; 类中既可以有成员变量&#xff0c;又可以有成员函数&#xff0c;那么一个类的对象中包含了什么&#xff1f;如何计算 一个类的大小&#xff1f; 类对象的存储方…

Windows 图形显示驱动开发-WDDM 3.0功能- 硬件翻转队列(一)

WDDM 3.0 之前的翻转队列模型 许多新式显示控制器支持对按顺序显示的多个帧排队的能力。 从 WDDM 2.1 开始&#xff0c;OS 支持将在下一个 VSync 中显示的多个未完成的翻转覆盖请求。 显示微型端口驱动程序 (KMD) 通过 DXGK_DRIVERCAPS 中的 MaxQueuedMultiPlaneOverlayFlipVS…

OSPF-5 3类LSA SummaryLSA

上一期我们介绍了2类LSA Network LSA的内容信息以及怎样从2类LSA中的信息描绘出一张具体的拓扑信息以及网段信息 这一期我们将介绍3类LSA Summary LSA区域间的LSA看看3类LSA是怎样把域间的路由信息传递到别的区域的 一、概述 由于3类LSA是用来描述我们域间的路由信息所以它是…

AI驱动的视频字幕提取与翻译工具

青梧字幕是一款基于Whisper技术的AI字幕提取工具&#xff0c;专为视频制作者、翻译人员和自媒体创作者设计。它通过先进的语音识别算法&#xff0c;能够自动从视频文件中提取字幕内容&#xff0c;并支持多种语言和字幕格式&#xff0c;极大地简化了字幕制作流程。 目前暂支持 …

ONNX:统一深度学习工作流的关键枢纽

引言 在深度学习领域&#xff0c;模型创建与部署的割裂曾是核心挑战。不同框架训练的模型难以在多样环境部署&#xff0c;而 ONNX&#xff08;Open Neural Network Exchange&#xff09;作为开放式神经网络交换格式&#xff0c;搭建起从模型创建到部署的统一桥梁&#xff0c;完…