純粋なJavaでの関数型プログラミング:FunctorおよびMonadの例

この記事は、最初はRxjavaでのリアクティブプログラミングの本の付録でした。 しかし、モナドの導入は、リアクティブプログラミングに非常に関連しているにもかかわらず、それにはあまり適していませんでした。 だから私はそれを取り出して、これを別々にブログ記事として公開することにしました。 それは”私の半分正しい半の完全な説明monads”は、新しい”こんにちは、世界の”プログラミングでしょう。 しかし、この記事では、Javaデータ構造とライブラリの特定の角度からファンクタとモナドを見ています。 したがって、私はそれを共有する価値があると思いました。

rxjavaは、ファンクタ、モノイド、モナドのような非常に基本的な概念の上に設計され、構築されました。 Rxは最初は命令型C#言語用にモデル化されていましたが、rxjavaについて学び、同様に命令型言語の上で作業していますが、ライブラリは関数型プログ RxJava APIがどれほどコンパクトであるかを認識した後は驚かないでください。 コアクラスはほんの一握りであり、通常は不変であり、すべてが主に純粋な関数を使用して構成されています。

最近の関数型プログラミング(または関数型スタイル)の台頭により、ScalaやClojureのような現代の言語で最も一般的に表現され、モナドは広く議論された話題 それらの周りには多くの民間伝承があります:

モナドはendofunctorsのカテゴリのモノイドですが、問題は何ですか?

james Iry

モナドの呪いは、あなたがエピファニーを得ると、一度あなたが理解したら-“ああそれはそれが何であるか”-あなたは誰にもそれを説明する能力を失

Douglas Crockford

大多数のプログラマ、特に関数型プログラミングの背景のないプログラマは、モナドがいくつかの難解なコンピュータサイエンスの概念であると信 この否定的な視点は、何十もの記事やブログの投稿があまりにも抽象的であるか狭すぎることに起因する可能性があります。 しかし、標準のJavaライブラリでさえ、特にJava Development Kit(JDK)8以降(詳細は後述)、モナドはすべて私たちの周りにあることが判明しました。 絶対に素晴らしいのは、モナドを初めて理解すると、突然、まったく異なる目的を果たすいくつかの無関係なクラスと抽象化が身近になるというこ

モナドは様々な一見独立した概念を一般化するので、モナドのさらに別の化身を学ぶのにはほとんど時間がかかりません。 たとえば、CompletableFutureがJava8でどのように機能するかを学ぶ必要はありません-それがモナドであることに気づいたら、それがどのように機能し、そのセマンティ そして、あなたはrxjavaについて聞いていますが、これは非常に異なっていますが、Observableはモナドなので、追加することはあまりありません。 あなたがそれを知らずにすでに遭遇したモナドの他の多くの例があります。 したがって、実際にRxJavaを使用しなかった場合でも、このセクションは便利な復習になります。

Functors

モナドが何であるかを説明する前に、functorと呼ばれるより単純な構造を調べてみましょう。 ファンクタは、ある値をカプセル化する型付きデータ構造です。 構文的な観点から見ると、ファンクタは次のAPIを持つコンテナです:

import java.util.function.Function;interface Functor<T> { <R> Functor<R> map(Function<T, R> f);}

しかし、単なる構文では、関手が何であるかを理解するには不十分です。 Functorが提供する唯一の操作は、関数fを取るmap()です。 それをよくお読みください。 Functor<T>は常に不変のコンテナであるため、mapは実行された元のオブジェクトを変更しません。 代わりに、新しいfunctorでラップされた結果(またはresults-be patient)を返します(おそらく異なるタイプRのもの)。 このようなパターンは、常に同じファンクタまたは等しいインスタンスのいずれかを返す必要があります。

多くの場合、ファンクタ<T>は、この値と対話する唯一の方法は変換することであるtのインスタンスを保持するボックスと比較されます。 しかし、functorからラップを解除したりエスケープしたりする慣用的な方法はありません。 値は常にファンクタのコンテキスト内にとどまります。 関手はなぜ便利なのですか? 彼らは、コレクション、約束、optionalsなどのような複数の一般的なイディオムを一般化します。 それらのすべてで動作する単一の統一されたAPIを使用します。 このAPIをより流暢にするために、いくつかのファンクタを紹介しましょう:

interface Functor<T,F extends Functor<?,?>> { <R> F map(Function<T,R> f);}class Identity<T> implements Functor<T,Identity<?>> { private final T value; Identity(T value) { this.value = value; } public <R> Identity<R> map(Function<T,R> f) { final R result = f.apply(value); return new Identity<>(result); }}

Identityをコンパイルするには、追加のF型パラメータが必要でした。 前の例で見たのは、値を保持するだけの最も単純なファンクタでした。 その値でできることは、mapメソッド内でそれを変換することだけですが、それを抽出する方法はありません。 これは純粋関手の範囲を超えていると考えられます。 Functorと対話する唯一の方法は、型セーフ変換のシーケンスを適用することです:

Identity<String> idString = new Identity<>("abc");Identity<Integer> idInt = idString.map(String::length);

または流暢に、あなたが関数を構成するのと同じように:

Identity<byte> idBytes = new Identity<>(customer) .map(Customer::getAddress) .map(Address::street) .map((String s) -> s.substring(0, 3)) .map(String::toLowerCase) .map(String::getBytes);

この観点から見ると、ファンクタ上のマッピングは、単に連鎖関数を呼び出すこととあまり違いはありません:

byte bytes = customer .getAddress() .street() .substring(0, 3) .toLowerCase() .getBytes();

なぜ、付加価値を提供しないだけでなく、内容を元に戻すことができないような冗長なラッピングを気にするのですか? さて、この生のfunctor抽象化を使用して、他のいくつかの概念をモデル化できることがわかります。 たとえば、Java8Optionalから始まるのは、map()メソッドを持つファンクタです。 私たちはゼロからそれを実装してみましょう:

class FOptional<T> implements Functor<T,FOptional<?>> { private final T valueOrNull; private FOptional(T valueOrNull) { this.valueOrNull = valueOrNull; } public <R> FOptional<R> map(Function<T,R> f) { if (valueOrNull == null) return empty(); else return of(f.apply(valueOrNull)); } public static <T> FOptional<T> of(T a) { return new FOptional<T>(a); } public static <T> FOptional<T> empty() { return new FOptional<T>(null); }}

今では面白くなります。 FOptional<T>ファンクタは値を保持することができますが、同様に空である可能性があります。 これは、nullをエンコードするタイプセーフな方法です。 値を指定するか、empty()インスタンスを作成することによって、FOptionalを構築する方法が2つあります。 どちらの場合も、Identityと同様に、FOptionalは不変であり、内部からの値とのみ対話できます。 FOptionalと異なるのは、変換関数fが空の場合、どの値にも適用されない可能性があることです。 これは、functorが必ずしもT型の値を1つだけカプセル化するとは限らないことを意味します。 これは、Listのように、任意の数の値をラップすることもできます。.. 関手:

import com.google.common.collect.ImmutableList;class FList<T> implements Functor<T, FList<?>> { private final ImmutableList<T> list; FList(Iterable<T> value) { this.list = ImmutableList.copyOf(value); } @Override public <R> FList<?> map(Function<T, R> f) { ArrayList<R> result = new ArrayList<R>(list.size()); for (T t : list) { result.add(f.apply(t)); } return new FList<>(result); }}

APIは同じままです: あなたは変換でファンクタを取る-しかし、動作は大きく異なります。 ここで、FList内のすべての項目に変換を適用し、リスト全体を宣言的に変換します。 あなたが顧客のリストを持っていて、あなたが彼らの通りのリストをしたいのであれば、それは次のように簡単です:

import static java.util.Arrays.asList;FList<Customer> customers = new FList<>(asList(cust1, cust2));FList<String> streets = customers .map(Customer::getAddress) .map(Address::street);

顧客のコレクションでgetAddress()を呼び出すことはできず、個々の顧客でgetAddress()を呼び出してコレクションに戻す必要があります。 ところで、Groovyはこのパターンが非常に一般的であることを発見し、実際にはcustomer*.getAddress()*.street()という構文糖を持っています。 この演算子はspread-dotとして知られており、実際には変装したmapです。 たぶん、Java8:list.stream().map(f).collect(toList())Streamを使用するのではなく、map内でlistを手動で反復処理する理由が不思議に思っていますか? これは鐘を鳴らすのですか? もし私があなたに言ったらjava.util.stream.Stream<T>Javaでもfunctorですか? ところで、モナドも?
ここで、ファンクタの最初の利点を確認する必要があります-彼らは内部表現を抽象化し、さまざまなデータ構造に対して一貫した使いやすいAPIを提 最後の例として、Futureと同様のpromiseファンクタを紹介しましょう。 Promise値がいつか利用できるようになることを”約束”します。 いくつかのバックグラウンド計算が生成されたか、外部イベントを待っている可能性があるため、まだそこにはありません。 しかし、それはいつか将来的に表示されます。 APromise<T>を完成させる仕組みは興味深いものではないが、関手の性質は次のようなものである。:

Promise<Customer> customer = //...Promise<byte> bytes = customer .map(Customer::getAddress) .map(Address::street) .map((String s) -> s.substring(0, 3)) .map(String::toLowerCase) .map(String::getBytes);

おなじみに見えますか? それがポイントです! ファンクタの実装はこの記事の範囲を超えており、重要ではありません。 Java8からCompletableFutureを実装することに非常に近く、RxjavaからObservableをほとんど発見したと言っても過言ではありません。 しかし、関手に戻ります。 Promise<Customer>はまだCustomerの値を保持していません。 それは将来的にそのような価値を持つことを約束します。 しかし、FOptionalとFListで行ったのと同じように、そのようなファンクタをマップすることはできます-構文とセマンティクスはまったく同じです。 この動作は、ファンクタが表すものに従います。 顧客を呼び出す。map(Customer::getAddress)はPromise<Address>を生成し、mapがノンブロッキングであることを意味します。 顧客。map()は、顧客が完了することを約束します。 代わりに、別の型の別のpromiseを返します。 上流のpromiseが完了すると、下流のpromiseはmap()に渡された関数を適用し、その結果を下流に渡します。 突然、私たちのファンクタは、非ブロッキング方法で非同期計算をパイプライン化することができます。 しかし、あなたはそれを理解したり学んだりする必要はありません-Promiseはファンクタなので、構文と法律に従わなければなりません。

函手の他の多くの素晴らしい例があり、例えば値や誤差を構成的に表現しています。 しかし、モナドを見るのは時間がかかります。ファンクタからモナドへ

私はあなたがファンクタがどのように機能するのか、なぜそれらが有用な抽象化であるのかを理解していると思います。 しかし、関手は期待されるほど普遍的ではありません。 変換関数(map()の引数として渡された関数)が単純な値ではなくfunctorインスタンスを返すとどうなりますか? まあ、ファンクタも単なる値なので、何も悪いことは起こりません。 返されたものはすべてfunctorに戻されるので、すべて一貫して動作します。 しかし、文字列を解析するためのこの便利な方法があると想像してください:

FOptional<Integer> tryParse(String s) { try { final int i = Integer.parseInt(s); return FOptional.of(i); } catch (NumberFormatException e) { return FOptional.empty(); }}

例外は、型システムと機能的純度を損なう副作用です。 純粋な関数型言語では、例外の場所はありません。 結局のところ、数学の授業中に例外を投げることについて聞いたことはありませんでしたよね? エラーと不正な条件は、値とラッパーを使用して明示的に表現されます。 たとえば、tryParse()は文字列を受け取りますが、単にintを返したり、実行時に例外を静かにスローしたりすることはありません。 型システムを通じて、tryParse()が失敗する可能性があり、不正な形式の文字列を持つことに例外的または誤りはないことを明示的に伝えます。 この半失敗は、オプションの結果で表されます。 興味深いことに、Javaは例外、宣言して処理する必要がある例外をチェックしているので、ある意味では、Javaはその点でより純粋であり、副作用を隠さない。 しかし、良くも悪くも、チェックされた例外はJavaでは推奨されないことが多いので、tryParse()に戻りましょう。 すでにFOptionalでラップされた文字列でtryParseを構成すると便利です:

FOptional<String> str = FOptional.of("42");FOptional<FOptional<Integer>> num = str.map(this::tryParse);

それは驚きとして来るべきではありません。 tryParse()intを返す場合、FOptional<Integer> numを取得しますが、map()関数はFOptional<Integer>自体を返すため、2回厄介なFOptional<FOptional<Integer>>にラップされます。 私たちがここでこの二重ラッパーを得た理由を理解しなければなりません。 ファンクタの中にファンクタを持っていることは、恐ろしい見てから離れて組成物と流暢な連鎖を台無しにします:

FOptional<Integer> num1 = //...FOptional<FOptional<Integer>> num2 = //...FOptional<Date> date1 = num1.map(t -> new Date(t));//doesn't compile!FOptional<Date> date2 = num2.map(t -> new Date(t));

ここでは、intを+Date+に変換してFOptionalの内容をマップしようとします。 int -> Dateの関数を持つと、Functor<Integer>からFunctor<Date>に簡単に変換できます。 しかし、num2の場合、状況は複雑になる。 入力として受け取るnum2.map()はもはやintではなくFOoption<Integer>であり、明らかにjava.util.Dateはそのようなコンストラクタを持っていません。 私たちはそれを二重に包むことによって私たちの関手を壊しました。 しかし、単純な値ではなくファンクタを返す関数を持つことは(tryParse()のように)非常に一般的であるため、そのような要件を単に無視することはできません。 一つのアプローチは、ネストされた関手を”平坦化”する特別なパラメータレスjoin()メソッドを導入することです:

FOptional<Integer> num3 = num2.join()

動作しますが、このパターンは非常に一般的であるため、flatMap()という名前の特別なメソッドが導入されました。 flatMap()mapと非常によく似ていますが、引数として受け取った関数が正確にはファンクタまたはモナドを返すことを期待しています:

interface Monad<T,M extends Monad<?,?>> extends Functor<T,M> { M flatMap(Function<T,M> f);}

私たちは単にflatMapがより良い構成を可能にする単なる構文糖であると結論づけました。 しかし、flatMapメソッド(Haskellからbindまたは>>=と呼ばれることが多い)は、複雑な変換を純粋で機能的なスタイルで構成できるため、すべての違いがあります。 FOptionalがmonadのインスタンスであった場合、解析は突然期待どおりに動作します:

FOptional<String> num = FOptional.of("42");FOptional<Integer> answer = num.flatMap(this::tryParse);

モナドはmapを実装する必要はなく、flatMap()の上に簡単に実装できます。 実際のところ、flatMapは、変換の全く新しい宇宙を可能にする本質的な演算子です。 明らかにファンクタと同じように、構文的なコンプライアンスは、いくつかのクラスaモナドを呼び出すのに十分ではなく、flatMap()演算子はモナドの法則に従 後者は、値xおよび任意の関数fを保持するモナドに対して、m(x).flatMap(f)f(x)と同じであることを必要とします。 私たちはモナド理論にあまりにも深く潜るつもりはなく、代わりに実用的な意味に焦点を当てましょう。 モナドは、将来値を保持するPromiseモナドのように、内部構造が自明でないときに輝きます。 タイプシステムから、次のプログラムでPromiseがどのように動作するかを推測できますか? まず、完了に時間がかかる可能性のあるすべてのメソッドは、aを返しますPromise:

import java.time.DayOfWeek;Promise<Customer> loadCustomer(int id) { //...}Promise<Basket> readBasket(Customer customer) { //...}Promise<BigDecimal> calculateDiscount(Basket basket, DayOfWeek dow) { //...}

これらの関数は、モナド演算子を使用してすべてブロックしているかのように構成できます:

Promise<BigDecimal> discount = loadCustomer(42) .flatMap(this::readBasket) .flatMap(b -> calculateDiscount(b, DayOfWeek.FRIDAY));

これは興味深いものになります。 flatMap()はモナド型を保持しなければならないため、すべての中間オブジェクトはPromiseです。 型を順番に保つだけではありません-先行するプログラムは突然完全に非同期です! loadCustomer()Promiseを返しますので、ブロックしません。 readBasket()は、Promiseが持っている(持っている)ものを取り、別のPromiseを返す関数を適用します。 基本的には、バックグラウンドでの1つのステップの完了が自動的に次のステップをトリガーする計算の非同期パイプラインを構築しました。

flatMap()を探索する

二つのモナドを持ち、それらが囲む値を組み合わせることは非常に一般的です。 しかし、ファンクタとモナドの両方が内部に直接アクセスすることを許可していないため、不純になります。 代わりに、モナドをエスケープせずに変換を慎重に適用する必要があります。 あなたが二つのモナドを持っていて、それらを結合したいとします:

import java.time.LocalDate;import java.time.Month;Monad<Month> month = //...Monad<Integer> dayOfMonth = //...Monad<LocalDate> date = month.flatMap((Month m) -> dayOfMonth .map((int d) -> LocalDate.of(2016, m, d)));

上記の擬似コードを勉強するためにあなたの時間を取ってください。 私はコアコンセプトを強調するためにPromiseListのような実際のモナド実装を使用しません。 私たちは2つの独立したモナドを持っています、1つはタイプMonthそしてもう1つはタイプIntegerです。 それらからLocalDateを構築するには、両方のモナドの内部にアクセスできるネストされた変換を構築する必要があります。 特に、ある場所でflatMapを使用し、別の場所でmap()を使用する理由を理解してください。 3番目のMonad<Year>もあれば、このコードをどのように構造化するか考えてみてください。 2つの引数(この場合はmd)の関数を適用するこのパターンは非常に一般的であるため、HaskellではliftM2と呼ばれる特別なヘルパー関数があり、mapflatMapの上に実 Java擬似構文では、次のようになります:

Monad<R> liftM2(Monad<T1> t1, Monad<T2> t2, BiFunction<T1, T2, R> fun) { return t1.flatMap((T1 tv1) -> t2.map((T2 tv2) -> fun.apply(tv1, tv2)) );}

すべてのモナドに対してこのメソッドを実装する必要はありません。flatMap()で十分ですが、すべてのモナドに対して一貫して動作します。 liftM2は、さまざまなモナドでどのように使用できるかを検討するときに非常に便利です。 たとえば、listM2(list1, list2, function)は、list1list2(デカルト積)のすべての可能な項目のペアにfunctionを適用します。 一方、optionalsの場合、両方のoptionalsが空でない場合にのみ関数が適用されます。 さらに良いことに、Promise モナドの場合、両方のPromiseが完了すると関数が非同期に実行されます。 これは、2つの非同期ステップの単純な同期メカニズム(fork-joinアルゴリズムのjoin())を発明しただけであることを意味します。

flatMap()の上に簡単に構築できるもう一つの便利な演算子は、モナドの中にあるものを取り、特定の述語を満たさない場合は完全に破棄するfilter(Predicate<T>)です。 ある意味では、それはmapに似ていますが、1-to-1マッピングではなく、1-to-0-または-1があります。 また、filter()はすべてのモナドに対して同じ意味を持ちますが、実際に使用するモナドに応じて非常に素晴らしい機能を持っています。 明らかに、リストから特定の要素を除外することができます:

FList<Customer> vips = customers.filter(c -> c.totalOrders > 1_000);

しかし、それはoptionalsのためにちょうど同様に動作します。 その場合、optionalの内容がいくつかの基準を満たさない場合、空でないoptionalを空のものに変換できます。 空のオプションはそのまま残されます。

モナドのリストからリストのモナドへ

flatMap()から派生するもう一つの便利な演算子はsequence()です。 型シグネチャを見るだけで、それが何をするのかを簡単に推測できます:

Monad<Iterable<T>> sequence(Iterable<Monad<T>> monads)

多くの場合、同じタイプのモナドがたくさんあり、そのタイプのリストの単一のモナドを持ちたいと考えています。 これはあなたには抽象的に聞こえるかもしれませんが、印象的に便利です。 データベースから数人の顧客をIDで同時にロードしたいとしたので、異なるIdに対してloadCustomer(id)メソッドを数回使用し、各呼び出しがPromise<Customer>を返すとします。 今、あなたはPromiseのリストを持っていますが、あなたが本当に欲しいのは顧客のリストです。 sequence()(rxjavasequence()では、ユースケースに応じてconcat()またはmerge()と呼ばれます)演算子はそのためだけに構築されています:

FList<Promise<Customer>> custPromises = FList .of(1, 2, 3) .map(database::loadCustomer);Promise<FList<Customer>> customers = custPromises.sequence();customers.map((FList<Customer> c) -> ...);

顧客Idを表すFList<Integer>を持つmapそれを超えています(FListがファンクタであることがどのように役立つかわかりますか?)各IDに対してdatabase.loadCustomer(id)を呼び出すことによって。 これはPromiseのかなり不便なリストにつながります。sequence()はその日を節約しますが、これは単なる構文上の砂糖ではありません。 上記のコードは完全に非ブロッキングです。 異なる種類のモナドsequence()はまだ理にかなっていますが、別の計算コンテキストではありません。 たとえば、FList<FOptional<T>>FOptional<FList<T>>に変更できます。 ちなみに、flatMap()の上にsequence()map()と同じように)を実装することができます。

これは、flatMap()とモナド一般の有用性に関しては氷山の一角に過ぎません。 モナドはかなりあいまいなカテゴリ理論から来ているにもかかわらず、Javaのようなオブジェクト指向プログラミング言語でさえ、非常に有用な抽象化であることが証明された。 モナドを返す関数を構成できることは、非常に普遍的に有用であるため、何十もの無関係なクラスがモナドの動作に従います。

さらに、モナド内にデータをカプセル化すると、明示的に取得するのが難しいことがよくあります。 このような操作はモナドの動作の一部ではなく、多くの場合、非慣用的なコードにつながります。 たとえば、Promise<T>Promise.get()は技術的にはTを返すことができますが、ブロッキングによってのみ返されますが、flatMap()に基づくすべての演算子は非ブロッキングです。 別の例はFOptional.get()ですが、FOptionalが空である可能性があるため失敗する可能性があります。 リストから特定の要素を覗くFList.get(idx)でさえ、forループをmap()に置き換えることができるので、厄介です。

モナドが最近なぜそんなに人気があるのかを理解していただければ幸いです。 Javaのようなオブジェクト指向(-ish)言語でも、それらは非常に便利な抽象化です。

コメントを残す

メールアドレスが公開されることはありません。