Skip to content

和面试官聊聊实现管道和流计算的基石:函数式的Monad

函数式编程就是用函数写程序,当然这是一句废话。我相信大家都知道什么是用函数写程序。但是大家可能不知道如何通过函数去构造复杂的、大型的、可扩展的、可测试,复用率高,安全性强的、有数学证明的程序。

面向对象编程以现实世界为原型去投影程序的世界,适合对事务进行抽象、组织、分解;函数式编程则研究的是如何更好的进行计算。

计算是计算机最重要的能力。让计算没有副作用、让计算可以并行、让计算可以被数学证明、让计算更安全、让计算最大程度被复用,这些都是函数式编程的目标。

因此作为一个出色的Java工程师,是有必要学习一下函数式编程的。

这节课我是以函数式编程中的一个重要的概念,也是构造流计算的一个重要的概念——Monad。为大家讲解函数式编程。

关联面试题:

  1. Optional<T>的作用?
  2. FunctionalInterface在做什么?
  3. 说说你理解的函数式编程?
  4. 解释下什么是Monad?
  5. 如何实现管道和流?

引入:流计算的类型设计?

请你先看一下这个例子。一开始,我们构造了一个Optional<Integer>的类型。

 @Test
public void test_optional(){
    var result = Optional.of(100)
        .map(x -> x*x)
        .map(x -> x / 100);

}

我们用map进行映射,求了平方,好除以100。请你仔细观察类型的变化,看看有没有什么发现。

image-20210131105039067

上图中是类型的变化,数据源的类型是Optional<T>。中间map函数运算时,进行了拆箱,实际操作的类型是整数到整数的函数。最终的结果,依然是Optional<T>

那么家来请你思考两个问题:

  1. 为什么中间的运算不用Optional类型?
  2. 为什么两端的数据用Optional类型?

道理其实很简单。在我们进行流计算的这个过程当中,我们期望的是类型的统一、以及类型的安全。Optional类型支持空值,准确说,是empty/undefined类型,因此使用起来更加安全。

空值和空值进行操作,没有抛异常,而是继续返回空值。这让我们无论得到什么值,计算都可以继续。

if(!optionalValue.isPresent()) {
  return Optional.empty();
}
// 计算逻辑

这样的写法不可取。这样会产生大量的判断,这样的写法应该被封装起来。只有这样才能最大程度的减少程序员的心智负担。

看下下面的例子:

@Test
public void test_udef(){
    Optional<Integer> x= Optional.empty();
    var y = x.map(a -> a * a);
    System.out.println(y);
}

在上面的程序当中map内部实现了逻辑,对于empty的值,并不会进入计算逻辑,而是直接以empty的值返回,这样大大节省了用户进行判断的时间。

流计算之所以要设计这些中间的类型,是为了封装错误、避免问题、组织一致性的计算过程、以及惰性的求值过程……

Monad

上面我们描述的流计算的类型设计,实际上就是Monad模式。Monad有非常多的定义。比如说,数学上的。我们先给一个最简单的定义,是从设计模式上去思考。

一个Monad设计模式有3个显著的特点:

  • 一个泛型的构造函数。比如: Optional<T>
  • 不改变泛型类型的运算操作,内部是非泛型计算:例如:Optional<R> map(T -> R)
  • 泛型类型不变。 比如可以是Optional<Integer>Optional<String>,但还是Optional<T>类型。

你可能会说,这样的设计究竟有什么用?这个设计其实非常有价值。比如说你可以思考一下Stream<T>的设计。

@Test
public void test_stream(){
    var result = Stream.of("Hello", "World")
        .map(x -> x.length());
}

泛型类型不变是构造流计算的基石。Stream 是一类事物,这一类事物无论作用在怎样的操作上,最终结果还是Stream。这样map、filter、reduce等操作就能作用在任何类型的流上。

也是Monad的值。Moand设计模式中,除了要设计bind方法,还需要构造函数(或工厂方法)。这个构造函数用于构造Monad类型,比如Stream.of等等。

实战场景

管道和流计算是需要Monad的一个非常重要的场景。在我们实现业务逻辑的时候,抽象到非常领域化的逻辑也可以使用Monad。尽管通常这样的设计模式我们会被称之为连贯操作。但是设计的好的连贯操作。应该是Monad,因为Monad的拥有数学的证明,严密可靠。

比如:

SearchResult<Order> result = Search.ofTable(Tables.Order)
    .where(item -> item.amount > 100)
    .where(...)
    .search();


if(result.hasException()) {
    var ex = result.getException();
    // 处理异常
}

if(result.isEmpty()) {
    // 处理空集合
}

// 转换为列表
var list = result.toList();

上面程序中的SearchResult<T> Monad是对搜索结果的一种封装。where 是一个不改变SearchResult泛型类型的操作。上面的程序是惰性的。我们可以根据search 调用之前用户注入进来的过滤函数,生成最优的sql语句,组织缓存。

解读定义

最后我们来看看Monad的一些奇怪的定义,例如:Monad是一个自函子范畴上的一个幺半群。

函子接收一个函数,返回提升的版本:

(A -> B) -> (M<A> -> M<B>)

M理解成某种泛型类型。

例如:

Optional.of(100)
    .map(a -> a.toString());

map方法接收一个Integer -> String的方法,然后将Optional<Integer>(100)转换为Optional<String>("100")

上面的程序map构成了一个函子。

函数的定义

我们重新思考一下一个将类型A映射到类型B的泛型函数,如何描述:

@FunctionalInterface
interface FN<A, B> {
    B apply(A a);
}

函子(fuctor)的实现

那么函子是对函数的提升,将FN映射到MA->MB。M可以理解成某个构造函数,比如说Optional的构造函数。

子函子中A,B是相同的类型。

(A->A) -> (MA -> MB),

假设我们有一个封装的类型:Event<T>

    class Event<T> {
        T data;    
    
    
        static class EventData {
            Integer id;
            String msg;
    
            public EventData(Integer id, String msg) {
                this.id = id;
                this.msg = msg;
            }
        }
    }

里面最重要的数据是data ,我们想要将Integer类型的data,也就是事件编号,映射成为对象类型的数据,也就是EventData对象。我们先定义转换方法:

    static class Transforms {
        static EventData transform(Integer id) {
            switch(id) {
                case 0:
                    return new EventData(id, "Start");
                case 1:
                    return new EventData(id, "Running");
                case 2:
                    return new EventData(id, "Done");
                case 3:
                    return new EventData(id, "Fail");
                default:
                    return new EventData(id, "Error");
            }
        } 
    }

我们期望可以这样来使用(架构)程序:

    Stream<Event<Integer>> events = 
        Stream.of(new Event(0), new Event(1), new Event(2), new Event(10))
        .map(event -> event.map(Transforms::transform))
        .forEach(e -> {
            System.out.println(e.value);
        });

上面程序可以轻而易举的将整数型的事件流补充额外的信息,并且能将事件轻易的纳入流计算体系。最关键的,我们要实现Event.map方法。

    <B> Event<?> map(FN<T, B> f) {
        return new Event<>(f.apply(this.value));
    }

这个map方法传入的是一个T->B的函数。返回的是一个需要推导的Event<?>类型。这样也就相当于间接的实现了(A->B) ->(MA->MB)函数的转换。M就是Event,A是Integer, B是EventData。

自函子

准确的来说。上面的函子。是一个自函子(Endofuctor)。

函子将(A->B)映射到(C -> D)。自函子要求C、D是一类事物,比如M(Optional, Stream, Event等)。

幺半群(monoid)

注意到上面我们的Event类实现,实际上已经是一个Monad模式,可见自函子是实现Monad模式的必要手段。

我们将管道和流式计算引入到了Event<T>类型中。最大的特点,是map方法不改变泛型类型。计算用的函数,比如Transform::transform变成了一种可插拔的组件。或者说是管道节点。

这是Monad的架构神奇的地方。也是以后大家组织流式计算的必经步骤。写的专业的流式计算应该是Monad的。

从数学上或者专业点说。从范畴论上。Monad必须是一个monoid(幺半群),群指的是一类对象,比如说以Optional<T>、Event<T> ,Stream<T>就是群,群可以理解成满足某类关系的对象集合。

群通需要满足封闭性,以Optional<T>为例:

Optional.of(100) * Optional.of(100) = Optional.of(10000)

上面是一个逻辑概念,相当幺半群(集合中)的任意两个元素a(100), b(100),在二元运算符*之后,还是Optional类型(还在群中)。

实际的表达会是这样:

    Optional(100) | op1 | op2 | op3 …… | Optional<?>

一个Optional的值,经过无数次操作,还保留Optional的值。

另外,幺半群要求结合律:a*b*c = a*(b*c)

从Java程序上可以这样表达结合律:

Optional.of(100)
    .map(x -> x * 2)
    .map(x -> x + 3);
    
    // 等价于  
Optional.of(100)
  .map(x -> x*2 + 3)

结合率的目标是让计算符合人类的思考方式。人类会像处理步骤一步骤二步骤三这样去看待一次计算。而后面的步骤进行合并。并不会影响最后的结果。看似这样的约定很无聊,而且没有意义,但是如果不这样去约定,那么写出的程序就会和人的思考方式产生非常大的偏差。

最后就是单位元的(unit)的要求。所有的元素。都必须存在一个元素e 使得a * e = e * a =a 。对于Optional的设计,这个单位元就是Optional.empty()

最后请你回顾一下概念:Monad是一个自函子范畴上的一个幺半群。Moand本身一类数据,类似:泛型类M<?>。Monad需要用函子实现。幺半群(monoid)对Monad定义了详细的规则。

以下是Event<T>的全部程序:

    public class Event<T>{
    
        T value;
        public Event(T value) {
            this.value = value;
        }
    
        @FunctionalInterface
        interface FN<A, B> {
            B apply(A a);
        }
    
    
        static class EventData {
            Integer id;
            String msg;
    
            public EventData(Integer id, String msg) {
                this.id = id;
                this.msg = msg;
            }
    
            @Override
            public String toString() {
                return "EventData{" +
                        "id=" + id +
                        ", msg='" + msg + '\'' +
                        '}';
            }
        }
    
        static class Transforms {
            static EventData transform(Integer id) {
                switch(id) {
                    case 0:
                        return new EventData(id, "Start");
                    case 1:
                        return new EventData(id, "Running");
                    case 2:
                        return new EventData(id, "Done");
                    case 3:
                        return new EventData(id, "Fail");
                    default:
                        return new EventData(id, "Error");
                }
            }
        }
    
    
        <B> Event<?> map(FN<T, B> f) {
            return new Event<B>(f.apply(this.value));
        }
    
    
    
        public static void main(String[] argv) {
    
            Stream<Event<Integer>> s = Stream.of(new Event(0), new Event(1), new Event(2), new Event(10));
            s.map(event -> event.map(Transforms::transform))
                    .forEach(e -> {
                        System.out.println(e.value);
                    });
        }
    
    }

总结

Java中还有多种Monad的设计,除了Optional和Stream。还有CompletableFuture,封装针对异步运算管道的Monad。还有,Try:针对可能会产生异常操作的Monad。

函数式编程是一片汪洋大海。开始尝试用函数式编程封装自己的业务逻辑。我认为是成为一名领域化高手。第一步。尽管很多面向对象的高手不懂函数式编程。但是经过长期的摸索,往往都会靠近函数式的设计。尽管自己不自知而已。

如果你对函数式编程感兴趣,推荐你一篇翻译的文章。https://zhuanlan.zhihu.com/p/30510749

《认知科学家写给小白的lambda演算》

希望通过这一篇文章。可以帮助你入门函数式。

文章来源于自己总结和网络转载,内容如有任何问题,请大佬斧正!联系我