当我们谈论Monad的时候(一)

2022-01-14 16:41:29 浏览数 (1)

Monad不就是个自函子范畴上的幺半群,这有什么难理解的。 Phillip Wadler

当我们谈论Monad的时候,我们在谈论什么

坊间一直流传着一句话:“一百个学FP的人的心中就有一百个对Monad的理解”。而我相信,他们中的大部分人在看明白后又会写出一篇崭新的Monad文。我也一直很想写一写自己关于Monad的见解,但是一直找不到合适的说明方式。先前我在某群提到,从Optional(也就是Haskell的Maybe)理解Monad会是一个很不错的方式。而直到最近我正好看到了这样一篇文章(Reference 1),与我的想法不谋而合,于是我就借用这篇文章的方式谈一谈我对Monad的理解吧。

Monad作为函数式编程中最著名的几个输出概念之一,困扰了一批又一批想要学习的工程型选手。在我看来,主要的理解障碍有二:

  1. 定义晦涩难懂,一介绍Monad就要从Functor、Applicative一字排开。而大部分语言浅显的文章又“绕着Monad转”,就是不说Monad是什么
  2. 无法直观的看出Monad的用处。废了老大劲看完的文章,也不知道Monad能干嘛,看了几个示范的Monad又仿佛Monad什么都能干

综上,我打算用工程化的方式来解释Monad到底是什么。之后,用Haskell作为过渡,最后在讲讲理论相关的内容。而第一篇作为工程部分,自然用的是大家最喜欢的Java主要是我最喜欢来讲解了。

不过我先打个预防针,本篇文章是站在工程角度的浅显介绍,因此语言可能不甚严谨。

Monad是层数很高的抽象

Runnable一样,Monad是一个功能的抽象。在Java中,我们可以用接口类来描述它。就像你说ThreadRunnable一样,我们也同样可以说XX类Monad。实现了Monad要求的方法,你就可以用一些公用的方法来操作一个类了,就这么简单。

唯一的难点是,Monad要求实现的方法没有特定的功能。这比较像Comparable,而我们知道Comparable比较大小的语义纯粹只是人为增加的而已。只要符合一些规则(自反性、反对称性、传递性),你就可以编写一个靠谱的Comparable。Monad也一样,只不过Monad更加抽象。Monad这波绝对不止第五层

Functor

知道了Monad就是个抽象,下一步就是看看它要求什么接口、能帮我们干什么。于是这就避不开Functor了,因为Functor实际上是Monad的一个“初级形态”。不妨直接来看看Functor的一个Java定义:

代码语言:javascript复制
import java.util.function.Function;

public interface Functor<T> {
    <R> Functor<R> map(Function<T, R> f);
}

可以看到Functor是一个泛型类,接受一个泛型参数T。此外,Functor接口只需要实现一个map方法。这个map方法接受一个函数,它的参数类型为T,返回值类型为R,写作T -> R。此外,调用时我们还传入了Functor<T>类型的this。最后,函数返回了Functor<R>

既然明白了这个要求,我们就来举个例子看看Functor到底在抽象什么。

代码语言:javascript复制
import java.util.function.Function;

public class MyFunctor<T> implements Functor<T> {
    private final T value;

    public MyFunctor(T value) {
        this.value = value;
    }

    @Override
    public <R> MyFunctor<R> map(Function<T, R> f) {
        final R result = f.apply(value);
        return new MyFunctor<R>(result);
    }
}

简而言之,MyFunctor<T>就是一个T类型的容器,然后map就把参数的函数f应用到自己的身上,得到一个MyFunctor<R>。有什么用呢?嘛,这样你就可以写:

代码语言:javascript复制
new MyFunctor<>("Functor gives fmap")
    .map((String s) -> s.substring(0, 3))
    .map(String::toLowerCase)
    .map(String::getBytes);

好像根本没有什么锤子用,看起来我们只是把所有值的外边套了一个MyFunctor的娃,然后把一次次调用放在了map函数里。这种什么都不干,套了娃跟没套一样的Functor有一个名字:Identity。

虽然Identity好像没什么用,但是看到这个代码,不觉得有内味了嘛?什么味?这不就是Optional API的调用方法嘛!没错,稍微改一改,我们就能得到一个Optional。

代码语言:javascript复制
import java.util.function.Function;

public class MyOptional<T> implements Functor<T> {
    private final T valueOrNull;

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

    @Override
    public <R> MyOptional<R> map(Function<T, R> f) {
        if (valueOrNull == null)
            return empty();
        else
            return of(f.apply(valueOrNull));
    }

    public static <T> MyOptional<T> of(T a) {
        return new MyOptional<T>(a);
    }

    public static <T> MyOptional<T> empty() {
        return new MyOptional<T>(null);
    }
}

虽然调用的格式还是和Identity一样,只不过现在链式调用是完全空安全的。看到这里,Functor抽象的东西应该就很明显了:一个容器。而map抽象的,则是对容器内部值的操作。

而且由于Functor的抽象层数很高,因此它能抽象Optional这种有两个状态的容器。当然,抽象List这种不限长度的容器也是OK的。

代码语言:javascript复制
import com.google.common.collect.ImmutableList;

import java.util.ArrayList;
import java.util.function.Function;

public class FList<T> implements Functor<T, FList<?>> {
    private final ImmutableList<T> list;

    public FList(Iterable<T> value) {
        this.list = ImmutableList.copyOf(value);
    }

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

这样,我们就可以方便的对列表元素进行链式操作了。

Monad

但是Functor还是有一个问题,它没法解决嵌套。比如,如果我们希望计算两个MyOptional<Integer>的和,得到一个MyOptional<Integer>,那要怎么编码呢?

代码语言:javascript复制
MyOptional<Integer> optA = MyOptional.of(1);
MyOptional<Integer> optB = MyOptional.of(2);
var result = optA.map(a -> optB.map(b -> a   b));

我们虽然不能确定optAoptB内部的值(它们可能是null),但是通过map,我们可以变相的得到他们的真实值。这么写倒是没什么问题,但是,你猜猜result的类型是什么?没错,是:MyOptional<MyOptional<Integer>>。而且随着你需要的参数变多(这里是加法,故只需要两个),结果的套娃也会一层一层变多。这太丑陋了!

而且你细品这个娃品谁的娃?。你会发现内层MyOptional实际上是因为我们无法确定optB有无值而引入的。但是实际上我们希望达到的效果是,只要optAoptB有一个是empty的就返回MyOptional.empty(),否则就计算加法。因此,这个娃套的可以说完全没有必要。

Monad就是对这种没必要的套娃的抽象。Monad引入了一个join函数,把两层嵌套简化为一层:

代码语言:javascript复制
import java.util.function.Function;

public abstract class Monad<T> implements Functor<T> {
    public abstract <R> Monad<R> map(Function<T, R> f);

    public abstract <R> Monad<R> join(Monad<Monad<R>> m);
}

这里注意,join本来的意图是将Monad<Monad<R>>变成Monad<R>,因此理论上它应该是个静态方法。但是Java不允许声明抽象静态方法,只能变成这样了。

很明显,对于Optional实现join,我们只要检查内部值是不是null就可以安心返回内层Optional了。也就是:

代码语言:javascript复制
@Override
public <R> MyMonadOptional<R> join(Monad<Monad<R>> m) {
    var opt = (MyMonadOptional<Monad<R>>) m;
    if (opt.valueOrNull == null)
        return empty();
    else
        return (MyMonadOptional<R>) opt.valueOrNull;
}

这样我们只需要在调用的最后增加一个join,就可以消除这层没必要的嵌套了。

不光如此,有了join函数,我们还可以很简单的构造出flatMap函数。

代码语言:javascript复制
public <R> Monad<R> flatMap(Function<T, Monad<R>> f) {
    Monad<Monad<R>> result = this.map(f);
    return this.join(result);
}

在Optional的情况下,flatMap是用来实现返回值本身可能是null的函数,比如:

代码语言:javascript复制
MyMonadOptional<Integer> tryParse(String s) {
    try {
        final int i = Integer.parseInt(s);
        return MyMonadOptional.of(i);
    } catch (NumberFormatException e) {
        return MyMonadOptional.empty();
    }
}

使用flatMap,我们同样可以避免map会产生的嵌套问题。还记得开始的时候我们举得例子嘛?我们现在可以改写成:

代码语言:javascript复制
var mOptA = MyMonadOptional.of(1);
var mOptB = MyMonadOptional.of(2);
var ret = mOptA.flatMap(a -> mOptB.map(b -> a   b));
// ret is MyMonadOptional<Integer> !!

更有意思的一件事情是,使用flatMap也可以实现join函数。也就是说,我们也能定义出Monad!

代码语言:javascript复制
import java.util.function.Function;

public abstract class Monad<T> implements Functor<T> {
    public abstract <R> Monad<R> map(Function<T, R> f);

    public abstract <R> Monad<R> flatMap(Function<T, Monad<R>> f);

    public <R> Monad<R> join(Monad<Monad<R>> m) {
        return m.flatMap(Function.identity());
    }
}

而这个定义,就是大多数编程语言(比如Scala、Haskell)对Monad的定义。

Program in Monad

通过了刚才的介绍,你应该能找出不少现有的Monad。像一直举例用的Java的Optional API,Java的Stream API,还有ES6的Promise,它们本质上其实都是Monad。相信这些Monad你应该已经不难看出了,所以我想介绍一个略微特别的Monad——列表。

由于我们之前已经实现过列表的Functor了,因此我们只需要考虑它的join,也就是要设计一个把嵌套的列表变成不嵌套的函数。嘛,直接把他们连起来就可以了。比如:join([ [ 1, 2 ], [ 3, 4 ] ]) = [ 1, 2, 3, 4 ]

代码语言:javascript复制
import com.google.common.collect.ImmutableList;

import java.util.ArrayList;
import java.util.function.Function;

public class MonadList<T> extends Monad<T> {
    private final ImmutableList<T> list;

    public MonadList(Iterable<T> value) {
        this.list = ImmutableList.copyOf(value);
    }

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

    @Override
    public <R> MonadList<R> join(Monad<Monad<R>> m) {
        var lists = (MonadList<Monad<R>>) m;
        var result = new ArrayList<R>(list.size());
        for (var list : lists.list) {
            result.addAll(((MonadList<R>) list).list);
        }
        return new MonadList<>(result);
    }
}

接下来,我们看看对Monad开发的通用函数用在List身上是什么效果。还记得我们之前计算加法的模式嘛?稍微抽象下,我们就能得到一个对所有Monad都适用的函数:

代码语言:javascript复制
import java.util.function.BiFunction;

public class Functional {
    public static <T1, T2, R> Monad<R> liftM2(Monad<T1> t1, Monad<T2> t2, BiFunction<T1, T2, R> f) {
        return t1.flatMap((T1 tv1) ->
                t2.map((T2 tv2) -> f.apply(tv1, tv2))
        );
    }
}

那么问题来了。它用在List上是什么效果呢?

代码语言:javascript复制
var lstA = new MonadList<>(Arrays.asList(1, 2));
var lstB = new MonadList<>(Arrays.asList(4, 5));
var result = Functional.liftM2(lstA, lstB, Integer::sum);
// [ 5, 6, 6, 7 ]

没错,它返回了4个数字,而这正是两个列表内容的所有可能组合进行运算的结果(1 4、1 5、2 4、2 5)!liftM2作用于List的效果就是一个笛卡尔积。而且你细品,这不就是列表推导式嘛。

根据这个例子,不难看出:由于高度的抽象,基于Monad编写的函数(如liftM2)本身没有“明确的用途”。根据Monad的不同,它实际表现出来的作用很可能相当不同。我觉得代码复用的最高层次也莫过于此。

不确定性之盒

说了那么多,你应该能了解到Monad只是一个抽象结构而已。不过这么说确实还是太抽象了,所以我打算用一个经典的例子再次描述Monad——纸箱。

由于需要一个类型参数T,Monad几乎必然持有一个T类型的值(你确实可以写一个完全不持有的Monad,但是它什么都做不了)。但是这个T类型的值存在的“形式”是不确定的。对于Optional来说,它有可能存储一个T,也有可能是空的。对List来说,它储存的T元素数目是不确定的。所以Monad实际上是一个存储不确定性的纸箱

对于Monad,如果我们不“打开”它,我们就永远无法得知其中的内容,因为我们根本无法确定纸箱里面内容的具体形式。Monad的创意是,它用map来变相帮助我们读取它的内容!也就是说,Monad把处理数据的操作也变得不确定了。如果纸箱里有东西,我们就把它取出来处理,如没有东西就原封不动。操作的执行与否和纸箱里面的东西存在与否息息相关!而且Monad还允许我们引入新的不确定性。如果一个操作的结果就是一个纸箱,我们就不必再重复套纸箱了。

Monad的灵魂就在于不拆开纸箱。对于Optional,我们尽可能晚的打开纸箱(也就是get等等消去Optional的方法),这样我们就不用担心处理过程中的不确定性会影响整个流程了。而对于Promise,我们根本没办法打开纸箱。回调成了我们读取数据的唯一办法,而这就是Monad能抽象的部分。

下一个话题

到这里,我关于Monad工程角度的介绍就结束了。我个人认为,只是理解Monad的用途是没有必要,也没有意义去看Monad背后的数学定义的。

不过只从工程角度理解Monad是远远不够的。文中没有提及flatMap需要遵守的规则,对Monad的定义也不太完备(缺少了return),也没有细究join和flatMap的互相实现。要真正理解Monad,理论上的内容同样是不可避免的。

下一篇文章,我将简单介绍Haskell中的Monad实现与一些有趣的Monad,作为过渡。再下一篇,我将从理论角度(主要是范畴论)介绍Monad。

Reference

  1. Functional Programming in Pure Java: Functor and Monad Examples(https://dzone.com/articles/functor-and-monad-examples-in-plain-java)

0 人点赞