實用函式式 Java (PFJ)簡介

信碼由韁發表於2021-11-12

【注】本文譯自: Introduction To Pragmatic Functional Java - DZone Java

實用函式式(Pragmatic Funcational) Java 是一種基於函數語言程式設計概念的現代、非常簡潔但可讀的 Java 編碼風格。

實用函式式 Java (PFJ) 試圖定義一種新的慣用 Java 編碼風格。編碼風格,將完全利用當前和即將推出的 Java 版本的所有功能,並涉及編譯器來幫助編寫簡潔但可靠和可讀的程式碼。
雖然這種風格甚至可以在 Java 8 中使用,但在 Java 11 中它看起來更加簡潔和簡潔。它在 Java 17 中變得更具表現力,並受益於每個新的 Java 語言功能。
但 PFJ 不是免費的午餐,它需要開發人員的習慣和方法發生重大改變。改變習慣並不容易,傳統的命令式習慣尤其難以解決。
這值得麼? 確實! PFJ 程式碼簡潔、富有表現力且可靠。它易於閱讀和維護,並且在大多數情況下,如果程式碼可以編譯 - 它可以工作!

實用函式式 Java 的元素

PFJ 源自一本精彩的 Effective Java 書籍,其中包含一些額外的概念和約定,特別是源自函數語言程式設計(FP:Functional Programming)。請注意,儘管使用了 FP 概念,但 PFJ 並未嘗試強制執行特定於 FP 的術語。(儘管對於那些有興趣進一步探索這些概念的人,我們也提供了參考)。
PFJ專注於:

  • 減輕心理負擔。
  • 提高程式碼可靠性。
  • 提高長期可維護性。
  • 藉助編譯器來幫助編寫正確的程式碼。
  • 讓編寫正確的程式碼變得簡單而自然,編寫不正確的程式碼雖然仍然可能,但應該需要付出努力。

儘管目標雄心勃勃,但只有兩個關鍵的 PFJ 規則:

  • 儘可能避免 null
  • 沒有業務異常。

下面,更詳細地探討了每個關鍵規則:

儘可能避免 null(ANAMAP 規則)

變數的可空性是特殊狀態之一。它們是眾所周知的執行時錯誤和樣板程式碼的來源。為了消除這些問題並表示可能丟失的值,PFJ 使用 Option<T> 容器。這涵蓋了可能出現此類值的所有情況 - 返回值、輸入引數或欄位。
在某些情況下,例如出於效能或與現有框架相容性的原因,類可能會在內部使用 null。這些情況必須清楚記錄並且對類使用者不可見,即所有類 API 都應使用 Option<T>
這種方法有幾個優點:

  • 可空變數在程式碼中立即可見。無需閱讀文件、檢查原始碼或依賴註釋。
  • 編譯器區分可為空和不可為空的變數,並防止它們之間的錯誤賦值。
  • 消除了 null 檢查所需的所有樣板。

無業務異常(NBE 規則)

PFJ 僅使用異常來表示致命的、不可恢復的(技術)故障的情況。此類異常可能僅出於記錄和/或正常關閉應用程式的目的而被攔截。不鼓勵並儘可能避免所有其他異常及其攔截。
業務異常是特殊狀態的另一種情況。為了傳播和處理業務級錯誤,PFJ 使用 Result<T> 容器。同樣,這涵蓋了可能出現錯誤的所有情況 - 返回值、輸入引數或欄位。實踐表明,欄位很少(如果有的話)需要使用這個容器。
沒有任何正當的情況可以使用業務級異常。與通過專用包裝方法與現有 Java 庫和遺留程式碼互動。Result<T> 容器包含這些包裝方法的實現。
無業務異常規則具有以下優點:

  • 可以返回錯誤的方法在程式碼中立即可見。 無需閱讀 文件、檢查原始碼或分析呼叫樹,以檢查可以丟擲哪些異常以及在哪些條件下被丟擲。
  • 編譯器強制執行正確的錯誤處理和傳播。
  • 幾乎沒有錯誤處理和傳播的樣板。
  • 我們可以為快樂的日子場景編寫程式碼,並在最方便的點處理錯誤 - 異常的原始意圖,這一點實際上從未實現過。
  • 程式碼保持可組合、易於閱讀和推理,在執行流程中沒有隱藏的中斷或意外的轉換——你讀到的就是將要執行的

將遺留程式碼轉換為 PFJ 風格的程式碼

好的,關鍵規則看起來不錯而且很有用,但是真正的程式碼會是什麼樣子呢?
讓我們從一個非常典型的後端程式碼開始:

public interface UserRepository {
    User findById(User.Id userId);
}

public interface UserProfileRepository {
    UserProfile findById(User.Id userId);
}

public class UserService {
    private final UserRepository userRepository;
    private final UserProfileRepository userProfileRepository;

    public UserWithProfile getUserWithProfile(User.Id userId) {
        User user = userRepository.findById(userId);
        if (user == null) {
            throw UserNotFoundException("User with ID " + userId + " not found");
        }
        UserProfile details = userProfileRepository.findById(userId);
        return UserWithProfile.of(user, details == null ? UserProfile.defaultDetails() : details);
    }
}

示例開頭的介面是為了上下文清晰而提供的。主要的興趣點是 getUserWithProfile 方法。我們一步一步來分析。

  • 第一條語句從使用者儲存庫中檢索 user 變數。
  • 由於使用者可能不存在於儲存庫中,因此 user 變數可能為 null。以下 null 檢查驗證是否是這種情況,如果是,則丟擲業務異常。
  • 下一步是檢索使用者配置檔案詳細資訊。缺乏細節不被視為錯誤。相反,當缺少詳細資訊時,配置檔案將使用預設值。

上面的程式碼有幾個問題。首先,如果儲存庫中不存在值,則返回 null 從介面看並不明顯。 我們需要檢查文件,研究實現或猜測這些儲存庫是如何工作的。
有時使用註解來提供提示,但這仍然不能保證 API 的行為。
為了解決這個問題,讓我們將規則應用於儲存庫:

public interface UserRepository {
    Option<User> findById(User.Id userId);
}

public interface UserProfileRepository {
    Option<UserProfile> findById(User.Id userId);
}

現在無需進行任何猜測 - API 明確告知可能不存在返回值。
現在讓我們再看看 getUserWithProfile 方法。 要注意的第二件事是該方法可能會返回一個值或可能會引發異常。這是一個業務異常,因此我們可以應用該規則。更改的主要目標 - 明確方法可能返回值錯誤的事實:

public Result<UserWithProfile> getUserWithProfile(User.Id userId) {

好的,現在我們已經清理了 API,可以開始更改程式碼了。第一個變化是由 userRepository 現在返回
Option<User> 引起的:

public Result<UserWithProfile> getUserWithProfile(User.Id userId) {
    Option<User> user = userRepository.findById(userId);
}

現在我們需要檢查使用者是否存在,如果不存在,則返回一個錯誤。使用傳統的命令式方法,程式碼應該是這樣的:

public Result<UserWithProfile> getUserWithProfile(User.Id userId) {
    Option<User> user = userRepository.findById(userId);
   
    if (user.isEmpty()) {
        return Result.failure(Causes.cause("User with ID " + userId + " not found"));
    }

}
程式碼看起來不是很吸引人,但也不比原來的差,所以暫時保持原樣。
下一步是嘗試轉換剩餘部分的程式碼:

public Result<UserWithProfile> getUserWithProfile(User.Id userId) {
    Option<User> user = userRepository.findById(userId);
   
    if (user.isEmpty()) {
        return Result.failure(Causes.cause("User with ID " + userId + " not found"));
    }

    Option<UserProfile> details = userProfileRepository.findById(userId);
   
}

問題來了:詳細資訊和使用者儲存在 Option<T> 容器中,因此要組裝 UserWithProfile,我們需要以某種方式提取值。這裡可能有不同的方法,例如,使用 Option.fold() 方法。生成的程式碼肯定不會很漂亮,而且很可能會違反規則。
還有另一種方法 - 使用 Option<T> 是具有特殊屬性的容器這一事實。
特別是,可以使用 Option.map()Option.flatMap() 方法轉換 Option<T> 中的值。此外,我們知道,details 值將由儲存庫提供或替換為預設值。為此,我們可以使用 Option.or() 方法從容器中提取詳細資訊。讓我們試試這些方法:

public Result<UserWithProfile> getUserWithProfile(User.Id userId) {
    Option<User> user = userRepository.findById(userId);
   
    if (user.isEmpty()) {
        return Result.failure(Causes.cause("User with ID " + userId + " not found"));
    }

    UserProfile details = userProfileRepository.findById(userId).or(UserProfile.defaultDetails());
   
    Option<UserWithProfile> userWithProfile =  user.map(userValue -> UserWithProfile.of(userValue, details));
   
}

現在我們需要編寫最後一步 - 將 userWithProfile 容器從 Option<T> 轉換為 Result<T>

public Result<UserWithProfile> getUserWithProfile(User.Id userId) {
    Option<User> user = userRepository.findById(userId);
   
    if (user.isEmpty()) {
        return Result.failure(Causes.cause("User with ID " + userId + " not found"));
    }

    UserProfile details = userProfileRepository.findById(userId).or(UserProfile.defaultDetails());

    Option<UserWithProfile> userWithProfile =  user.map(userValue -> UserWithProfile.of(userValue, details));

    return userWithProfile.toResult(Cause.cause(""));
}

我們暫時將 return 語句中的錯誤原因留空,然後再次檢視程式碼。
我們可以很容易地發現一個問題:我們肯定知道 userWithProfile 總是存在 - 當 user 不存在時,上面已經處理了這種情況。我們怎樣才能解決這個問題?
請注意,我們可以在不檢查使用者是否存在的情況下呼叫 user.map()。僅當 user 存在時才會應用轉換,否則將被忽略。 這樣,我們可以消除 if(user.isEmpty()) 檢查。讓我們在傳遞給 user.map() 的 lambda 中移動對 Userdetails 檢索和轉換到 UserWithProfile 中:

public Result<UserWithProfile> getUserWithProfile(User.Id userId) {
    Option<UserWithProfile> userWithProfile = userRepository.findById(userId).map(userValue -> {
        UserProfile details = userProfileRepository.findById(userId).or(UserProfile.defaultDetails());
        return UserWithProfile.of(userValue, details);
    });
   
    return userWithProfile.toResult(Cause.cause(""));
}

現在需要更改最後一行,因為 userWithProfile 可能會缺失。該錯誤將與以前的版本相同,因為僅當 userRepository.findById(userId) 返回的值缺失時,userWithProfile 才會缺失:

public Result<UserWithProfile> getUserWithProfile(User.Id userId) {
    Option<UserWithProfile> userWithProfile = userRepository.findById(userId).map(userValue -> {
        UserProfile details = userProfileRepository.findById(userId).or(UserProfile.defaultDetails());
        return UserWithProfile.of(userValue, details);
    });
   
    return userWithProfile.toResult(Causes.cause("User with ID " + userId + " not found"));
}

最後,我們可以內聯 detailsuserWithProfile,因為它們僅在建立後立即使用一次:

public Result<UserWithProfile> getUserWithProfile(User.Id userId) {
    return userRepository.findById(userId)
        .map(userValue -> UserWithProfile.of(userValue, userProfileRepository.findById(userId)
                                                                             .or(UserProfile.defaultDetails())))
        .toResult(Causes.cause("User with ID " + userId + " not found"));
}

請注意縮排如何幫助將程式碼分組為邏輯連結的部分。
讓我們來分析結果程式碼:

  • 程式碼更簡潔,為快樂的日子場景編寫,沒有明確的錯誤或 null 檢查,沒有干擾業務邏輯
  • 沒有簡單的方法可以跳過或避免錯誤或 null 檢查,編寫正確可靠的程式碼是直接而自然的。

不太明顯的觀察:

  • 所有型別都是自動派生的。這簡化了重構並消除了不必要的混亂。如果需要,仍然可以新增型別。
  • 如果在某個時候儲存庫將開始返回 Result<T> 而不是 Option<T>,程式碼將保持不變,除了最後一個轉換 (toResult) 將被刪除。
  • 除了用 Option.or() 方法替換三元運算子之外,結果程式碼看起來很像如果我們將傳遞給 lambda 內部的原始 return 語句中的程式碼移動到 map() 方法。

最後一個觀察對於開始方便地編寫(閱讀通常不是問題)PFJ 風格的程式碼非常有用。它可以改寫為以下經驗規則:在右側尋找值。比較一下:

User user = userRepository.findById(userId); // <-- 值在表示式左邊

return userRepository.findById(userId)
.map(user -> ...); // <-- 值在表示式右邊

這種有用的觀察有助於從遺留命令式程式碼風格向 PFJ 轉換。

與遺留程式碼互動

不用說,現有程式碼不遵循 PFJ 方法。它丟擲異常,返回 null 等等。有時可以重新編寫此程式碼以使其與 PFJ 相容,但通常情況並非如此。對於外部庫和框架尤其如此。

呼叫遺留程式碼

遺留程式碼呼叫有兩個主要問題。它們中的每一個都與違反相應的 PFJ 規則有關:

處理業務異常

Result<T> 包含一個名為 lift() 的輔助方法,它涵蓋了大多數用例。方法簽名看起來是這樣:

static <R> Result<R> lift(FN1<? extends Cause, ? super Throwable> exceptionMapper, ThrowingSupplier<R> supplier)

第一個引數是將異常轉換為 Cause 例項的函式(反過來,它用於在失敗情況下建立 Result<T> 例項)。第二個引數是 lambda,它封裝了對需要與 PFJ 相容的實際程式碼的呼叫。
Causesutility 類中提供了最簡單的函式,它將異常轉換為 Cause 的例項:fromThrowable()。它們可以與 Result.lift() 一起使用,如下所示:

public static Result<URI> createURI(String uri) {
    return Result.lift(Causes::fromThrowable, () -> URI.create(uri));
}

處理 null 值返回

這種情況相當簡單 - 如果 API 可以返回 null,只需使用 Option.option() 方法將其包裝到 Option<T> 中。

提供遺留 API

有時需要允許遺留程式碼呼叫以 PFJ 風格編寫的程式碼。特別是,當一些較小的子系統轉換為 PFJ 風格時,通常會發生這種情況,但系統的其餘部分仍然以舊風格編寫,並且需要保留 API。最方便的方法是將實現拆分為兩部分——PFJ 風格的 API 和介面卡,它只將新 API 適配到舊 API。這可能是一個非常有用的簡單輔助方法,如下所示:

public static <T> T unwrap(Result<T> value) {
    return value.fold(
        cause -> { throw new IllegalStateException(cause.message()); },
        content -> content
    );
}

Result<T> 中沒有提供隨時可用的輔助方法,原因如下:

  • 可能有不同的用例,並且可以丟擲不同型別的異常(已檢查和未檢查)。
  • Cause 轉換為不同的特定異常在很大程度上取決於特定的用例。

管理變數作用域

本節將專門介紹在編寫 PFJ 風格程式碼時出現的各種實際案例。
下面的示例假設使用 Result<T>,但這在很大程度上無關緊要,因為所有考慮因素也適用於 Option<T>。此外,示例假定示例中呼叫的函式被轉換為返回 Result<T> 而不是丟擲異常。

巢狀作用域

函式風格程式碼大量使用 lambda 來執行 Option<T>Result<T> 容器內的值的計算和轉換。每個 lambda 都隱式地為其引數建立了作用域——它們可以在 lambda 主體內部訪問,但不能在其外部訪問。
這通常是一個有用的屬性,但對於傳統的命令式程式碼,它很不尋常,一開始可能會覺得不方便。幸運的是,有一種簡單的技術可以克服感知上的不便。
我們來看看下面的命令式程式碼:

var value1 = function1(...); // function1()
 可能丟擲異常
var value2 = function2(value1, ...); // function2() 可能丟擲異常
var value3 = function3(value1, value2, ...); // function3() 可能丟擲異常

變數 value1 應該可訪問以呼叫 function2() 和 function3()。 這確實意味著直接轉換為 PFJ 樣式將不起作用:

function1(...)
.flatMap(value1 -> function2(value1, ...))
.flatMap(value2 -> function3(value1, value2, ...)); // <-- 錯, value1 不可訪問

為了保持值的可訪問性,我們需要使用巢狀作用域,即巢狀呼叫如下:

function1(...)
.flatMap(value1 -> function2(value1, ...)
    .flatMap(value2 -> function3(value1, value2, ...)));

第二次呼叫 flatMap() 是針對 function2 返回的值而不是第一個 flatMap() 返回的值。通過這種方式,我們將 value1 保持在範圍內,並使 function3 可以訪問它。
儘管可以建立任意深度的巢狀作用域,但通常多個巢狀作用域更難閱讀和遵循。在這種情況下,強烈建議將更深的範圍提取到專用函式中。

平行作用域

另一個經常觀察到的情況是需要計算/檢索幾個獨立的值,然後進行呼叫或構建一個物件。讓我們看看下面的例子:

var value1 = function1(...);    // function1() 可能丟擲異常
var value2 = function2(...);    // function2() 可能丟擲異常
var value3 = function3(...);    // function3() 可能丟擲異常
return new MyObject(value1, value2, value3);

乍一看,轉換為 PFJ 樣式可以與巢狀作用域完全相同。每個值的可見性將與命令式程式碼相同。不幸的是,這會使範圍巢狀很深,尤其是在需要獲取許多值的情況下。
對於這種情況,Option<T>Result<T> 提供了一組 all() 方法。這些方法執行所有值的“並行”計算並返回 MapperX<...> 介面的專用版本。 這個介面只有三個方法—— id()map()flatMap()map()flatMap() 方法的工作方式與 Option<T>Result<T> 中的相應方法完全相同,只是它們接受具有不同數量引數的 lambda。讓我們來看看它在實踐中是如何工作的,並將上面的命令式程式碼轉換為 PFJ 樣式:

return Result.all(
          function1(...),
          function2(...),
          function3(...)
        ).map(MyObject::new);

除了緊湊和扁平之外,這種方法還有一些優點。首先,它明確表達意圖——在使用前計算所有值。命令式程式碼按順序執行此操作,隱藏了原始意圖。第二個優點 - 每個值的計算是獨立的,不會將不必要的值帶入範圍。這減少了理解和推理每個函式呼叫所需的上下文。

替代作用域

一個不太常見但仍然很重要的情況是我們需要檢索一個值,但如果它不可用,那麼我們使用該值的替代來源。當有多個替代方案可用時,這種情況的頻率甚至更低,而且在涉及錯誤處理時會更加痛苦。
我們來看看下面的命令式程式碼:

MyType value;

try {
    value = function1(...);
} catch (MyException e1) {
    try {
        value = function2(...);    
    } catch(MyException e2) {
        try {
            value = function3(...);
        } catch(MyException e3) {
            ... // repeat as many times as there are alternatives
        }
    }
}

程式碼是人為設計的,因為巢狀案例通常隱藏在其他方法中。儘管如此,整體邏輯並不簡單,主要是因為除了選擇值之外,我們還需要處理錯誤。錯誤處理使程式碼變得混亂,並使初始意圖 - 選擇第一個可用的替代方案 - 隱藏在錯誤處理中。
轉變為 PFJ 風格使意圖非常清晰:

var value = Result.any(
        function1(...),
        function2(...),
        function3(...)
    );

不幸的是,這裡有一個重要的區別:原始命令式程式碼僅在必要時計算第二個和後續替代項。在某些情況下,這不是問題,但在許多情況下,這是非常不可取的。幸運的是,Result.any() 有一個惰性版本。使用它,我們可以重寫程式碼如下:

var value = Result.any(
        function1(...),
        () -> function2(...),
        () -> function3(...)
    );

現在,轉換後的程式碼的行為與它的命令式對應程式碼完全一樣。

Option<T> 和 Result<T> 的簡要技術概述

這兩個容器在函數語言程式設計術語中是單子(monad)。
Option<T>Option/Optional/Maybe monad 的直接實現。
Result<T>Either<L,R> 的特意簡化和專門版本:左型別是固定的,應該實現 Cause 介面。專業化使 API 與 Option<T> 非常相似,並以失去通用性為代價消除了許多不必要的輸入。
這個特定的實現集中在兩件事上:

  • 與現有 JDK 類(如 Optional<T>Stream<T>)之間的互操作性
  • 用於明確意圖表達的 API

最後一句話值得更深入的解釋。
每個容器都有幾個核心方法:

  • 工廠方法
  • map() 轉換方法,轉換值但不改變特殊狀態:present Option<T> 保持 present,success Result<T> 保持 success
  • flatMap() 轉換方法,除了轉換之外,還可以改變特殊狀態:將 Option<T> present 轉換為 empty 或將 Result<T> success 轉換為 failure
  • fold() 方法,它同時處理兩種情況(Option<T>present/emptyResult<T>success/failure)。

除了核心方法,還有一堆輔助方法,它們在經常觀察到的用例中很有用。
在這些方法中,有一組方法是明確設計來產生副作用的。
Option<T> 有以下副作用的方法:

Option<T> whenPresent(Consumer<? super T> consumer);
Option<T> whenEmpty(Runnable action);
Option<T> apply(Runnable emptyValConsumer, Consumer<? super T> nonEmptyValConsumer);

Result<T> 有以下副作用的方法:

Result<T> onSuccess(Consumer<T> consumer);
Result<T> onSuccessDo(Runnable action);
Result<T> onFailure(Consumer<? super Cause> consumer);
Result<T> onFailureDo(Runnable action);
Result<T> apply(Consumer<? super Cause> failureConsumer, Consumer<? super T> successConsumer);

這些方法向讀者提供了程式碼處理副作用而不是轉換的提示。

其他有用的工具

除了 Option<T>Result<T> 之外,PFJ 還使用了一些其他通用類。下面,將對每種方法進行更詳細地描述。

Functions(函式)

JDK 提供了許多有用的功能介面。不幸的是,通用函式的函式式介面僅限於兩個版本:單引數 Function<T, R> 和兩個引數 BiFunction<T, U, R>
顯然,這在許多實際情況中是不夠的。此外,出於某種原因,這些函式的型別引數與 Java 中函式的宣告方式相反:結果型別列在最後,而在函式宣告中,它首先定義。
PFJ 為具有 1 到 9 個引數的函式使用一組一致的函式介面。 為簡潔起見,它們被稱為 FN1…FN9。到目前為止,還沒有更多引數的函式用例(通常這是程式碼異味)。但如果有必要,該清單可以進一步擴充套件。

Tuples(元組)

元組是一種特殊的容器,可用於在單個變數中儲存多個不同型別的值。與類或記錄不同,儲存在其中的值沒有名稱。這使它們成為在保留型別的同時捕獲任意值集的不可或缺的工具。這個用例的一個很好的例子是 Result.all() Option.all() 方法集的實現。
在某種意義上,元組可以被認為是為函式呼叫準備的一組凍結的引數。從這個角度來看,讓元組內部值只能通過 map() 方法訪問的決定聽起來很合理。然而,具有 2 個引數的元組具有額外的訪問器,可以使用 Tuple2<T1,T2> 作為各種 Pair<T1,T2> 實現的替代。
PFJ 使用一組一致的元組實現,具有 0 到 9 個值。提供具有 0 和 1 值的元組以保持一致性。

結論

實用函式式 Java 是一種基於函數語言程式設計概念的現代、非常簡潔但可讀的 Java 編碼風格。與傳統的慣用 Java 編碼風格相比,它提供了許多好處:

  • PFJ 藉助 Java 編譯器來幫助編寫可靠的程式碼:

    • 編譯的程式碼通常是有效的
    • 許多錯誤從執行時轉移到編譯時
    • 某些類別的錯誤,例如 NullPointerException 或未處理的異常,實際上已被消除
  • PFJ 顯著減少了與錯誤傳播和處理以及 null 檢查相關的樣板程式碼量
  • PFJ 專注於清晰表達意圖並減少心理負擔

相關文章