函數(shù)式編程的Java編碼實(shí)踐:利用惰性寫出高性能且抽象的代碼
本文會(huì)以惰性加載為例一步步介紹函數(shù)式編程中各種概念,所以讀者不需要任何函數(shù)式編程的基礎(chǔ),只需要對 Java 8 有些許了解即可。
一 抽象一定會(huì)導(dǎo)致代碼性能降低?
程序員的夢想就是能寫出 “高內(nèi)聚,低耦合”的代碼,但從經(jīng)驗(yàn)上來看,越抽象的代碼往往意味著越低的性能。機(jī)器可以直接執(zhí)行的匯編性能最強(qiáng),C 語言其次,Java 因?yàn)檩^高的抽象層次導(dǎo)致性能更低。業(yè)務(wù)系統(tǒng)也受到同樣的規(guī)律制約,底層的數(shù)增刪改查接口性能最高,上層業(yè)務(wù)接口,因?yàn)樵黾恿烁鞣N業(yè)務(wù)校驗(yàn),以及消息發(fā)送,導(dǎo)致性能較低。
對性能的顧慮,也制約程序員對于模塊更加合理的抽象。
一起來看一個(gè)常見的系統(tǒng)抽象,“用戶” 是系統(tǒng)中常見的一個(gè)實(shí)體,為了統(tǒng)一系統(tǒng)中的 “用戶” 抽象,我們定義了一個(gè)通用領(lǐng)域模型 User,除了用戶的 id 外,還含有部門信息,用戶的主管等等,這些都是常常在系統(tǒng)中聚合在一起使用的屬性:
- public class User {
- // 用戶 id
- private Long uid;
- // 用戶的部門,為了保持示例簡單,這里就用普通的字符串
- // 需要遠(yuǎn)程調(diào)用 通訊錄系統(tǒng) 獲得
- private String department;
- // 用戶的主管,為了保持示例簡單,這里就用一個(gè) id 表示
- // 需要遠(yuǎn)程調(diào)用 通訊錄系統(tǒng) 獲得
- private Long supervisor;
- // 用戶所持有的權(quán)限
- // 需要遠(yuǎn)程調(diào)用 權(quán)限系統(tǒng) 獲得
- private Set<String> permission;
- }
這看起來非常棒,“用戶“常用的屬性全部集中到了一個(gè)實(shí)體里,只要將這個(gè) User 作為方法的參數(shù),這個(gè)方法基本就不再需要查詢其他用戶信息了。但是一旦實(shí)施起來就會(huì)發(fā)現(xiàn)問題,部門和主管信息需要遠(yuǎn)程調(diào)用通訊錄系統(tǒng)獲得,權(quán)限需要遠(yuǎn)程調(diào)用權(quán)限系統(tǒng)獲得,每次構(gòu)造 User 都必須付出這兩次遠(yuǎn)程調(diào)用的代價(jià),即使有的信息沒有用到。比如下面的方法就展示了這種情況(判斷一個(gè)用戶是否是另一個(gè)用戶的主管):
- public boolean isSupervisor(User u1, User u2) {
- return Objects.equals(u1.getSupervisor(), u2.getUid());
- }
為了能在上面這個(gè)方法參數(shù)中使用通用 User 實(shí)體,必須付出額外的代價(jià):遠(yuǎn)程調(diào)用獲得完全用不到的權(quán)限信息,如果權(quán)限系統(tǒng)出現(xiàn)了問題,還會(huì)影響無關(guān)接口的穩(wěn)定性。
想到這里我們可能就想要放棄通用實(shí)體的方案了,讓裸露的 uid 彌漫在系統(tǒng)中,在系統(tǒng)各處散落用戶信息查詢代碼。
其實(shí)稍作改進(jìn)就可以繼續(xù)使用上面的抽象,只需要將 department, supervisor 和 permission 全部變成惰性加載的字段,在需要的時(shí)候才進(jìn)行外部調(diào)用獲得,這樣做有非常多的好處:
- 業(yè)務(wù)建模只需要考慮貼合業(yè)務(wù),而不需要考慮底層的性能問題,真正實(shí)現(xiàn)業(yè)務(wù)層和物理層的解耦
- 業(yè)務(wù)邏輯與外部調(diào)用分離,無論外部接口如何變化,我們總是有一層適配層保證核心邏輯的穩(wěn)定
- 業(yè)務(wù)邏輯看起來就是純粹的實(shí)體操作,易于編寫單元測試,保障核心邏輯的正確性
但是在實(shí)踐的過程中常會(huì)遇到一些問題,本文就結(jié)合 Java 以及函數(shù)式編程的一些技巧,一起來實(shí)現(xiàn)一個(gè)惰性加載工具類。
二 嚴(yán)格與惰性:Java 8 的 Supplier 的本質(zhì)
Java 8 引入了全新的函數(shù)式接口 Supplier,從老 Java 程序員的角度理解,它不過就是一個(gè)可以獲取任意值的接口而已,Lambda 不過是這種接口實(shí)現(xiàn)類的語法糖。這是站在語言角度而不是計(jì)算角度的理解。當(dāng)你了解了嚴(yán)格(strict)與惰性(lazy)的區(qū)別之后,可能會(huì)有更加接近計(jì)算本質(zhì)的看法。
因?yàn)?Java 和 C 都是嚴(yán)格的編程語言,所以我們習(xí)慣了變量在定義的地方就完成了計(jì)算。事實(shí)上,還有另外一個(gè)編程語言流派,它們是在變量使用的時(shí)候才進(jìn)行計(jì)算的,比如函數(shù)式編程語言 Haskell。
所以 Supplier 的本質(zhì)是在 Java 語言中引入了惰性計(jì)算的機(jī)制,為了在 Java 中實(shí)現(xiàn)等價(jià)的惰性計(jì)算,可以這么寫:
- Supplier<Integer> a = () -> 10 + 1;
- int b = a.get() + 1;
三 Supplier 的進(jìn)一步優(yōu)化:Lazy
Supplier 還存在一個(gè)問題,就是每次通過 get 獲取值時(shí)都會(huì)重新進(jìn)行計(jì)算,真正的惰性計(jì)算應(yīng)該在第一次 get 后把值緩存下來。只要對 Supplier 稍作包裝即可:
- /**
- * 為了方便與標(biāo)準(zhǔn)的 Java 函數(shù)式接口交互,Lazy 也實(shí)現(xiàn)了 Supplier
- */
- public class Lazy<T> implements Supplier<T> {
- private final Supplier<? extends T> supplier;
- // 利用 value 屬性緩存 supplier 計(jì)算后的值
- 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 來寫之前的惰性計(jì)算代碼:
- Lazy<Integer> a = Lazy.of(() -> 10 + 1);
- int b = a.get() + 1;
- // get 不會(huì)再重新計(jì)算, 直接用緩存的值
- int c = a.get();
通過這個(gè)惰性加載工具類來優(yōu)化我們之前的通用用戶實(shí)體:
- public class User {
- // 用戶 id
- private Long uid;
- // 用戶的部門,為了保持示例簡單,這里就用普通的字符串
- // 需要遠(yuǎn)程調(diào)用 通訊錄系統(tǒng) 獲得
- private Lazy<String> department;
- // 用戶的主管,為了保持示例簡單,這里就用一個(gè) id 表示
- // 需要遠(yuǎn)程調(diào)用 通訊錄系統(tǒng) 獲得
- private Lazy<Long> supervisor;
- // 用戶所含有的權(quán)限
- // 需要遠(yuǎn)程調(diào)用 權(quán)限系統(tǒng) 獲得
- private Lazy<Set<String>> permission;
- public Long getUid() {
- return uid;
- }
- public void setUid(Long uid) {
- this.uid = uid;
- }
- public String getDepartment() {
- return department.get();
- }
- /**
- * 因?yàn)?nbsp;department 是一個(gè)惰性加載的屬性,所以 set 方法必須傳入計(jì)算函數(shù),而不是具體值
- */
- public void setDepartment(Lazy<String> department) {
- this.department = department;
- }
- // ... 后面類似的省略
- }
一個(gè)簡單的構(gòu)造 User 實(shí)體的例子如下:
- Long uid = 1L;
- User user = new User();
- user.setUid(uid);
- // departmentService 是一個(gè)rpc調(diào)用
- user.setDepartment(Lazy.of(() -> departmentService.getDepartment(uid)));
- // ....
這看起來還不錯(cuò),但當(dāng)你繼續(xù)深入使用時(shí)會(huì)發(fā)現(xiàn)一些問題:用戶的兩個(gè)屬性部門和主管是有相關(guān)性,需要通過 rpc 接口獲得用戶部門,然后通過另一個(gè) rpc 接口根據(jù)部門獲得主管。代碼如下:
- String department = departmentService.getDepartment(uid);
- Long supervisor = SupervisorService.getSupervisor(department);
但是現(xiàn)在 department 不再是一個(gè)計(jì)算好的值了,而是一個(gè)惰性計(jì)算的 Lazy 對象,上面的代碼又應(yīng)該怎么寫呢?"函子" 就是用來解決這個(gè)問題的
四 Lazy 實(shí)現(xiàn)函子(Functor)
快速理解:類似 Java 中的 stream api 或者 Optional 中的 map 方法。函子可以理解為一個(gè)接口,而 map 可以理解為接口中的方法。
1 函子的計(jì)算對象
Java 中的 Collection,Optional,以及我們剛剛實(shí)現(xiàn) Lazy,都有一個(gè)共同特點(diǎn),就是他們都有且僅有一個(gè)泛型參數(shù),我們在這篇文章中暫且稱其為盒子,記做 Box,因?yàn)樗麄兌己孟褚粋€(gè)萬能的容器,可以任意類型打包進(jìn)去。
2 函子的定義
函子運(yùn)算可以將一個(gè) T 映射到 S 的 function 應(yīng)用到 Box 上,讓其成為 Box,一個(gè)將 Box 中的數(shù)字轉(zhuǎn)換為字符串的例子如下:
在盒子中裝的是類型,而不是 1 和 "1" 的原因是,盒子中不一定是單個(gè)值,比如集合,甚至是更加復(fù)雜的多值映射關(guān)系。
需要注意的是,并不是隨便定義一個(gè)簽名滿足 Box map(Function function) 就能讓 Box 成為函子的,下面就是一個(gè)反例:
- // 反例,不能成為函子,因?yàn)檫@個(gè)方法沒有在盒子中如實(shí)反映 function 的映射關(guān)系
- public Box<S> map(Function<T,S> function) {
- return new Box<>(null);
- }
所以函子是比 map 方法更加嚴(yán)格的定義,他還要求 map 滿足如下的定律,稱為 函子定律(定律的本質(zhì)就是保障 map 方法能如實(shí)反映參數(shù) function 定義的映射關(guān)系):
- 單位元律:Box 在應(yīng)用了恒等函數(shù)后,值不會(huì)改變,即 box.equals(box.map(Function.identity()))始終成立(這里的 equals 只是想表達(dá)的一個(gè)數(shù)學(xué)上相等的含義)
- 復(fù)合律:假設(shè)有兩個(gè)函數(shù) f1 和 f2,map(x -> f2(f1(x))) 和 map(f1).map(f2) 始終等價(jià)
很顯然 Lazy 是滿足上面兩個(gè)定律的。
3 Lazy 函子
雖然介紹了這么多理論,實(shí)現(xiàn)卻非常簡單:
- public <S> Lazy<S> map(Function<? super T, ? extends S> function) {
- return Lazy.of(() -> function.apply(get()));
- }
可以很容易地證明它是滿足函子定律的。
通過 map 我們很容易解決之前遇到的難題,map 中傳入的函數(shù)可以在假設(shè)部門信息已經(jīng)獲取到的情況下進(jìn)行運(yùn)算:
- Lazy<String> departmentLazy = Lazy.of(() -> departmentService.getDepartment(uid));
- Lazy<Long> supervisorLazy = departmentLazy.map(
- department -> SupervisorService.getSupervisor(department)
- );
4 遇到了更加棘手的情況
我們現(xiàn)在不僅可以構(gòu)造惰性的值,還可以用一個(gè)惰性值計(jì)算另一個(gè)惰性值,看上去很完美。但是當(dāng)你進(jìn)一步深入使用的時(shí)候,又發(fā)現(xiàn)了更加棘手的問題。
我現(xiàn)在需要部門和主管兩個(gè)參數(shù)來調(diào)用權(quán)限系統(tǒng)來獲得權(quán)限,而部門和主管這兩個(gè)值都是惰性的值。先用嵌套 map 來試一下:
- Lazy<Lazy<Set<String>>> permissions = departmentLazy.map(department ->
- supervisorLazy.map(supervisor -> getPermissions(department, supervisor))
- );
返回值的類型好像有點(diǎn)奇怪,我們期待得到的是 Lazy>,這里得到的卻多了一層變成 Lazy>>。而且隨著你嵌套 map 層數(shù)增加,Lazy 的泛型層次也會(huì)同樣增加,三參數(shù)的例子如下:
- 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)
- )
- );
這個(gè)就需要下面的單子運(yùn)算來解決了。
五 Lazy 實(shí)現(xiàn)單子 (Monad)
快速理解:和 Java stream api 以及 Optional 中的 flatmap 功能類似
1 單子的定義
單子和函子的重大區(qū)別在于接收的函數(shù),函子的函數(shù)一般返回的是原生的值,而單子的函數(shù)返回卻是一個(gè)盒裝的值。下圖中的 function 如果用 map 而不是 flatmap 的話,就會(huì)導(dǎo)致結(jié)果變成一個(gè)俄羅斯套娃--兩層盒子。
單子當(dāng)然也有單子定律,但是比函子定律要復(fù)雜些,這里就不做闡釋了,他的作用和函子定律也是類似,確保 flatmap 能夠如實(shí)反映 function 的映射關(guān)系。
2 Lazy 單子
實(shí)現(xiàn)同樣很簡單:
- 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))
- );
三參數(shù)的情況:
- 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)
- )
- );
其中的規(guī)律就是,最后一次取值用 map,其他都用 flatmap。
3 題外話:函數(shù)式語言中的單子語法糖
看了上面的例子你一定會(huì)覺得惰性計(jì)算好麻煩,每次為了取里面的惰性值都要經(jīng)歷多次的 flatmap 與 map。這其實(shí)是 Java 沒有原生支持函數(shù)式編程而做的妥協(xié)之舉,Haskell 中就支持用 do 記法簡化 Monad 的運(yùn)算,上面三參數(shù)的例子如果用 Haskell 則寫做:
- do
- param1 <- param1Lazy
- param2 <- param2Lazy
- param3 <- param3Lazy
- -- 注釋: do 記法中 return 的含義和 Java 完全不一樣
- -- 它表示將值打包進(jìn)盒子里,
- -- 等價(jià)的 Java 寫法是 Lazy.of(() -> param1 + param2 + param3)
- return param1 + param2 + param3
Java 中雖然沒有語法糖,但是上帝關(guān)了一扇門,就會(huì)打開一扇窗。在 Java 中可以清晰地看出每一步在做什么,理解其中的原理,如果你讀過了本文之前的內(nèi)容,肯定能明白這個(gè) 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());
- }
- }
七 構(gòu)造一個(gè)能夠自動(dòng)優(yōu)化性能的實(shí)體
利用 Lazy 我們寫一個(gè)構(gòu)造通用 User 實(shí)體的工廠:
- @Component
- public class UserFactory {
- // 部門服務(wù), rpc 接口
- @Resource
- private DepartmentService departmentService;
- // 主管服務(wù), rpc 接口
- @Resource
- private SupervisorService supervisorService;
- // 權(quán)限服務(wù), 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)
- );
- // 通過部門和主管獲得權(quán)限
- // 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);
- }
- }
工廠類就是在構(gòu)造一顆求值樹,通過工廠類可以清晰地看出 User 各個(gè)屬性間的求值依賴關(guān)系,同時(shí) User 對象能夠在運(yùn)行時(shí)自動(dòng)地優(yōu)化性能,一旦某個(gè)節(jié)點(diǎn)被求值,路徑上的所有屬性的值都會(huì)被緩存。
八 異常處理
雖然我們通過惰性讓 user.getDepartment() 仿佛是一次純內(nèi)存操作,但是他實(shí)際上還是一次遠(yuǎn)程調(diào)用,所以可能出現(xiàn)各種出乎意料的異常,比如超時(shí)等等。
異常處理肯定不能交給業(yè)務(wù)邏輯,這樣會(huì)影響業(yè)務(wù)邏輯的純粹性,讓我們前功盡棄。比較理想的方式是交給惰性值的加載邏輯 Supplier。在 Supllier 的計(jì)算邏輯中就充分考慮各種異常情況,重試或者拋出異常。雖然拋出異常可能不是那么“函數(shù)式”,但是比較貼近 Java 的編程習(xí)慣,而且在關(guān)鍵的值獲取不到時(shí)就應(yīng)該通過異常阻斷業(yè)務(wù)邏輯的運(yùn)行。
九 總結(jié)
利用本文方法構(gòu)造的實(shí)體,可以將業(yè)務(wù)建模上需要的屬性全部放置進(jìn)去,業(yè)務(wù)建模只需要考慮貼合業(yè)務(wù),而不需要考慮底層的性能問題,真正實(shí)現(xiàn)業(yè)務(wù)層和物理層的解耦。
同時(shí) UserFactory 本質(zhì)上就是一個(gè)外部接口的適配層,一旦外部接口發(fā)生變化,只需要修改適配層即可,能夠保護(hù)核心業(yè)務(wù)代碼的穩(wěn)定。
業(yè)務(wù)核心代碼因?yàn)橥獠空{(diào)用大大減少,代碼更加接近純粹的運(yùn)算,因而易于書寫單元測試,通過單元測試能夠保證核心代碼的穩(wěn)定且不會(huì)出錯(cuò)。
十 題外話:Java 中缺失的柯里化與應(yīng)用函子(Applicative)
仔細(xì)想想,剛剛做了這么多,目的就是一個(gè),讓簽名為 C f(A,B) 的函數(shù)可以無需修改地應(yīng)用到盒裝類型 Box和 Box 上,并且產(chǎn)生一個(gè) Box,在函數(shù)式語言中有更加方便的方法,那就是應(yīng)用函子。
應(yīng)用函子概念上非常簡單,就是將盒裝的函數(shù)應(yīng)用到盒裝的值上,最后得到一個(gè)盒裝的值,在 Lazy 中可以這么實(shí)現(xiàn):
- // 注意,這里的 function 是裝在 lazy 里面的
- public <S> Lazy<S> apply(Lazy<Function<? super T, ? extends S>> function) {
- return Lazy.of(() -> function.get().apply(get()));
- }
不過在 Java 中實(shí)現(xiàn)這個(gè)并沒有什么用,因?yàn)?Java 不支持柯里化。
柯里化允許我們將函數(shù)的幾個(gè)參數(shù)固定下來變成一個(gè)新的函數(shù),假如函數(shù)簽名為 f(a,b),支持柯里化的語言允許直接 f(a) 進(jìn)行調(diào)用,此時(shí)返回值是一個(gè)只接收 b 的函數(shù)。
在支持柯里化的情況下,只需要連續(xù)的幾次應(yīng)用函子,就可以將普通的函數(shù)應(yīng)用在盒裝類型上了,舉個(gè) Haskell 的例子如下(<*> 是 Haskell 中應(yīng)用函子的語法糖, f 是個(gè)簽名為 c f(a, b) 的函數(shù),語法不完全正確,只是表達(dá)個(gè)意思):
- -- 注釋: 結(jié)果為 box c
- box f <*> box a <*> box b
參考資料
- 在 Java 函數(shù)式類庫 VAVR 中提供了類似的 Lazy 實(shí)現(xiàn),不過如果只是為了用這個(gè)一個(gè)類的話,引入整個(gè)庫還是有些重,可以利用本文的思路直接自己實(shí)現(xiàn)
- 函數(shù)式編程進(jìn)階:應(yīng)用函子 前端角度的函數(shù)式編程文章,本文一定程度上參考了里面盒子的類比方法:https://juejin.cn/post/6891820537736069134?spm=ata.21736010.0.0.595242a7a98f3U
- 《Haskell函數(shù)式編程基礎(chǔ)》
- 《Java函數(shù)式編程》