Java中函數語言程式設計Monad概念介紹

banq發表於2024-05-15

在本教程中,我們將瞭解 monad,以及它們如何幫助我們處理效果。我們將學習使我們能夠連結 monad 和操作的基本方法:map()和flatMap()。 在整篇文章中,我們將探討 Java 生態系統中一些流行 monad 的 API,重點關注它們的實際應用。

效果effect
在函數語言程式設計中,“效果”通常是指導致超出函式或元件範圍的更改的操作。

為了在處理這些影響時應用函數語言程式設計範例,我們可以將操作或資料包裝在容器中。我們可以將 monad 視為允許我們處理函式範圍之外的效果的容器,從而保持函式的純度。

例如,假設我們有一個將兩個雙精度數相除的函式:

double divide(double dividend, double divisor) {
    return dividend / divisor;
}

儘管它看起來像一個純函式,但當我們傳遞零作為除數 引數的值時,該函式會透過丟擲 ArithmeticException 產生副作用。但是,我們可以使用 monad 來包裝函式的結果幷包含其效果。

讓我們更改該函式並使其返回一個Optional<Double>:

Optional<Double> divide(double dividend, double divisor) {
    if (divisor == 0) {
        return Optional.empty();
    }
    return Optional.of(dividend / divisor);
}

正如我們所看到的,當我們嘗試除以零時,該函式不再產生副作用。

以下是其他一些流行的 Java monad 示例,可以幫助我們處理各種效果:

  • Optional<>  ——處理可為空性
  • List<>、Stream<> –管理資料集合
  • Mono<>、 CompletableFuture<> ——處理併發和 I/O
  • Try<>, Result<> –處理錯誤
  • Either<> –處理二元性

函子
當我們建立一個 monad 時,我們需要允許它改變其封裝的物件或操作,同時保持相同的容器型別。

我們以Java Streams為例。如果在“現實世界”中,可以透過呼叫Instant.ofEpochSeconds()方法將Long型別的例項轉換為Instant ,則這種關係必須保留在Stream的世界中。

為了實現這一點,Stream API 公開了一個高階函式來“提升”原始關係。這個概念也稱為“函子”,允許轉換封裝型別的方法通常稱為“ map ”:

Stream<Long> longs = Stream.of(1712000000L, 1713000000L, 1714000000L);
Stream<Instant> instants = longs.map(Instant::ofEpochSecond);

儘管“ map ”是此函式型別的典型術語,但特定的方法名稱本身對於物件是否有資格作為函子來說並不是必需的。例如,CompletableFuture monad 提供了一個名為thenApply()的方法:

CompletableFuture<Long> timestamp = CompletableFuture.completedFuture(1713000000L);
CompletableFuture<Instant> instant = timestamp.thenApply(Instant::ofEpochSecond);

Binding繫結
繫結是單子的一個關鍵特徵,它允許我們在單子上下文中連結多個計算。換句話說,我們可以透過用繫結替換map()來避免雙重巢狀。

巢狀單子
如果我們僅僅依靠函子來對操作進行排序,我們最終將得到巢狀容器。 讓我們在這個例子中使用Project Reactor的Mono monad。

假設我們有兩種方法可以讓我們以反應方式獲取Author和Book實體:

Mono<Author> findAuthorByName(String name) { <font>/* ... */<i> }
Mono<Book> findLatestBookByAuthorId(Long authorId) {
/* ... */<i> }

現在,如果我們從作者的姓名開始,我們可以使用第一種方法並獲取他的詳細資訊。結果是Mono<Author>:

void findLatestBookOfAuthor(String authorName) {
    Mono<Author> author = findAuthorByName(authorName);
    <font>// ...<i>
}

之後,我們可能會想使用 map () 方法將容器的內容 從Author更改為他最新的Book:

Mono<Mono<Book>> book = author.map(it -> findLatestBookByAuthorId(it.authorId());

但是,正如我們所看到的,這會產生一個巢狀的Mono容器。發生這種情況是因為findLatestBookByAuthorId()返回Mono<Author>而map()再次包裝結果。

flatMap
然而,如果我們使用繫結來代替,我們就消除了額外的容器並使結構扁平化。繫結方法通常採用名稱“flatMap” ,儘管有一些例外,其呼叫方式有所不同:

void findLatestBookOfAuthor(String authorName) {
    Mono<Author> author = findAuthorByName(authorName);
    Mono<Book> book = author.flatMap(it -> findLatestBookByAuthorId(it.authorId()));
    <font>// ...<i>
}

現在,我們可以透過內聯操作來稍微簡化程式碼,並引入一個從Author轉換為其authorId的中間map():

void findLatestBookOfAuthor(String authorName) {
    Mono<Book> book = findAuthorByName(authorName)
      .map(Author::authorId)
      .flatMap(this::findLatestBookByAuthorId));
    <font>// ...<i>
}

正如我們所看到的,結合map()和flatMap()是一種使用monad的有效方法,允許我們以宣告方式定義一系列轉換。


實際用例
正如我們在前面的程式碼示例中所看到的,單子透過提供額外的抽象層來幫助我們處理效果。大多數時候,它們使我們能夠專注於主要場景並處理主要邏輯之外的極端情況。

Railroad模式
像這樣的繫結 monad 也稱為“鐵路”模式。我們可以透過想象一條鐵路成一條直線來視覺化主流。此外,如果發生意外情況,我們會從主要鐵路切換到輔助並行鐵路。

讓我們考慮一下驗證Book 物件。我們首先驗證書籍的 ISBN,然後檢查作者 ID,最後驗證書籍的型別:

void validateBook(Book book) {
    if (!validIsbn(book.getIsbn())) {
        throw new IllegalArgumentException(<font>"Invalid ISBN");
    }
    Author author = authorRepository.findById(book.getAuthorId());
    if (author == null) {
        throw new AuthorNotFoundException(
"Author not found");
    }
    if (!author.genres().contains(book.genre())) {
        throw new IllegalArgumentException(
"Author does not write in this genre");
    }
}

我們可以使用vavr 的Try  monad並應用鐵路模式將這些驗證連結在一起:

void validateBook(Book bookToValidate) {
    Try.ofSupplier(() -> bookToValidate)
      .andThen(book -> validateIsbn(book.getIsbn()))
      .map(book -> fetchAuthor(book.getAuthorId()))
      .andThen(author -> validateBookGenre(bookToValidate.genre(), author))
      .get();
}
void validateIsbn(String isbn) { <font>/* ... */<i> }
Author fetchAuthor(Long authorId) {
/* ... */<i> }
void validateBookGenre(String genre, Author author) {
/* ... */<i> }

正如我們所看到的,API 公開了像andThen() 這樣的方法,對於我們不需要響應的函式非常有用。

它們的目的是檢查故障,並在需要時切換到輔助通道。另一方面,map()和flatMap()等方法旨在進一步推動流程,建立一個新的Try<> monad 來包裝函式的響應,在本例中為Author物件

現在我們已經瞭解了 monad 的工作原理以及如何使用鐵路模式繫結它們,我們知道各種方法的實際名稱是無關緊要的。相反,我們應該關注他們的目的。大多數來自 monad API 的方法:

  • 轉換底層資料
  • 如果需要,在通道之間切換

例如,Optional monad 使用map()和flatMap()來轉換其資料,分別使用 filter()和or()來潛在地在“空”和“存在”狀態之間切換。
另一方面,CompletableFuture使用thenApply()和thenCombine()等方法而不是map()和flatMap() ,並允許我們透過exceptedly()從故障通道中恢復。
 

相關文章