和面试官聊聊实现管道和流计算的基石:函数式的Monad
函数式编程就是用函数写程序,当然这是一句废话。我相信大家都知道什么是用函数写程序。但是大家可能不知道如何通过函数去构造复杂的、大型的、可扩展的、可测试,复用率高,安全性强的、有数学证明的程序。
面向对象编程以现实世界为原型去投影程序的世界,适合对事务进行抽象、组织、分解;函数式编程则研究的是如何更好的进行计算。
计算是计算机最重要的能力。让计算没有副作用、让计算可以并行、让计算可以被数学证明、让计算更安全、让计算最大程度被复用,这些都是函数式编程的目标。
因此作为一个出色的Java工程师,是有必要学习一下函数式编程的。
这节课我是以函数式编程中的一个重要的概念,也是构造流计算的一个重要的概念——Monad。为大家讲解函数式编程。
关联面试题:
Optional<T>
的作用?- FunctionalInterface在做什么?
- 说说你理解的函数式编程?
- 解释下什么是Monad?
- 如何实现管道和流?
引入:流计算的类型设计?
请你先看一下这个例子。一开始,我们构造了一个Optional<Integer>
的类型。
@Test
public void test_optional(){
var result = Optional.of(100)
.map(x -> x*x)
.map(x -> x / 100);
}
我们用map进行映射,求了平方,好除以100。请你仔细观察类型的变化,看看有没有什么发现。
上图中是类型的变化,数据源的类型是Optional<T>
。中间map函数运算时,进行了拆箱,实际操作的类型是整数到整数的函数。最终的结果,依然是Optional<T>
。
那么家来请你思考两个问题:
- 为什么中间的运算不用Optional类型?
- 为什么两端的数据用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演算》
希望通过这一篇文章。可以帮助你入门函数式。