纯Java中的函数式编程:Functor和Monad示例

绝大多数程序员,特别是那些没有功能编程背景的程序员,都倾向于认为monad是某种神秘的计算机科学概念,因此从理论上讲,它对他们的编程事业没有帮助。这种消极的观点可以归因于数十篇文章或博客文章过于抽象或过于狭窄。但是事实证明,即使在标准Java库中,monad也无处不在,尤其是从Java Development Kit(JDK)8开始(以后会有更多介绍)。绝对妙不可言的是,一旦您第一次了解monad,突然之间就会有几个完全不相同的目的无关的类和抽象变得熟悉。
Monad概括了各种看似独立的概念,因此学习Monad的另一种化身只需很少的时间。例如,您不必学习CompletableFuture在Java 8中的工作方式-一旦意识到它是monad,就可以精确地知道它的工作方式,以及从其语义中可以期待什么。然后您会听说RxJava听起来有很大不同,但是由于Observable是monad,因此没有太多要添加的内容。您已经不知不觉中已经遇到过许多其他的Monads示例。因此,即使您实际上没有使用RxJava,本节也将是有用的复习。
Functors
在解释什么是monad之前,让我们研究一个称为functor的简单结构。Functors是封装某些值的类型化数据结构。从语法的角度来看,Functors是具有以下API的容器:
import java.util.function.Function;
interface Functor {
Functor map(Function<T, R> f);
}
但是仅语法是不足以了解什么是Functors。functor提供的唯一操作是带有函数f的map()。此函数接收框内的任何内容,对其进行转换并将结果按原样包装到另一个Functors中。请仔细阅读。Functor 始终是一个不可变的容器,因此map不会使执行该操作的原始对象发生突变。相反,它将返回包装在全新Functors中的结果(或结果-请耐心等待),Functors可能是类型R。另外,Functors在应用标识函数(即map(x-> x))时不应执行任何操作。这种模式应始终返回相同的Functors或相等的实例。
通常将Functor 与保存T的实例进行比较,其中与该值交互的唯一方法是对其进行转换。但是,没有从Functors解开或逃逸的惯用方法。值始终位于Functors的上下文内。Functors为什么有用?它们使用一个统一的,适用于所有集合的API概括了集合,promise,Optionals等多个常见习语。让我介绍几个Functors,以使您更流畅地使用此API:
interface Functor<T,F extends Functor<?,?>> {
F map(Function<T,R> f);
}
class Identity implements Functor<T,Identity<?>> {
private final T value;
Identity(T value) { this.value = value; }
public Identity map(Function<T,R> f) {
final R result = f.apply(value);
return new Identity<>(result);
}
}
需要额外的F类型参数来进行Identity编译。在前面的示例中,您看到的是最简单的Functors,仅包含一个值。您只能在map方法内部对其进行转换,但是无法提取它。这被认为超出了纯Functors的范围。与Functors进行交互的唯一方法是应用类型安全的转换序列:
Identity idString = new Identity<>(“abc”);
Identity idInt = idString.map(String::length);
或流利地,就像您编写函数一样:
Identity<byte[]> idBytes = new Identity<>(customer)
.map(Customer::getAddress)
.map(Address::street)
.map((String s) -> s.substring(0, 3))
.map(String::toLowerCase)
.map(String::getBytes);
从这个角度来看,在Functors上的映射与调用链式函数没有太大不同:
byte[] bytes = customer
.getAddress()
.street()
.substring(0, 3)
.toLowerCase()
.getBytes();
您为什么还要打扰这样冗长的包装,不仅不提供任何附加值,而且也不能将内容提取回去?好了,事实证明您可以使用此原始Functors抽象对其他几个概念建模。例如,从Java 8开始,可选的是带有map()方法的Functors。让我们从头开始实现它:
class FOptional implements Functor<T,FOptional<?>> {
private final T valueOrNull;
private FOptional(T valueOrNull) {
this.valueOrNull = valueOrNull;
}
public FOptional map(Function<T,R> f) {
if (valueOrNull == null)
return empty();
else
return of(f.apply(valueOrNull));
}
public static FOptional of(T a) {
return new FOptional(a);
}
public static FOptional empty() {
return new FOptional(null);
}
}
现在变得有趣了。一个FOptional仿函数可以持有价值,但同样也可能是空的。这是一种类型安全的编码方式null。有两种构造方法FOptional-通过提供值或创建 empty()实例。在这两种情况下,就像with一样Identity,FOptional都是不可变的,我们只能与内部的值进行交互。不同之处FOptional在于,如果转换函数f为空,则可能不会将其应用于任何值。这意味着Functors可能未必必须完全封装type的一个值T。它也可以包装任意数量的值,就像List… functor:
import com.google.common.collect.ImmutableList;
class FList implements Functor<T, FList<?>> {
private final ImmutableList list;
FList(Iterable value) {
this.list = ImmutableList.copyOf(value);
}
@Override
public FList<?> map(Function<T, R> f) {
ArrayList result = new ArrayList(list.size());
for (T t : list) {
result.add(f.apply(t));
}
return new FList<>(result);
}
}
API保持不变:您可以在转换中使用Functors-但行为却大不相同。现在,我们对FList中的每个项目进行转换,以声明方式转换整个列表。因此,如果您有客户列表,并且想要他们的街道列表,则非常简单:
import static java.util.Arrays.asList;
FList customers = new FList<>(asList(cust1, cust2));
FList streets = customers
.map(Customer::getAddress)
.map(Address::street);
这不再像说那么简单customers.getAddress().street(),您不能getAddress()在一个客户集合上调用,您必须getAddress()在每个单独的客户上调用,然后将其放回一个集合中。顺便说一句,Groovy发现这种模式是如此普遍,以至于实际上它有一个语法糖:customer*.getAddress()*.street()。该运算符称为散点,实际上是一种map伪装。也许您想知道为什么我要在list内部手动迭代map而不是使用StreamJava 8中的s list.stream().map(f).collect(toList())?这会响吗?如果我java.util.stream.Stream用Java 告诉您也是Functors怎么办?顺便说一句,一个Monads?

现在,您应该看到Functors的第一个好处-它们抽象了内部表示形式,并为各种数据结构提供了一致且易于使用的API。作为最后一个示例,让我介绍类似于的 promise函数Future。Promise“承诺”有一天将提供一个值。它尚未出现,可能是因为产生了一些后台计算,或者我们正在等待外部事件。但是它将在将来的某个时间出现。完成a Promise的机制并不有趣,但是Functors的性质是:
Promise customer = //…
Promise<byte[]> bytes = customer
.map(Customer::getAddress)
.map(Address::street)
.map((String s) -> s.substring(0, 3))
.map(String::toLowerCase)
.map(String::getBytes);
看起来很熟悉?这就是我想说的!Functors的实现超出了本文的范围,甚至不重要。不用说,我们非常接近从Java 8实现CompletableFuture,并且几乎从RxJava中发现了Observable。但是回到Functors。Promise <客户>尚未持有客户的值。它有望在将来具有这种价值。但是,我们仍然可以像使用FOptional和FList一样映射此类Functors-语法和语义完全相同。行为遵循Functors表示的内容。调用customer.map(Customer :: getAddress)会产生Promise

,这意味着地图是非阻塞的。customer.map()将客户承诺完成。相反,它将返回另一个不同类型的promise。当上游承诺完成后,下游承诺应用传递给map()的函数并将结果传递给下游。突然,我们的Functors使我们能够以非阻塞方式流水线进行异步计算。但是您不必了解或学习-因为Promise是Functors,所以它必须遵循语法和法则。
Functors还有许多其他很好的例子,例如以组合方式表示值或错误。但是现在是时候看看Monads了。
从 Functors到Monads

我假设您了解Functors是如何工作的,为什么它们是有用的抽象。但是Functors并不像人们期望的那样普遍。如果您的转换函数(作为map()的一个参数传递)返回Functors实例而不是简单值,会发生什么情况?好吧,Functors也是一个值,因此不会发生任何不良情况。将返回的所有内容放回Functors中,以便所有行为都保持一致。但是,假设您有以下方便的方法来解析字符串:
FOptional tryParse(String s) {
try {
final int i = Integer.parseInt(s);
return FOptional.of(i);
} catch (NumberFormatException e) {
return FOptional.empty();
}
}
例外是会影响类型系统和功能纯度的副作用。在纯函数式语言中,没有例外的地方。毕竟,我们从未听说过在数学课上抛出异常,对吗?错误和非法条件使用值和包装器明确表示。例如,tryParse()接受一个String,而不是简单地返回一个int或在运行时静默引发异常。通过类型系统,我们明确地告诉了tryParse()可能失败,字符串格式错误没有任何异常或错误。此半故障由可选结果表示。有趣的是,Java已经检查了必须声明和处理的异常,因此从某种意义上讲,Java在这方面更纯净,它没有隐藏副作用。但是对于Java中通常不建议检查的异常情况,因此,让我们回到tryParse()。用已经包装在FOptional中的String组成tryParse似乎很有用:
FOptional str = FOptional.of(“42”);
FOptional<FOptional> num = str.map(this::tryParse);
这不足为奇。如果tryParse()返回a,int您将得到FOptional num,但是由于map()函数FOptional本身返回,因此将其包装两次成尴尬FOptional<FOptional>。请仔细查看类型,您必须了解为什么我们在这里得到这种双重包装。除了看上去很恐怖之外,在Functors中放一个Functors会破坏构图和流畅的链接:
FOptional num1 = //…
FOptional<FOptional> num2 = //…
FOptional date1 = num1.map(t -> new Date(t));
//doesn’t compile!
FOptional date2 = num2.map(t -> new Date(t));
在这里,我们尝试FOptional通过转换int为+ Date + 映射内容。有了int -> Date我们可以轻松地从转换Functor为的功能Functor,我们知道它是如何工作的。但是在 num2 情况变得复杂的情况下。什么num2.map()接收输入的不再是一个int,但一个FOoption显然java.util.Date不具备这样的构造。我们通过双重包裹打破了Functors。但是,拥有返回Functors而不是简单值的函数非常普遍(如tryParse()),我们不能简单地忽略这种要求。一种方法是引入一种特殊的无参数join()方法,以“展平”嵌套Functors:
FOptional num3 = num2.join()
它可以工作,但是因为这种模式太普遍了,所以flatMap()引入了名为的特殊方法。flatMap()与以下内容非常相似,map但希望作为参数接收的函数返回Functors-或准确地说是monad:
interface Monad<T,M extends Monad<?,?>> extends Functor<T,M> {
M flatMap(Function<T,M> f);
}
我们简单地得出结论,这flatMap只是一种语法糖,可以使成分更好。但是flatMap方法(通常称为Haskell bind或>>=从Haskell 调用)具有所有不同,因为它允许以纯净的功能样式构成复杂的转换。如果FOptional是monad的实例,则解析突然可以按预期进行:
FOptional num = FOptional.of(“42”);
FOptional answer = num.flatMap(this::tryParse);
Monads不需要实现map,它可以flatMap()很容易地实现。事实上flatMap,必不可少的运算符可实现全新的转换领域。显然,就像Functors一样,句法顺从性不足以将某类称为Monads,flatMap()操作员必须遵守Monads法则,但是它们非常直观,就像flatMap()与身份的结合一样。后者要求m(x).flatMap(f)与f(x)持有值x和函数的任何monad 相同f。我们不会深入研究monad理论,而让我们关注实际含义。例如,当单声道内部结构不重要时,它们会发光Promise未来将具有价值的monad。您可以从类型系统中猜出Promise在以下程序中将如何运行吗?首先,所有可能花费一些时间才能完成的方法都返回a Promise:
import java.time.DayOfWeek;
Promise loadCustomer(int id) {
//…
}
Promise readBasket(Customer customer) {
//…
}
Promise calculateDiscount(Basket basket, DayOfWeek dow) {
//…
}
现在,我们可以像使用monadic运算符一样阻止所有这些函数的方式编写这些函数:
Promise discount =
loadCustomer(42)
.flatMap(this::readBasket)
.flatMap(b -> calculateDiscount(b, DayOfWeek.FRIDAY));
这变得很有趣。flatMap()必须保留Monads类型,因此所有中间对象均为Promises。这不仅仅是保持类型有序-前一个程序突然完全异步!loadCustomer()返回一个,Promise因此它不会阻塞。readBasket()接受Promise具有(将具有)的任何东西,并应用返回另一个函数的函数Promise,依此类推。基本上,我们建立了一个异步计算管道,其中后台完成一个步骤会自动触发下一步。
探索 flatMap()
有两个Monads并将它们包含的值组合在一起是很常见的。但是,Functors和monad都不允许直接访问其内部,这是不纯的。相反,我们必须谨慎地应用转换,而不能逃脱monad。假设您有两个Monads,并且想要将它们合并:
import java.time.LocalDate;
import java.time.Month;
Monad month = //…
Monad dayOfMonth = //…
Monad date = month.flatMap((Month m) ->
dayOfMonth
.map((int d) -> LocalDate.of(2016, m, d)));
请花点时间研究前面的伪代码。我不使用任何真正的monad实现方式,Promise也不List强调核心概念。我们有两个独立的Monads,一个是type Month,另一个是type Integer。为了构建LocalDate它们,我们必须构建一个嵌套的转换,该转换可以访问两个monad的内部。仔细研究这些类型,尤其要确保您了解为什么我们flatMap在一个地方和另一个地方使用map()。想想如果您也有三分之一的话,将如何构造该代码Monad。应用的两个参数的函数(的这种模式m,并d在我们的例子)是很常见的,在Haskell有一个名为特殊辅助函数liftM2正是在map和之上实现的转换flatMap。在Java伪语法中,它看起来像这样:
Monad liftM2(Monad t1, Monad t2, BiFunction<T1, T2, R> fun) {
return t1.flatMap((T1 tv1) ->
t2.map((T2 tv2) -> fun.apply(tv1, tv2))
);
}
您不必为每个monad都实现此方法,这flatMap()已经足够了,而且,它对所有monad都一致地起作用。liftM2当您考虑如何将其与各种monad结合使用时,它非常有用。例如,listM2(list1, list2, function)将应用于和(笛卡尔积)function上的所有可能的项目对。另一方面,对于可选选项,仅当两个可选选项均为非空时,它将应用功能。更好的是,对于 monad,当两个都完成时,函数将异步执行。这意味着我们只是发明了一个简单的同步机制(在fork-join算法中),该机制包含两个异步步骤。list1list2Promise Promisejoin()
我们可以轻松构建的另一个有用的运算符flatMap()是filter(Predicate),它接受monad中的所有内容,如果不符合某些谓词,则将其完全丢弃。在某种程度上,它类似于map1-to-1映射,而不是1-to-1映射。同样filter(),每个monad具有相同的语义,但取决于我们实际使用的monad,其功能却非常出色。显然,它允许从列表中过滤掉某些元素:
FList vips =
customers.filter(c -> c.totalOrders > 1_000);
但是它也可以很好地工作,例如对于可选项目。在这种情况下,如果可选内容不符合某些条件,我们可以将非空可选转换为空。空的可选部分保持不变。
从Monads列表到Monads列表
源自flatMap()的另一个有用的运算符是sequence()。您只需查看类型签名即可轻松猜测其作用:
Monad<Iterable> sequence(Iterable<Monad> monads)
通常,我们有一堆相同类型的monad,而我们想要一个具有该类型列表的monad。这对您来说可能听起来很抽象,但却非常有用。想象一下,您想通过ID同时从数据库中加载一些客户,因此您loadCustomer(id)多次对不同的ID 使用方法,每次调用都返回Promise。现在,您有一个的列表,Promise但您真正想要的是一个客户列表,例如要在Web浏览器中显示的客户列表。将 sequence()(在RxJava sequence()被称为concat()或merge()根据使用情况)运算符刚建成为:
FList<Promise> custPromises = FList
.of(1, 2, 3)
.map(database::loadCustomer);
Promise<FList> customers = custPromises.sequence();
customers.map((FList c) -> …);
通过调用每个ID,FList我们拥有一个具有代表性的客户ID map(您知道它对FList仿函数有何帮助?)database.loadCustomer(id)。这导致Promises的列表非常不便。sequence()节省了一天的时间,但这再次不仅仅是语法糖。前面的代码是完全非阻塞的。对于不同种类的Monadssequence()还是有意义的,但是在不同的计算环境中。例如,它可以更改FList<FOptional>为FOptional<FList>。顺便说一句,您可以在之上实现sequence()(就像map())flatMap()。
flatMap()一般而言,这只是冰山一角。尽管源于晦涩的类别理论,但即使在Java等面向对象的编程语言中,monad也被证明是极其有用的抽象。能够组成返回Monads函数的函数非常有用,以至于数十个无关的类遵循Monads行为。
而且,一旦将数据封装在monad中,通常很难显式地将其取出。这种操作不是monad行为的一部分,并且经常导致非惯用语代码。例如,Promise.get()on Promise可以从技术上返回T,但只能通过阻塞返回,而所有基于的运算符flatMap()都是非阻塞的。另一个示例是FOptional.get(),但是可能失败,因为FOptional可能为空。即使FList.get(idx)从列表中偷看特定元素也听起来很尴尬,因为您可以经常替换for循环map()。
我希望您现在了解为什么现在这些Monads如此流行。即使在像Java这样的面向对象的语言中,它们也是非常有用的抽象。
最后,开发这么多年我也总结了一套学习Java的资料与面试题,如果你在技术上面想提升自己的话,可以关注我,私信发送领取资料或者在评论区留下自己的联系方式,有时间记得帮我点下转发让跟多的人看到哦。在这里插入图片描述

发布了98 篇原创文章 · 获赞 16 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/zhaozihao594/article/details/104295326