栏目分类:
子分类:
返回
名师互学网用户登录
快速导航关闭
当前搜索
当前分类
子分类
实用工具
热门搜索
名师互学网 > IT > 软件开发 > 后端开发 > Java

Java中的Functor和Monad

Java 更新时间: 发布时间: IT归档 最新发布 模块sitemap 名妆网 法律咨询 聚返吧 英语巴士网 伯小乐 网商动力

Java中的Functor和Monad

​ 随着最近函数式编程(或函数式编程风格)的兴起,monads成为一个广泛讨论的话题。关于他们有很多民间传说:

A monad is a monoid in the category of endofunctors, what’s the problem?

单子是内函子范畴中的一个幺半群,有什么问题吗?

​ — James Iry

The curse of the monad is that once you get the epiphany, once you understand - “oh that’s what it is” - you lose the ability to explain it to anybody.

单子的诅咒是,一旦你获得了顿悟,一旦你明白了——“哦,原来如此”——你就失去了向任何人解释的动力。

​ — Douglas Crockford

​ 绝大多数程序员,尤其是那些没有函数式编程背景的程序员,往往认为 monad是一些神秘的计算机科学概念,过于理论化以至于对编程生涯没有帮助。这种负面观点可归因于一些介绍 monad 的文章和博客过于抽象或过于狭隘。但事实证明,monad 就在我们身边,甚至是标准的 Java 库,尤其是从 Java Development Kit (JDK) 8开始。有趣的是,一旦你理解了 monad,就会对其他的几个概念一通百通。

​ Monad 概括了各种看似独立的概念,因此学习 monad的变体只需要很少的时间。例如,你不必学习Java 8 中CompletableFuture的工作原理,一旦你意识到它是一个 monad,你就会准确地知道它是如何工作的以及你可以从其语义中得到什么。你或许听说过RxJava,RxJava中的Observable也是一个 monad,所以RxJava也变得没那么神秘。你已经在不知不觉中遇到了许多的 monad示例,因此不要被那些高深的概念吓倒。

Functors

​ 在我们解释 monad是什么之前,让我们探索一个更简单的结构,称为functor(函子)。函子是封装一些值的类型化数据结构。从语法的角度来看,函子是一个具有以下 API 的容器:

import java.util.function.Function;

interface Functor {
    
     Functor map(Function f);
    
}

​ 但是仅仅语法合规不足以理解函子是什么。函子提供的唯一操作是map(),map()接受一个函数f,这个函数f接收函子中封装的值,转换它并将结果按原样包装到第二个函子中。函子始终是一个不可变的容器,因此map()永远不会改变执行它的原始对象,而是将结果包裹在一个全新的函子中,可能是不同的类型。此外,当应用恒等函数时,函子不应执行任何操作,即map(x -> x)。这种情况下应该总是返回相同的函子或相等的实例。

​ 通常Functor被比作一个盒子,里面装着值T,与T交互的唯一方法是使用map()对其进行转换。一般情况下,没有常用的方法让值T从函子中解脱出来,值始终保持在函子的上下文中。

​ 函子用一个统一的 API 概括了多个常见的习惯用法,如:collections, promises, optionals等。下面介绍几个函子,让你更流畅地使用这个 API:

interface Functor> {
     F map(Function f);
}

class Identity implements Functor> {

    private final T value;

    Identity(T value) { this.value = value; }

    public  Identity map(Function f) {
        final R result = f.apply(value);
        return new Identity<>(result);
    }
    
}

​ 上面的例子是个最简单的函子,只是持有一个值,对该值所能做的就是在map方法中对其进行转换,但无法提取它。与函子交互的唯一方法是进行一系列类型安全的转换:

Identity idString = new Identity<>("abc");
Identity idInt = idString.map(String::length);

你也可以使用链式写法:

Identity idBytes = new Identity<>(customer)
        .map(Customer::getAddress)
        .map(Address::street)
        .map((String s) -> s.substring(0, 3))
        .map(String::toLowerCase)
        .map(String::getBytes);

从这个角度来看,对函子进行映射与链式调用没有太大区别:

byte[] bytes = customer
        .getAddress()
        .street()
        .substring(0, 3)
        .toLowerCase()
        .getBytes();

​ 那为什么还要费心使用这种冗长的包装,不仅不能提供任何附加值,而且不能提取内容的结构呢?事实证明这个原始函子确实没多大用,但是可以使用这个原始函子抽象来模拟其他几个概念,例如:java.util.Optional,这个从 Java 8 开始时带有map()方法的函子。让我们从头开始实现它:

class FOptional implements Functor> {

    private final T valueOrNull;

    private FOptional(T valueOrNull) {
        this.valueOrNull = valueOrNull;
    }

    public  FOptional map(Function 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()实例。就像 Identity函子一样,FOptional是不可变的,我们只能从内部与值进行交互。不同的是,对 FOptional 来说,如果它为空,则转换函数f不会应用于任何值。这意味着函子不一定只封装一个值T。它也可以包装任意数量的值,就像List…函子一样:

import com.google.common.collect.ImmutableList;

class FList implements Functor> {

    private final ImmutableList list;

    FList(Iterable value) {
        this.list = ImmutableList.copyOf(value);
    }

    @Override
    public  FList map(Function f) {
        ArrayList result = new ArrayList(list.size());
        for (T t : list) {
            result.add(f.apply(t));
        }
        return new FList<>(result);
    }
}

​ API 保持不变(都是使用map()进行转换),但行为却大不相同。我们对FList中的每一项应用转换,声明性地转换整个列表。因此,如果你有一个列表customers并且想要得到他们的street,那么例子如下:

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()这种链式调用的方式不再能简单的得到结果,你不能对customers集合调用getAddress(),你必须对每个单独的customer调用getAddress(),然后将其放回集合中。顺便说一下,Groovy 发现这种模式非常普遍,以至于它实际上有一个语法糖:customer*.getAddress()*.street()。 这个方法,被称为spread-dot,实际上是一种map的伪装。也许你想知道为什么我在 map 里手动迭代列表,而不是使用Java 8中的 Stream :list.stream().map(f).collect(toList())?如果我告诉你java.util.stream.Stream在 Java 中也是函子呢?

​ 现在你应该看到函子的第一个好处,它们抽象出各种数据结构的内部表示,并提供一致的、易于使用的 API用于操作数据。作为最后一个例子,让我介绍一下promise函子,类似于Future。Promise“承诺”某个值有一天会变得可用,它还没有出现,可能是因为等待后台计算,或者正在等待外部事件,但它会在未来的某个时间出现。

Promise customer = //...
Promise bytes = customer
        .map(Customer::getAddress)
        .map(Address::street)
        .map((String s) -> s.substring(0, 3))
        .map(String::toLowerCase)
        .map(String::getBytes);

​ 是不是很熟悉?这就是我想说的!Promise现在还没有值,它承诺未来会有值,但是没有关系,就像 FOptional 和FList那样,我们依然可以对promise函子进行映射,语法和语义完全相同。customer.map()不会等待底层promise完成,而是生成另一个不同类型的promise,这意味着map是非阻塞的。当上游promise完成时,下游promise会应用传给 map() 的函数,然后将结果传递给更下游。突然之间,我们的函子允许我们以管道的方式进行非阻塞异步计算。但是你不必理解或学习——因为Promise也是一个函子,它遵循函子语法和定律。

​ 还有许多其他函子的例子,例如可以表示值或错误的函子。但现在是了解monad的时候了。

从functors到monads

​ 我假设你已经了解了函子的工作原理以及为什么它们是有用的抽象。但是函子并不像人们想象的那样普遍,如果你的转换函数(作为参数传递给 map()的那个)返回函子实例而不是简单的返回值,会发生什么?好吧,函子也只是一个值,所以没有什么不好的事情发生。返回的任何内容都放回函子中,因此所有行为都一致。然而,想象一下你有这个解析String的方法:

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()可能会成功也可能会失败,但不会有任何异常或错误。这种可选的结果由optional来表示。

​ 试着组合 一下tryParse 和 FOptional

FOptional str = FOptional.of("42");
FOptional> num = str.map(this::tryParse);

​ 问题出现了,tryParse()会返回一个FOptional,但因为map()函数会把FOptional当作转换后的值再次包装,变成了FOptional>,造成了包装两次的尴尬。请仔细回想一下函子的定义,搞清楚为什么这里得到这个双重包装器。除了看起来很糟糕之外,在 functor 中使用 functor 会破坏函子的组合性和流畅的转换链:

FOptional num1 = //...
FOptional> num2 = //...

FOptional date1 = num1.map(t -> new Date(t));

//doesn't compile!
FOptional date2 = num2.map(t -> new Date(t));

​ 这里我们尝试通过传入一个 int -> Date 的函数把 FOptional 中的int 转换成Date ,对于num1这很简单。但到了num2情况变得复杂了,num2.map()接收的函数f的输入不再是一个int,而是一个FOptional,显然 java.util.Date 没有一个参数类型是FOptional的构造器,num2.map()无法通过编译,双重包装破坏了我们的函子。然而,返回函子而不是返回简单值的函数是很常见的需求(如tryParse()),我们不能简单地忽略这样的需求。一种方案是是引入一种特殊的无参数join()方法来“flattens(展平)”嵌套函子:

FOptional num3 = num2.join()

​ 由于这种模式非常普遍,因此引入了特殊的方法叫作flatMap()。flatMap()非常类似于map(),但它期望接收函数作为参数,返回函子(而不是嵌套函子) ,或者准确地说 — monad

interface Monad> extends Functor {
    M flatMap(Function f);
}

​ 我们简单地得出结论, flatMap 只是一种语法糖,可以实现更好的组合。但是flatMap方法(在 Haskell中通常叫作 bind 或者 >>= )让一切变得不同了,因为它允许以纯函数式的风格组合复杂的转换。如果FOptional是monad的一个实例,那么之前的转换就能按预期工作:

FOptional num = FOptional.of("42");
FOptional answer = num.flatMap(this::tryParse);

​ Monads 不需要实现map,它可以简单的在 flatMap() 之上实现。事实上, flatMap 是打开transformations(变换)领域新世界的基本方法。就像函子一样,语法合规不足以将某个类称为 monad,flatMap()方法必须遵循 monad 定律,它们非常直观,就像 flatMap()和 identity 的结合性。后者要求对于任意持有值 x 的 monad和任意函数f, m(x).flatMap(f) 与f(x) 相同。我们不会深入研究 monad 理论,我们更关注其实际意义。例如, Promise monad将在未来持有值,你能从类型系统中猜测以下程序中Promise的行为吗?首先,所有可能需要一些时间才能完成的方法都返回一个Promise:

import java.time.DayOfWeek;


Promise loadCustomer(int id) {
    //...
}

Promise readBasket(Customer customer) {
    //...
}

Promise calculateDiscount(Basket basket, DayOfWeek dow) {
    //...
}

Promise discount = 
    loadCustomer(42)
        .flatMap(this::readBasket)
        .flatMap(b -> calculateDiscount(b, DayOfWeek.FRIDAY));
	`flatMap()`必须保留 `monadic` 类型,因此所有中间对象都是`Promise`。这不仅保持了类型的顺序,也使得前面的程序完全异步了。`loadCustomer()`返回 一个`Promise`,所以它不会阻塞, `readBasket()` 接收这个 `Promise` 以及它拥有(将拥有)的东西,在应用一个函数后返回另一个 `Promise` ,以此类推。基本上,我们构建了一个异步计算管道,后台完成一个步骤会自动触发下一步。
探索 flatMap()

​ 拥有两个 monad 并将它们包含的值组合在一起是很常见的。然而,函子和 monad 都不允许直接访问它们的内部,我们只能小心地应用转换。想象一下,你有两个 monad 并且想要将它们组合起来:

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,一个包装的类型是 Month,另一个是 Integer。为了构建LocalDate,我们必须构建一个可以访问两个 monad 内部结构的嵌套转换。仔细思考一下,为什么在一个地方使用flatMap()而在另一个地方使用map()。想想如果还有第三个Monad,你会如何构造这段代码。

​ 这种应用两个参数(在这个例子中是m和d)的函数是很常见的,在Haskell有一个名为 liftM2 特殊辅助函数,正是在map和flatmap之上实现的用于这种转换的函数。在 Java 伪语法中,就像这样:

Monad liftM2(Monad t1, Monad t2, BiFunction fun) {
    return t1.flatMap((T1 tv1) ->
            t2.map((T2 tv2) -> fun.apply(tv1, tv2))
    );
}

​ 你不必为每个 monad 实现这个方法,flatMap()就足够了,而且它对所有 monad 都能一致地工作。当你将其与其他monad一起使用时, liftM2 非常有用。例如: liftM2(list1, list2, function) 将会对 list1 和 list2中列表项的所有组合(笛卡尔积)应用函数function 。另一方面,对于optionals,只有当两个optional都不为空时,它才会应用一个函数。更骚的是,对于 Promise monad,只有当两个Promise 都完成时,才会异步的执行一个函数。这意味着我们发明了同步两个异步操作的简单同步机制(就像fork-join 算法中的join()一样)。

​ 我们可以在 flatMap() 之上构建另一个有用的方法filter(Predicate p),它接受 monad 中的内容T,如果T不满足谓词p,则将其丢弃。在某种程度上,它类似于map,但不是 1-to-1 映射,而是1-to-0-or-1。同样,filter()对于每个 monad具有相同的语义,但根据我们实际使用的 monad ,filter()具有amazing的功能。例如,它允许从列表中过滤掉某些元素:

FList vips = customers.filter(c -> c.totalOrders > 1_000);

​ 对于optionals它也同样适用。在这种情况下,如果 optional 的内容不符合某些标准,我们可以将非空 optional 转换为空的,空的 optional保持不变。

从monad的列表到列表的monad

​ 另一个有用的源自 flatMap()的方法是 sequence()。只需查看类型签名,就可以轻松猜出它的作用:

Monad> sequence(Iterable> monads)

​ 通常我们有一堆相同类型的monad,但我们希望有一个该类型列表的单个 monad。这可能听起来很抽象,但它非常有用。想象一下,你想通过 ID 从数据库中同时加载一些客户,因此你对不同的 ID多次使用了loadCustomer(id)方法,每次调用都返回Promise。现在你有了一个Promise列表,但你真正想要的是客户列表。sequence()(在 RxJava 中sequence()被称为concat()或者merge())方法就是为此而生的:

FList> custPromises = FList
    .of(1, 2, 3)
    .map(database::loadCustomer);

Promise> customers = custPromises.sequence();

customers.map((FList c) -> ...);

​ 对代表客户 ID 列表的FList中的每个ID应用database.loadCustomer(id)进行转换,就得到了相当不方便的Promise列表。幸好, sequence() 拯救了它。对于不同种类的 monad,在不同的计算上下文中,sequence()仍然有意义。例如,它可以将 FList> 更改为FOptional>。


​ 这其实只是flatMap()和monads实用性的冰山一角。尽管来自相当晦涩的范畴论,monads依然被证明是非常有用的抽象,即使在面向对象的编程语言(如 Java)中也是如此。能够组合函数返回monad是如此普遍有用,以至于许多不相关的类都遵循 monadic 行为。

​ 此外,一旦你将数据封装在 monad 中,通常很难明确地将其取出。取出这种操作不是 monad 行为的一部分,并且通常会破坏monad的哲学。例如,技术上 ,Promise 中的Promise.get()可以返回T,但只能通过阻塞的方式获取,而所有基于的flatMap()操作都是非阻塞的。另一个例子是FOptional.get(),FOptional.get()可能会失败,因为FOptional可能是空的。甚至是调用FList.get(idx)从列表中查看特定元素,也是不推荐的,因为通常你可以使用map()来替换for循环。

​ 现在,我希望你明白了为什么 monad 现在如此流行。即使在像 Java 这样的面向对象语言中,它们也是非常有用的抽象。

转载请注明:文章转载自 www.mshxw.com
本文地址:https://www.mshxw.com/it/338261.html
我们一直用心在做
关于我们 文章归档 网站地图 联系我们

版权所有 (c)2021-2022 MSHXW.COM

ICP备案号:晋ICP备2021003244-6号