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



Functional Programming Pure Java



プログラマーの大多数、特に関数型プログラミングのバックグラウンドを持たないプログラマーは、モナドはある種の不思議なコンピューターサイエンスの概念であると考える傾向があるため、理論的には、プログラミングのキャリアには役立ちません。この否定的な見方は、抽象的すぎたり狭すぎたりする数十の記事やブログ投稿に起因する可能性があります。しかし、標準のJavaライブラリでも、モナドはいたるところにあり、特にJava Development Kit(JDK)8(これについては後で詳しく説明します)からのものであることがわかりました。モナドについて初めて学ぶと、突然、目的ごとにまったく関係のない、まったく異なるクラスや抽象化がいくつかあることは、絶対に驚くべきことです。
モナドは、一見独立しているように見えるさまざまな概念を要約しているため、モナドの別の化身を学ぶのにほとんど時間がかかりません。たとえば、CompletableFutureがJava 8でどのように機能するかを学ぶ必要はありません。これがモナドであることに気づいたら、それがどのように機能し、そのセマンティクスから何を期待できるかを正確に知ることができます。次に、RxJavaのサウンドが大きく異なることがわかりますが、Observableはモナドであるため、追加するものはあまりありません。あなたは無意識のうちに他の多くのモナドの例に遭遇しました。したがって、実際にRxJavaを使用していない場合でも、このセクションは役立つレビューになります。
ファンクター
モナドとは何かを説明する前に、ファンクターと呼ばれる単純な構造を調べてみましょう。ファンクターは、特定の値をカプセル化する型付きデータ構造です。文法的な観点から、ファンクターは次のAPIを備えたコンテナーです。
java.util.function.Functionをインポートします
インターフェイスファンクター{
ファンクターマップ(関数f)
}
しかし、構文だけではファンクターが何であるかを理解するのに十分ではありません。 functorが提供する唯一の操作は、関数fを使用したmap()です。この関数は、ボックス内のすべてのものを受け取り、それを変換して、結果をそのまま別のファンクターにラップします。注意深く読んでください。 Functorは常に不変のコンテナであるため、mapは操作を実行する元のオブジェクトを変更しません。代わりに、新しいファンクターにラップされた結果(または結果-しばらくお待ちください)が返されます。ファンクターはタイプRの場合があります。さらに、ファンクターは識別機能を適用するときに操作を実行しないでください(つまり、マップ(x-> x ))。このモードは、常に同じファンクターまたは等しいインスタンスを返す必要があります。
ファンクターは通常、Tを保持するインスタンスと比較され、この値を操作する唯一の方法はそれを変換することです。ただし、ファンクターを解いたり、ファンクターから脱出したりする慣用的な方法はありません。値は常にファンクターのコンテキストにあります。ファンクターが役立つのはなぜですか?これらは、すべてのコレクションに適用できる統合APIを使用して、コレクション、プロミス、オプションなどの複数の一般的なイディオムを要約します。このAPIをよりスムーズに使用できるようにするために、いくつかのファンクターを紹介します。
インターフェースファンクター{{
Fマップ(関数f)
}
クラスIdentityはFunctorを実装します{{
プライベート最終T値
Identity(T value){this.value = value}
パブリックアイデンティティマップ(関数f){
最終的なRの結果= f.apply(value)
新しいIdentity(result)を返す
}
}
Identityのコンパイルには追加のFタイプパラメータが必要です。前の例では、1つの値のみを含む最も単純なファンクターを見ました。 mapメソッド内でのみ変換できますが、抽出することはできません。これは、純粋なファンクターの範囲を超えていると見なされます。ファンクターと対話する唯一の方法は、タイプセーフな変換シーケンスを適用することです。
Identity idString = new Identity(“ abc”)
Identity idInt = idString.map(String :: length)
または流暢に、関数を書いているかのように:
Identity idBytes = new Identity(customer)
.map(Customer :: getAddress)
.map(Address :: street)
.map((String s)-> s.substring(0、3))
.map(String :: toLowerCase)
.map(String :: getBytes)
この観点から、Functorsでのマッピングは、連鎖関数の呼び出しと大差ありません。
byte [] bytes = customer
.getAddress()
。通り()
.substring(0、3)
.toLowerCase()
.getBytes()
なぜこのような長いパッケージに悩まされるのでしょうか。付加価値がないだけでなく、コンテンツを抽出して戻すこともできません。この元のFunctors抽象化を使用して、他のいくつかの概念をモデル化できることがわかりました。たとえば、Java 8以降、map()メソッドを使用するファンクターはオプションです。ゼロから実装しましょう:
クラスFOptionalはFunctorを実装します{{
プライベートファイナルTvalueOrNull
private FOptional(T valueOrNull){
this.valueOrNull = valueOrNull
}
public FOptional map(Function f){
if(valueOrNull == null)
empty()を返す
そうしないと
return of(f.apply(valueOrNull))
}
public static FOptional of(T a){
新しいFOptional(a)を返す
}
public static FOptional empty(){
新しいFOptional(null)を返します
}
}
今では楽しいです。 FOptionalファンクターは値を保持できますが、空の場合もあります。これはタイプセーフなエンコーディングnullです。 FOptionalの2つの構築方法があります-値を提供するか、空の()インスタンスを作成します。どちらの場合も、Identityの場合と同様に、FOptionalは不変であり、内部値とのみ対話できます。違いFOptionalは、変換関数fが空の場合、どの値にも適用されない可能性があることです。これは、ファンクターが必ずしも型の値Tを完全にカプセル化する必要がない場合があることを意味します。 List ... functorのように、任意の数の値をラップすることもできます。
com.google.common.collect.ImmutableListをインポートします
クラスFListはFunctorを実装します{{
プライベート最終ImmutableListリスト
FList(Iterable value){
this.list = ImmutableList.copyOf(value)
}
@オーバーライド
public FList map(Function f){
ArrayListの結果= new ArrayList(list.size())
for(T t:list){
result.add(f.apply(t))
}
新しいFList(結果)を返す
}
}
APIは同じままです。トランジションでファンクターを使用できますが、動作は大きく異なります。ここで、FListの各項目を変換し、宣言的な方法でリスト全体を変換します。したがって、顧客のリストがあり、その通りのリストが必要な場合、それは非常に簡単です。
静的java.util.Arrays.asListをインポートします
FListの顧客=新しいFList(asList(cust1、cust2))
FListストリート=顧客
.map(Customer :: getAddress)
.map(Address :: street)
これは、customers.getAddress()と言うほど単純ではなくなりました。 street()の場合、顧客コレクションでGetAddress()を使用することはできません。通話中に、個々の顧客ごとにgetAddress()を取得してから、コレクションに戻す必要があります。ちなみに、Groovyは、このパターンが非常に一般的であるため、実際には構文糖衣構文が含まれていることを発見しました:customer * .getAddress()*。ストリート()。この演算子はスキャッターと呼ばれ、実際にはマップの変装です。 list.stream()を使用する代わりに、リスト内でマップを手動で反復する必要がある理由を知りたいと思うかもしれません。 StreamJava 8のマップ(f).collect(toList())?これは鳴りますか?私のjava.util.stream.Streamが、あなたがJavaのファンクターでもあると言ったらどうなりますか?ちなみに、モナド?

これで、Functorsの最初の利点がわかります。Functorsは内部表現を抽象化し、さまざまなデータ構造に一貫性のある使いやすいAPIを提供します。最後の例として、同様のpromise関数Futureを紹介します。約束の「コミットメント」はいつか価値を提供します。まだ表示されていません。バックグラウンド計算が行われたか、外部イベントを待機している可能性があります。しかし、それは将来いつか現れるでしょう。 Promiseを完了するメカニズムは興味深いものではありませんが、Functorsの性質は次のとおりです。
約束の顧客= //…
約束バイト=顧客
.map(Customer :: getAddress)
.map(Address :: street)
.map((String s)-> s.substring(0、3))
.map(String :: toLowerCase)
.map(String :: getBytes)
おなじみですか?これが私が言いたいことです!ファンクターの実現はこの記事の範囲を超えており、重要ではありません。言うまでもなく、Java 8からのCompletableFutureの実装に非常に近く、RxJavaからObservableがほぼ見つかりました。しかし、ファンクターに戻りましょう。 Promiseはまだ顧客の価値を保持していません。将来的にはこの値になると予想されます。ただし、FOptionalおよびFListを使用する場合と同じ構文およびセマンティクスを使用して、このようなファンクターをマップすることはできます。動作はファンクターが言ったことに従います。 customer.map(Customer :: getAddress)を呼び出すと、Promiseが生成されます



、これは、マップが非ブロッキングであることを意味します。 customer.map()は、顧客のコミットメントを完了します。代わりに、別のタイプの別のプロミスを返します。アップストリームコミットメントが完了すると、ダウンストリームコミットメントはmap()に渡された関数を適用し、結果をダウンストリームに渡します。突然、ファンクターを使用すると、非同期計算を非ブロッキング方式でパイプライン処理できるようになります。ただし、理解したり学習したりする必要はありません。Promiseはファンクターであるため、文法とルールに従う必要があります。
ファンクターには、値やエラーを組み合わせて表すなど、他にも多くの良い例があります。しかし、今がモナドを見る時です。
ファンクターからモナドへ

ファンクターがどのように機能し、なぜそれらが有用な抽象化であるのかを理解していると思います。しかし、ファンクターは予想されるほど普及していません。変換関数(map()のパラメーターとして渡される)が単純な値ではなくFunctorsインスタンスを返す場合はどうなりますか?さて、ファンクターも価値があるので、悪いことは起こりません。すべての動作が一貫するように、すべてをファンクターに戻します。ただし、文字列を解析するための次の便利な方法があるとします。
FオプションのtryParse(String s){
{を試してください
final int i = Integer.parseInt(s)
FOptional.of(i)を返します
} catch(NumberFormatException e){
FOptional.empty()を返します
}
}
例外は、型システムと機能の純度に影響を与える副作用です。純粋に関数型言語では、例外はありません。結局のところ、数学のクラスで例外をスローすることは聞いたことがありませんよね?エラーや違法な条件は、値とラッパーを使用して明確に表現します。たとえば、tryParse()は、単にintを返したり、実行時にサイレントに例外を発生させたりする代わりに、文字列を受け入れます。型システムを介して、tryParse()に、失敗する可能性があること、および文字列形式エラーに例外やエラーがないことを明示的に通知します。この半故障は、オプションの結果で示されます。興味深いことに、Javaは宣言して処理する必要のある例外をチェックしているため、ある意味でJavaはこの点でより純粋であり、隠れた副作用はありません。ただし、Javaでの検査に一般的に推奨されていない例外については、tryParse()に戻りましょう。すでにFOptionalでラップされた文字列を使用してtryParseを作成すると便利なようです。
FOptional str = FOptional.of(“ 42”)
Fオプションのnum = str.map(this :: tryParse)
これは驚くべきことではありません。 tryParse()がa、intを返す場合、FOptional numを取得しますが、map()関数FOptional自体が戻るため、扱いにくいFOptionalに2回ラップします。タイプを注意深くチェックしてください。なぜこの二重パッケージがここにあるのかを理解する必要があります。怖いように見えることに加えて、ファンクターをファンクターに入れると、構成が破壊され、リンクがスムーズになります。
Fオプションnum1 = //…
Fオプションnum2 = //…
Fオプションのdate1 = num1.map(t-> new Date(t))
//コンパイルされません!
Fオプションのdate2 = num2.map(t-> new Date(t))
ここでは、FOptionalを試して、intを+ Date +に変換してコンテンツをマップします。 int-> Dateを使用すると、FunctorをFunctorに簡単に変換できます。これがどのように機能するかがわかります。しかし、num2の状況が複雑になると。 num2.map()が入力を受け取るのは、もはやintではありませんが、FOoptionは明らかにjava.util.Dateにそのような構造を持っていません。ファンクターをダブルラッピングで壊しました。ただし、単純な値の代わりにファンクターを返す関数(tryParse()など)があることは非常に一般的であり、この要件を単純に無視することはできません。 1つの方法は、ネストされたファンクターを「フラット化」するための特別なパラメーターなしのjoin()メソッドを導入することです。
Fオプションnum3 = num2.join()
これは機能しますが、このパターンは非常に一般的であるため、flatMap()はと呼ばれる特別なメソッドを導入します。 flatMap()は、次のmapと非常によく似ていますが、パラメーターとして受け取った関数がファンクターまたはモナドを正確に返すことを期待しています。
インターフェースモナドFunctorを拡張します{
M flatMap(関数f)
}
このflatMapは、成分をより良くすることができる単なる構文糖衣であると単純に結論付けました。しかし、flatMapメソッド(Haskellバインドまたは>> = Haskellから呼び出されることが多い)は、純粋関数スタイルで複雑な変換を構築できるため、すべて異なります。 FOptionalがモナドのインスタンスである場合、解決は予期したとおりに突然進行する可能性があります。
FOptional num = FOptional.of(“ 42”)
Fオプションの回答= num.flatMap(this :: tryParse)
モナドはマップを実装する必要はありません。flatMap()を使用して簡単に実装できます。実際、flatMapでは、必須の演算子はまったく新しい変換フィールドを実現できます。明らかに、ファンクターと同様に、構文コンプライアンスは特定のクラスのモナドを呼び出すのに十分ではありません。 FlatMap()演算子は、モナドの規則に従う必要がありますが、flatMap()とIDの組み合わせと同じように、非常に直感的です。後者では、値xと関数fのモナドを保持するために、m(x).flatMap(f)とf(x)が必要です。モナド理論については掘り下げませんが、実際の意味に焦点を当てましょう。たとえば、モノラルの内部構造が重要でない場合、それらはプロミスが将来価値を持つであろうモナドを照らします。型システムから、次のプログラムでPromiseがどのように実行されるかを推測できますか?まず、完了するまでに時間がかかる可能性のあるすべてのメソッドがPromiseを返します。
java.time.DayOfWeekをインポートします
Promise loadCustomer(int id){
//..。
}
Promise readBasket(Customer customer){
//..。
}
PromisecalculateDiscount(バスケットバスケット、DayOfWeekダウ){
//..。
}
これで、モナド演算子を使用するのと同じように、これらすべての関数を防ぐ方法でこれらの関数を記述できます。
約束割引=
loadCustomer(42)
.flatMap(this :: readBasket)
.flatMap(b-> calculateDiscount(b、DayOfWeek.FRIDAY))
これは非常に興味深いものになります。 flatMap()はMonadsタイプを保持する必要があるため、すべての中間オブジェクトはPromisesです。型を順番に保つだけではありません-前のプログラムは突然完全に非同期になりました! loadCustomer()はPromiseを返すため、ブロックされません。 readBasket()は、Promiseが持っている(持つ予定の)すべてのものを受け入れ、別の関数を返すPromise関数を適用します。基本的に、非同期コンピューティングパイプラインを確立しました。このパイプラインでは、バックグラウンドでステップを完了すると、次のステップが自動的にトリガーされます。
flatMapを探索する()
2つのモナドを持ち、それらに含まれる値を組み合わせるのが一般的です。ただし、ファンクターもモナドも内部への直接アクセスを許可しておらず、これは不純です。それどころか、変換を慎重に適用する必要があり、モナドから逃れることはできません。 2つのモナドがあり、それらをマージするとします。
java.time.LocalDateをインポートします
java.time.Monthをインポートします
モナド月= //…
モナドdayOfMonth = //…
モナド日付= month.flatMap((Month m)->
dayOfMonth
.map((int d)-> LocalDate.of(2016、m、d)))
前の擬似コードを調べてみてください。私は実際のモナド実装を使用していません。PromiseとListはコアコンセプトを強調しています。 2つの独立したモナドがあります。1つはMonthタイプで、もう1つはIntegerタイプです。 LocalDateを構築するには、2つのモナドの内部にアクセスできるネストされた変換を構築する必要があります。これらのタイプを注意深く調べてください。特に、flatMapがある場所と別の場所でmap()を使用する理由を確実に理解してください。 3番目のコードもある場合は、このコードMonadをどのように作成するかを考えてください。 2つのパラメーター関数(このモードではmとd)を適用することは非常に一般的です。 Haskellには、mapとflatMapの間の変換を実装するliftM2と呼ばれる特別な補助関数があります。 Java疑似文法では、次のようになります。
モナドliftM2(モナドt1、モナドt2、BiFunction fun){
t1.flatMap((T1 tv1)->を返す
t2.map((T2 tv2)-> fun.apply(tv1、tv2))
)。
}
すべてのモナドにこのメソッドを実装する必要はありません。このflatMap()で十分であり、すべてのモナドで一貫して機能します。 liftM2は、さまざまなモナドでの使用方法を検討するときに非常に役立ちます。たとえば、listM2(list1、list2、function)は、(デカルト積)関数で可能なすべてのアイテムペアに適用されます。一方、オプションオプションの場合、両方のオプションオプションが空でない場合にのみ、機能が適用されます。さらに良いことに、モナドの場合、両方が完了すると、関数は非同期で実行されます。これは、2つの非同期ステップを含む単純な同期メカニズム(フォーク結合アルゴリズム)を発明したことを意味します。 list1list2Promise Promisejoin()
簡単に作成できるもう1つの便利な演算子であるflatMap()は、フィルター(述語)です。これは、モナド内のすべてを受け入れ、特定の述語が満たされない場合は完全に破棄します。ある意味では、1対1のマッピングではなく、map1対1のマッピングに似ています。同じフィルター()、各モナドは同じセマンティクスを持っていますが、実際に使用するモナドによっては、その機能は非常に優れています。明らかに、特定の要素をリストからフィルタリングできます。
FList vips =
Customers.filter(c-> c.totalOrders> 1_000)
ただし、オプションのアイテムなどでもうまく機能します。この場合、オプションのコンテンツが特定の条件を満たさない場合は、null以外のオプションを空に変換できます。空のオプションパーツは変更されません。
モナドリストからモナドリストへ
flatMap()から派生したもう1つの便利な演算子は、シーケンス()です。タイプシグニチャを見れば、それが何をするのかを簡単に推測できます。
モナドシーケンス(反復可能なモナド)
通常、同じタイプのモナドがたくさんあり、そのタイプのリストを持つモナドが必要です。これは抽象的に聞こえるかもしれませんが、非常に便利です。 IDによってデータベースから同時にいくつかの顧客をロードしたいとします。そのため、異なるIDメソッドを使用するために複数回loadCustomer(id)を実行すると、呼び出しごとにPromiseが返されます。これで、Promiseというリストができましたが、本当に必要なのは、Webブラウザーに表示される顧客のリストなど、顧客のリストです。シーケンス()(RxJavaシーケンス()では、使用法に応じてconcat()またはmerge()と呼ばれます)演算子は、次のように構築されています。
FList custPromises = FList
.of(1、2、3)
.map(database :: loadCustomer)
Promise Customers = custPromises.sequence()
Customers.map((FList c)->…)
各ID、FListを呼び出すことにより、代表的な顧客IDマップ(FListファンクターにどのように役立つか知っていますか?)database.loadCustomer(id)があります。これにより、Promiseのリストが非常に不便になります。 sequence()は1日節約しますが、これは単なる構文糖衣ではありません。上記のコードは完全に非ブロッキングです。さまざまな種類のMonadssequence()は依然として意味がありますが、コンピューティング環境は異なります。たとえば、FListをFOptionalに変更できます。ちなみに、flatMap()にシーケンス()(map()と同じように)を実装することができます。
flatMap()一般的に言って、これは氷山の一角にすぎません。あいまいな圏論から派生していますが、Javaなどのオブジェクト指向プログラミング言語でも、モナドは非常に有用な抽象化であることが証明されています。モナド関数を返すように構成できる関数は非常に便利なので、無関係なクラスがモナドの動作に従います。
さらに、データがモナドにカプセル化されると、通常、明示的にデータを取得することは困難です。この操作はモナドの動作の一部ではなく、多くの場合、非慣用的なコードになります。たとえば、PromiseのPromise.get()は技術的にTを返すことができますが、ブロッキングを介してのみであり、flatMap()に基づくすべての演算子は非ブロッキングです。別の例はFOptional.get()ですが、FOptionalが空である可能性があるため失敗する可能性があります。 FList.get(idx)でさえ、リストの特定の要素をのぞき見するのは厄介に聞こえます。これは、forループマップ()を置き換えることができる場合が多いためです。
これらのモナドが今とても人気がある理由をご理解いただければ幸いです。 Javaのようなオブジェクト指向言語でも、それらは非常に便利な抽象化です。
最後に、長年の開発の後、Javaを学習するための一連の資料とインタビューの質問も要約しました。テクノロジーを向上させたい場合は、私をフォローしたり、プライベートメッセージを送信して情報を受信したり、コメント領域に連絡先情報を残したりできます。再投稿を注文して、より多くの人に見てもらうのを忘れないでください。 画像