天天看點

函數式程式設計的Java編碼實踐:利用惰性寫出高性能且抽象的代碼

函數式程式設計的Java編碼實踐:利用惰性寫出高性能且抽象的代碼

作者 | 懸衡

來源 | 阿裡技術公衆号

本文會以惰性加載為例一步步介紹函數式程式設計中各種概念,是以讀者不需要任何函數式程式設計的基礎,隻需要對 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。

函數式程式設計的Java編碼實踐:利用惰性寫出高性能且抽象的代碼

是以 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>,因為他們都好像一個萬能的容器,可以任意類型打包進去。

函數式程式設計的Java編碼實踐:利用惰性寫出高性能且抽象的代碼

2 函子的定義

函子運算可以将一個 T 映射到 S 的 function 應用到 Box< T> 上,讓其成為 Box< S>,一個将 Box 中的數字轉換為字元串的例子如下:

函數式程式設計的Java編碼實踐:利用惰性寫出高性能且抽象的代碼

在盒子中裝的是類型,而不是 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 的話,就會導緻結果變成一個俄羅斯套娃--兩層盒子。

函數式程式設計的Java編碼實踐:利用惰性寫出高性能且抽象的代碼

單子當然也有單子定律,但是比函子定律要複雜些,這裡就不做闡釋了,他的作用和函子定律也是類似,確定 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 對象能夠在運作時自動地優化性能,一旦某個節點被求值,路徑上的所有屬性的值都會被緩存。

函數式程式設計的Java編碼實踐:利用惰性寫出高性能且抽象的代碼

八 異常處理

雖然我們通過惰性讓 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/6891820537736069134?spm=ata.21736010.0.0.595242a7a98f3U
  • 《Haskell函數式程式設計基礎》
  • 《Java函數式程式設計》

程式員是要專精,還是要廣度?

點選這裡

,檢視詳情!