在第二讲中我们探讨了一个诚实的函数应该要做到什么事,并运用了一种方法,让我们可以去准确的描述数据。
不过有一种情况让我们始料未及,例如网站需要收集一些信息,但有些信息不是必须的,是可有可无的。如果我们要去准确地描述这个情况应该怎么做呢。你可能会说,"我们有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