关于后端:函数式编程的Java编码实践利用惰性写出高性能且抽象的代码

34次阅读

共计 9149 个字符,预计需要花费 23 分钟才能阅读完成。

简介:本文会以惰性加载为例一步步介绍函数式编程中各种概念,所以读者不须要任何函数式编程的根底,只须要对 Java 8 有些许理解即可。

作者 | 悬衡
起源 | 阿里技术公众号

本文会以惰性加载为例一步步介绍函数式编程中各种概念,所以读者不须要任何函数式编程的根底,只须要对 Java 8 有些许理解即可。

一 形象肯定会导致代码性能升高?

程序员的幻想就是能写出“高内聚,低耦合”的代码,但从教训上来看,越形象的代码往往意味着越低的性能。机器能够间接执行的汇编性能最强,C 语言其次,Java 因为较高的抽象层次导致性能更低。业务零碎也受到同样的法则制约,底层的数增删改查接口性能最高,下层业务接口,因为减少了各种业务校验,以及音讯发送,导致性能较低。

对性能的顾虑,也制约程序员对于模块更加正当的形象。

一起来看一个常见的零碎形象,“用户”是零碎中常见的一个实体,为了对立零碎中的“用户”形象,咱们定义了一个通用畛域模型 User,除了用户的 id 外,还含有部门信息,用户的主管等等,这些都是经常在零碎中聚合在一起应用的属性:

public class User {
    // 用户 id
    private Long uid;
    // 用户的部门,为了放弃示例简略,这里就用一般的字符串
    // 须要近程调用 通讯录零碎 取得
    private String department;
    // 用户的主管,为了放弃示例简略,这里就用一个 id 示意
    // 须要近程调用 通讯录零碎 取得
    private Long supervisor;
    // 用户所持有的权限
    // 须要近程调用 权限零碎 取得
    private Set< String> permission;
}

这看起来十分棒,“用户“罕用的属性全副集中到了一个实体里,只有将这个 User 作为办法的参数,这个办法根本就不再须要查问其余用户信息了。然而一旦施行起来就会发现问题,部门和主管信息须要近程调用通讯录零碎取得,权限须要近程调用权限零碎取得,每次结构 User 都必须付出这两次近程调用的代价,即便有的信息没有用到。比方上面的办法就展现了这种状况(判断一个用户是否是另一个用户的主管):

public boolean isSupervisor(User u1, User u2) {return Objects.equals(u1.getSupervisor(), u2.getUid());
}

为了能在下面这个办法参数中应用通用 User 实体,必须付出额定的代价:近程调用取得齐全用不到的权限信息,如果权限零碎呈现了问题,还会影响无关接口的稳定性。

想到这里咱们可能就想要放弃通用实体的计划了,让袒露的 uid 洋溢在零碎中,在零碎各处散落用户信息查问代码。

其实稍作改良就能够持续应用下面的形象,只须要将 department, supervisor 和 permission 全副变成惰性加载的字段,在须要的时候才进行内部调用取得,这样做有十分多的益处:

  • 业务建模只须要思考贴合业务,而不须要思考底层的性能问题,真正实现业务层和物理层的解耦
  • 业务逻辑与内部调用拆散,无论内部接口如何变动,咱们总是有一层适配层保障外围逻辑的稳固
  • 业务逻辑看起来就是纯正的实体操作,易于编写单元测试,保障外围逻辑的正确性

然而在实际的过程中常会遇到一些问题,本文就联合 Java 以及函数式编程的一些技巧,一起来实现一个惰性加载工具类。

二 严格与惰性:Java 8 的 Supplier 的实质

Java 8 引入了全新的函数式接口 Supplier,从老 Java 程序员的角度了解,它不过就是一个能够获取任意值的接口而已,Lambda 不过是这种接口实现类的语法糖。这是站在语言角度而不是计算角度的了解。当你理解了严格(strict)与惰性(lazy)的区别之后,可能会有更加靠近计算实质的认识。

因为 Java 和 C 都是严格的编程语言,所以咱们习惯了变量在定义的中央就实现了计算。事实上,还有另外一个编程语言流派,它们是在变量应用的时候才进行计算的,比方函数式编程语言 Haskell。

所以 Supplier 的实质是在 Java 语言中引入了惰性计算的机制,为了在 Java 中实现等价的惰性计算,能够这么写:

Supplier< Integer> a = () -> 10 + 1;
int b = a.get() + 1;

三 Supplier 的进一步优化:Lazy

Supplier 还存在一个问题,就是每次通过 get 获取值时都会从新进行计算,真正的惰性计算应该在第一次 get 后把值缓存下来。只有对 Supplier 稍作包装即可:

/**
* 为了不便与规范的 Java 函数式接口交互,Lazy 也实现了 Supplier
*/
public class Lazy< T> implements Supplier< T> {

    private final Supplier< ? extends T> supplier;
    
    // 利用 value 属性缓存 supplier 计算后的值
    private T value;

    private Lazy(Supplier< ? extends T> supplier) {this.supplier = supplier;}

    public static < T> Lazy< T> of(Supplier< ? extends T> supplier) {return new Lazy< >(supplier);
    }

    public T get() {if (value == null) {T newValue = supplier.get();

            if (newValue == null) {throw new IllegalStateException("Lazy value can not be null!");
            }

            value = newValue;
        }

        return value;
    }
}

通过 Lazy 来写之前的惰性计算代码:

Lazy< Integer> a = Lazy.of(() -> 10 + 1);
int b = a.get() + 1;
// get 不会再从新计算, 间接用缓存的值
int c = a.get();

通过这个惰性加载工具类来优化咱们之前的通用用户实体:

public class User {
    // 用户 id
    private Long uid;
    // 用户的部门,为了放弃示例简略,这里就用一般的字符串
    // 须要近程调用 通讯录零碎 取得
    private Lazy< String> department;
    // 用户的主管,为了放弃示例简略,这里就用一个 id 示意
    // 须要近程调用 通讯录零碎 取得
    private Lazy< Long> supervisor;
    // 用户所含有的权限
    // 须要近程调用 权限零碎 取得
    private Lazy< Set< String>> permission;
    
    public Long getUid() {return uid;}
    
    public void setUid(Long uid) {this.uid = uid;}
    
    public String getDepartment() {return department.get();
    }
    
    /**
    * 因为 department 是一个惰性加载的属性,所以 set 办法必须传入计算函数,而不是具体值
    */
    public void setDepartment(Lazy< String> department) {this.department = department;}
    // ... 前面相似的省略
}

一个简略的结构 User 实体的例子如下:

Long uid = 1L;
User user = new User();
user.setUid(uid);
// departmentService 是一个 rpc 调用
user.setDepartment(Lazy.of(() -> departmentService.getDepartment(uid)));
// ....

这看起来还不错,但当你持续深刻应用时会发现一些问题:用户的两个属性部门和主管是有相关性,须要通过 rpc 接口取得用户部门,而后通过另一个 rpc 接口依据部门取得主管。代码如下:

String department = departmentService.getDepartment(uid);
Long supervisor = SupervisorService.getSupervisor(department);

然而当初 department 不再是一个计算好的值了,而是一个惰性计算的 Lazy 对象,下面的代码又应该怎么写呢?” 函子 ” 就是用来解决这个问题的

四 Lazy 实现函子(Functor)

疾速了解:相似 Java 中的 stream api 或者 Optional 中的 map 办法。函子能够了解为一个接口,而 map 能够了解为接口中的办法。

1 函子的计算对象

Java 中的 Collection< T>,Optional< T>,以及咱们刚刚实现 Lazy< T>,都有一个独特特点,就是他们都有且仅有一个泛型参数,咱们在这篇文章中暂且称其为盒子,记做 Box< T>,因为他们都如同一个万能的容器,能够任意类型打包进去。

2 函子的定义

函子运算能够将一个 T 映射到 S 的 function 利用到 Box< T> 上,让其成为 Box< S>,一个将 Box 中的数字转换为字符串的例子如下:

在盒子中装的是类型,而不是 1 和 “1” 的起因是,盒子中不肯定是单个值,比方汇合,甚至是更加简单的多值映射关系。

须要留神的是,并不是轻易定义一个签名满足 Box< S> map(Function< T,S> function) 就能让 Box< T> 成为函子的,上面就是一个反例:

// 反例,不能成为函子,因为这个办法没有在盒子中如实反映 function 的映射关系
public Box< S> map(Function< T,S> function) {return new Box< >(null);
}

所以函子是比 map 办法更加严格的定义,他还要求 map 满足如下的定律,称为 函子定律(定律的实质就是保障 map 办法能如实反映参数 function 定义的映射关系):

  • 单位元律:Box< T> 在利用了恒等函数后,值不会扭转,即 box.equals(box.map(Function.identity()))始终成立(这里的 equals 只是想表白的一个数学上相等的含意)
  • 复合律:假如有两个函数 f1 和 f2,map(x -> f2(f1(x))) 和 map(f1).map(f2) 始终等价

很显然 Lazy 是满足下面两个定律的。

3 Lazy 函子

尽管介绍了这么多实践,实现却非常简单:

public < S> Lazy< S> map(Function< ? super T, ? extends S> function) {return Lazy.of(() -> function.apply(get()));
  }

能够很容易地证实它是满足函子定律的。

通过 map 咱们很容易解决之前遇到的难题,map 中传入的函数能够在假如部门信息曾经获取到的状况下进行运算:

Lazy< String> departmentLazy = Lazy.of(() -> departmentService.getDepartment(uid));
Lazy< Long> supervisorLazy = departmentLazy.map(department -> SupervisorService.getSupervisor(department)
);

4 遇到了更加辣手的状况

咱们当初不仅能够结构惰性的值,还能够用一个惰性值计算另一个惰性值,看上去很完满。然而当你进一步深刻应用的时候,又发现了更加辣手的问题。

我当初须要部门和主管两个参数来调用权限零碎来取得权限,而部门和主管这两个值都是惰性的值。先用嵌套 map 来试一下:

Lazy< Lazy< Set< String>>> permissions = departmentLazy.map(department ->
         supervisorLazy.map(supervisor -> getPermissions(department, supervisor))
);

返回值的类型如同有点奇怪,咱们期待失去的是 Lazy< Set< String>>,这里失去的却多了一层变成 Lazy< Lazy< Set< String>>>。而且随着你嵌套 map 层数减少,Lazy 的泛型档次也会同样减少,三参数的例子如下:

Lazy< Long> param1Lazy = Lazy.of(() -> 2L);
Lazy< Long> param2Lazy = Lazy.of(() -> 2L);
Lazy< Long> param3Lazy = Lazy.of(() -> 2L);
Lazy< Lazy< Lazy< Long>>> result = param1Lazy.map(param1 ->
        param2Lazy.map(param2 ->
                param3Lazy.map(param3 -> param1 + param2 + param3)
        )
);

这个就须要上面的单子运算来解决了。

五 Lazy 实现单子(Monad)

疾速了解:和 Java stream api 以及 Optional 中的 flatmap 性能相似

1 单子的定义

单子和函子的重大区别在于接管的函数,函子的函数个别返回的是原生的值,而单子的函数返回却是一个盒装的值。下图中的 function 如果用 map 而不是 flatmap 的话,就会导致后果变成一个俄罗斯套娃 – 两层盒子。

单子当然也有单子定律,然而比函子定律要简单些,这里就不做阐释了,他的作用和函子定律也是相似,确保 flatmap 可能如实反映 function 的映射关系。

2 Lazy 单子

实现同样很简略:

    public < S> Lazy< S> flatMap(Function< ? super T, Lazy< ? extends S>> function) {return Lazy.of(() -> function.apply(get()).get());
    }

利用 flatmap 解决之前遇到的问题:

Lazy< Set< String>> permissions = departmentLazy.flatMap(department ->
         supervisorLazy.map(supervisor -> getPermissions(department, supervisor))
);

三参数的状况:

Lazy< Long> param1Lazy = Lazy.of(() -> 2L);
Lazy< Long> param2Lazy = Lazy.of(() -> 2L);
Lazy< Long> param3Lazy = Lazy.of(() -> 2L);
Lazy< Long> result = param1Lazy.flatMap(param1 ->
        param2Lazy.flatMap(param2 ->
                param3Lazy.map(param3 -> param1 + param2 + param3)
        )
);

其中的法则就是,最初一次取值用 map,其余都用 flatmap。

3 题外话:函数式语言中的单子语法糖

看了下面的例子你肯定会感觉惰性计算好麻烦,每次为了取外面的惰性值都要经验屡次的 flatmap 与 map。这其实是 Java 没有原生反对函数式编程而做的斗争之举,Haskell 中就反对用 do 记法简化 Monad 的运算,下面三参数的例子如果用 Haskell 则写做:

do
    param1 < - param1Lazy
    param2 < - param2Lazy
    param3 < - param3Lazy
    -- 正文: do 记法中 return 的含意和 Java 齐全不一样
    -- 它示意将值打包进盒子里,
    -- 等价的 Java 写法是 Lazy.of(() -> param1 + param2 + param3)
    return param1 + param2 + param3

Java 中尽管没有语法糖,然而上帝关了一扇门,就会关上一扇窗。在 Java 中能够清晰地看出每一步在做什么,了解其中的原理,如果你读过了本文之前的内容,必定能明确这个 do 记法就是不停地在做 flatmap。

六 Lazy 的最终代码

目前为止,咱们写的 Lazy 代码如下:

public class Lazy< T> implements Supplier< T> {

    private final Supplier< ? extends T> supplier;

    private T value;

    private Lazy(Supplier< ? extends T> supplier) {this.supplier = supplier;}

    public static < T> Lazy< T> of(Supplier< ? extends T> supplier) {return new Lazy< >(supplier);
    }

    public T get() {if (value == null) {T newValue = supplier.get();

            if (newValue == null) {throw new IllegalStateException("Lazy value can not be null!");
            }

            value = newValue;
        }

        return value;
    }

    public < S> Lazy< S> map(Function< ? super T, ? extends S> function) {return Lazy.of(() -> function.apply(get()));
    }

    public < S> Lazy< S> flatMap(Function< ? super T, Lazy< ? extends S>> function) {return Lazy.of(() -> function.apply(get()).get());
    }
}

七 结构一个可能主动优化性能的实体

利用 Lazy 咱们写一个结构通用 User 实体的工厂:

@Component
public class UserFactory {
    
    // 部门服务, rpc 接口
    @Resource
    private DepartmentService departmentService;
    
    // 主管服务, rpc 接口
    @Resource
    private SupervisorService supervisorService;
    
    // 权限服务, rpc 接口
    @Resource
    private PermissionService permissionService;
    
    public User buildUser(long uid) {Lazy< String> departmentLazy = Lazy.of(() -> departmentService.getDepartment(uid));
        // 通过部门取得主管
        // department -> supervisor
        Lazy< Long> supervisorLazy = departmentLazy.map(department -> SupervisorService.getSupervisor(department)
        );
        // 通过部门和主管取得权限
        // department, supervisor -> permission
        Lazy< Set< String>> permissionsLazy = departmentLazy.flatMap(department ->
            supervisorLazy.map(supervisor -> permissionService.getPermissions(department, supervisor)
            )
        );
        
        User user = new User();
        user.setUid(uid);
        user.setDepartment(departmentLazy);
        user.setSupervisor(supervisorLazy);
        user.setPermissions(permissionsLazy);
    }
}

工厂类就是在结构一颗求值树,通过工厂类能够清晰地看出 User 各个属性间的求值依赖关系,同时 User 对象可能在运行时主动地优化性能,一旦某个节点被求值,门路上的所有属性的值都会被缓存。

八 异样解决

尽管咱们通过惰性让 user.getDepartment() 好像是一次纯内存操作,然而他实际上还是一次近程调用,所以可能呈现各种出其不意的异样,比方超时等等。

异样解决必定不能交给业务逻辑,这样会影响业务逻辑的纯正性,让咱们半途而废。比拟现实的形式是交给惰性值的加载逻辑 Supplier。在 Supllier 的计算逻辑中就充分考虑各种异常情况,重试或者抛出异样。尽管抛出异样可能不是那么“函数式”,然而比拟贴近 Java 的编程习惯,而且在要害的值获取不到时就应该通过异样阻断业务逻辑的运行。

九 总结

利用本文办法结构的实体,能够将业务建模上须要的属性全副搁置进去,业务建模只须要思考贴合业务,而不须要思考底层的性能问题,真正实现业务层和物理层的解耦。

同时 UserFactory 实质上就是一个内部接口的适配层,一旦内部接口发生变化,只须要批改适配层即可,可能爱护外围业务代码的稳固。

业务外围代码因为内部调用大大减少,代码更加靠近纯正的运算,因此易于书写单元测试,通过单元测试可能保障外围代码的稳固且不会出错。

十 题外话:Java 中缺失的柯里化与利用函子(Applicative)

认真想想,刚刚做了这么多,目标就是一个,让签名为 C f(A,B) 的函数能够无需批改地利用到盒装类型 Box< A> 和 Box< B> 上,并且产生一个 Box< C>,在函数式语言中有更加不便的办法,那就是利用函子。

利用函子概念上非常简单,就是将盒装的函数利用到盒装的值上,最初失去一个盒装的值,在 Lazy 中能够这么实现:

    // 留神,这里的 function 是装在 lazy 外面的
    public < S> Lazy< S> apply(Lazy< Function< ? super T, ? extends S>> function) {return Lazy.of(() -> function.get().apply(get()));
    }

不过在 Java 中实现这个并没有什么用,因为 Java 不反对柯里化。

柯里化容许咱们将函数的几个参数固定下来变成一个新的函数,如果函数签名为 f(a,b),反对柯里化的语言容许间接 f(a) 进行调用,此时返回值是一个只接管 b 的函数。

在反对柯里化的状况下,只须要间断的几次利用函子,就能够将一般的函数利用在盒装类型上了,举个 Haskell 的例子如下(< *> 是 Haskell 中利用函子的语法糖, f 是个签名为 c f(a, b) 的函数,语法不完全正确,只是表白个意思):

-- 正文: 后果为 box c
box f < *> box a < *> box b

参考资料

  • 在 Java 函数式类库 VAVR 中提供了相似的 Lazy 实现,不过如果只是为了用这个一个类的话,引入整个库还是有些重,能够利用本文的思路间接本人实现
  • 函数式编程进阶:利用函子 前端角度的函数式编程文章,本文肯定水平上参考了外面盒子的类比办法:https://juejin.cn/post/689182…
  • 《Haskell 函数式编程根底》
  • 《Java 函数式编程》

原文链接
本文为阿里云原创内容,未经容许不得转载。

正文完
 0