Arrowを使用してポリモーフィックプログラムを作成する方法



こんにちは、Habr!

私の名前はArtyom Dobrovinskyです。 フィンチで働いています。 ポリモーフィックプログラムの記述方法に関するArrow関数型プログラミングライブラリの先祖の1人による記事を読むことをお勧めします。 多くの場合、機能的なスタイルで書き始めたばかりの人は、古い習慣を手放すことを急ぐことはなく、実際にはDIコンテナーと継承を使用して、もう少しエレガントな命令を書きます。 使用するタイプに関係なく関数を再利用するという考え方は、多くの人が正しい方向で考えることを促すかもしれません。

お楽しみください!


***


実行時に使用されるデータの種類を考えずにアプリケーションを記述でき、このデータがどのように処理されるかを単に記述できたらどうでしょうか?


RxJavaライブラリのObservable型で動作するアプリケーションがあると想像してください。 このタイプにより、データの呼び出しと操作のチェーンを書くことができますが、最終的に、このObservableは単なる追加のプロパティを持つコンテナではありませんか?


FlowableDeferred (Coroutines)、 FutureIOなど、多くの種類の同じストーリー。


概念的には、これらのすべてのタイプは、内部値を別のタイプ( map )にキャストするような操作をサポートする操作(既に行われているか、将来実装される予定)です。 )など


これらの動作に基づいてプログラムを記述し、宣言的な記述を維持し、 Observableなどの特定のデータ型からプログラムを独立させるObservable使用するデータ型がmapflatMapなどの特定のコントラクトに対応していれば十分です。 。


そのようなアプローチは奇妙に見えるか複雑すぎるかもしれませんが、興味深い利点があります。 最初に簡単な例を考えてから、それらについて話します。


正規の問題


To Doリストを持つアプリケーションがあり、ローカルキャッシュからTaskタイプのオブジェクトのリストを抽出するとします。 ローカルストレージで見つからない場合は、ネットワーク経由でリクエストを試みます。 ソースに関係なく、両方のデータソースが適切なUserオブジェクトのTaskタイプのオブジェクトのリストを取得できるように、両方のデータソースに単一のコントラクトが必要です。


 interface DataSource { fun allTasksByUser(user: User): Observable<List<Task>> } 

ここでは、簡単にするためにObservableを返しますが、 SingleMaybeFlowableDeferred目標を達成するのに適したものであれば何でもFlowable Maybeん。


データソースのいくつかのmook実装を追加します。1つは 、もう1つはです。


 class LocalDataSource : DataSource { private val localCache: Map<User, List<Task>> = mapOf(User(UserId("user1")) to listOf(Task("LocalTask assigned to user1"))) override fun allTasksByUser(user: User): Observable<List<Task>> = Observable.create { emitter -> val cachedUser = localCache[user] if (cachedUser != null) { emitter.onNext(cachedUser) } else { emitter.onError(UserNotInLocalStorage(user)) } } } class RemoteDataSource : DataSource { private val internetStorage: Map<User, List<Task>> = mapOf(User(UserId("user2")) to listOf(Task("Remote Task assigned to user2"))) override fun allTasksByUser(user: User): Observable<List<Task>> = Observable.create { emitter -> val networkUser = internetStorage[user] if (networkUser != null) { emitter.onNext(networkUser) } else { emitter.onError(UserNotInRemoteStorage(user)) } } } 

両方のデータソースの実装はほぼ同じです。 これらは、これらのソースの単なるモックバージョンであり、理想的にはローカルストレージまたはネットワークAPIからデータをプルします。 どちらの場合も、データの保存にはMap<User, List<Task>>が使用されます。


なぜなら データのソースは2つあるため、何らかの方法でそれらを調整する必要があります。 リポジトリを作成します。


 class TaskRepository(private val localDS: DataSource, private val remoteDS: RemoteDataSource) { fun allTasksByUser(user: User): Observable<List<Task>> = localDS.allTasksByUser(user) .subscribeOn(Schedulers.io()) .observeOn(Schedulers.computation()) .onErrorResumeNext { _: Throwable -> remoteDS.allTasksByUser(user) } } 

LocalDataSourceからList<Task>をロードしようとします。見つからない場合は、 RemoteDataSourceを使用してネットワークからそれらを要求しようとします。


依存関係注入(DI)のフレームワークを使用せずに依存関係を提供するための簡単なモジュールを作成しましょう。


 class Module { private val localDataSource: LocalDataSource = LocalDataSource() private val remoteDataSource: RemoteDataSource = RemoteDataSource() val repository: TaskRepository = TaskRepository(localDataSource, remoteDataSource) } 

最後に、操作のスタック全体を実行する簡単なテストが必要です。


 object test { @JvmStatic fun main(args: Array<String>): Unit { val user1 = User(UserId("user1")) val user2 = User(UserId("user2")) val user3 = User(UserId("unknown user")) val dependenciesModule = Module() dependenciesModule.run { repository.allTasksByUser(user1).subscribe({ println(it) }, { println(it) }) repository.allTasksByUser(user2).subscribe({ println(it) }, { println(it) }) repository.allTasksByUser(user3).subscribe({ println(it) }, { println(it) }) } } } 

上記のコードはすべてgithubにあります。


このプログラムは、3人のユーザーの実行チェーンを構成し、結果のObservableサブスクライブします。


タイプUserの最初の2つのオブジェクトが使用可能であり、これは幸運でした。 User1はローカルのDataSourceで使用でき、 User2はリモートで使用できます。


ただし、ローカルストレージでは使用できないため、 User3に問題があります。 プログラムはリモートサービスからダウンロードしようとしますが、そこにもありません。 検索は失敗し、コンソールにエラーメッセージが表示されます。


3つのケースすべてについて、コンソールに表示される内容は次のとおりです。


 > [Task(value=LocalTask assigned to user1)] > [Task(value=Remote Task assigned to user2)] > UserNotInRemoteStorage(user=User(userId=UserId(value=unknown user))) 

例はこれで完了です。 ここで、このロジックを スタイルでプログラミングしてみましょう。


データ型の抽象化


これで、 DataSourceインターフェイスのコントラクトは次のようになります。


 interface DataSource<F> { fun allTasksByUser(user: User): Kind<F, List<Task>> } 

すべてが似ているように見えますが、2つの重要な違いがあります。



Kindは、Arrowが一般に (higher kind)と呼ばれるものをエンコードする方法 (higher kind)
この概念を簡単な例で説明します。


Observable<A>は2つの部分があります。



Aようなジェネリック型を抽象化として扱うために使用されます。 しかし、 ObservableようObservableコンテナ型を抽象化できることを知っている人はあまりいません。 このために、ハイタイプがあります。


FA両方をジェネリック型にAことができるF<A>ようなコンストラクタを持つことができるという考え方です。 この構文はまだKotlinコンパイラーによってサポートされていません( まだ? )ので、同様のアプローチで模倣します。


Arrowは、両方のタイプへのリンクを含む中間メタインターフェースKind<F, A>の使用によりこれをサポートし、コンパイル中に両方向のコンバーターを生成するため、 Kind<Observable, List<Task>>からObservable<List<Task>> 、またはその逆。 理想的なソリューションではなく、実用的なソリューションです。


もう一度、リポジトリのインターフェイスを見てください。


 interface DataSource<F> { fun allTasksByUser(user: User): Kind<F, List<Task>> } 

DataSource関数は、上位タイプKind<F, List<Task>>返します。 F<List<Task>>に変換され、 Fは一般化されたままです。


署名のList<Task>のみをキャプチャします。 つまり、 List<Task>が含まれている限り、どのタイプFコンテナが使用されるかは気にしません。 関数に異なるデータコンテナを渡すことができます。 もうクリア? どうぞ


この方法で実装されたDataSource見てみましょうが、今回はそれぞれ個別に行います。 最初にローカル:


 class LocalDataSource<F>(A: ApplicativeError<F, Throwable>) : DataSource<F>, ApplicativeError<F, Throwable> by A { private val localCache: Map<User, List<Task>> = mapOf(User(UserId("user1")) to listOf(Task("LocalTask assigned to user1"))) override fun allTasksByUser(user: User): Kind<F, List<Task>> = Option.fromNullable(localCache[user]).fold( { raiseError(UserNotInLocalStorage(user)) }, { just(it) } ) } 

多くの新しいものが追加されました。すべてを段階的に分析します。


このデータDataSource 、データDataSource<F>実装するため、ジェネリック型F保持します。 このタイプを外部から送信する可能性を維持したいと考えています。


ここで、コンストラクターのなじみのないApplicativeError忘れて、 allTasksByUser()関数に注目してallTasksByUser() 。 そして、 ApplicativeError戻りApplicativeError


 override fun allTasksByUser(user: User): Kind<F, List<Task>> = Option.fromNullable(localCache[user]).fold( { raiseError(UserNotInLocalStorage(user)) }, { just(it) } ) 

Kind<F, List<Task>>返すことがKind<F, List<Task>>ます。 コンテナF List<Task>が含まれている限り、コンテナF何であるかは関係ありません。


しかし、問題があります。 ローカルストレージで目的のユーザーのTaskオブジェクトのリストを見つけることができるかどうかに応じて、エラーを報告する( Task見つからない)か、既にラップされているTaskTask見つかった)を返します。


そして、どちらの場合もKind<F, List<Task>>を返す必要がありKind<F, List<Task>>


つまり、何も知らない型( F )があり、その型にラップされたエラーを返す方法が必要です。 さらに、このタイプのインスタンスを作成する方法が必要です。この方法では、関数が正常に完了した後に取得された値がラップされます。 不可能なように聞こえますか?


クラス宣言に戻って、 ApplicativeErrorがコンストラクターに渡され、クラスのデリゲートとして使用されることに注意してください( by A )。


 class LocalDataSource<F>(A: ApplicativeError<F, Throwable>) : DataSource<F>, ApplicativeError<F, Throwable> by A { //... } 

ApplicativeErrorから継承され、どちらも型クラスです。


型クラスは、動作(契約)を定義します。 これらは、 Monad<F>Functor<F>などのように、ジェネリック型の形式で引数を扱うインターフェイスとしてエンコードされます。 このFはデータ型です。 このようにして、 Flowable OptionIOObservableFlowable型を渡すことができます。


したがって、2つの問題に戻ります。



このために、 Applicativeタイプのクラスを使用できます。 ApplicativeErrorそこから継承されているため、そのプロパティを委任できます。


Applicativejust(a)関数を提供します。 just(a)任意の高型のコンテキストで値をラップします。 したがって、 Applicative<F>がある場合は、 just(a)を呼び出して、その値が何であれ、コンテナFに値をラップできます。 Observableを使用すると、 ObservableでラップしてObservable.just(a)を最終的に取得する方法を知っているApplicative<Observable>がありApplicative<Observable>



このためにApplicativeErrorを使用できます。 タイプFコンテナでエラーをラップする関数raiseError(e)提供しますF Observable例では、エラータイプApplicativeError<F, Throwable>クラスとしてエラータイプを宣言したため、エラーによりObservable.error<A>(t)ようObservable.error<A>(t)ものが作成されますObservable.error<A>(t) tThrowable Observable.error<A>(t)


LocalDataSource<F>抽象的な実装を見てください。


 class LocalDataSource<F>(A: ApplicativeError<F, Throwable>) : DataSource<F>, ApplicativeError<F, Throwable> by A { private val localCache: Map<User, List<Task>> = mapOf(User(UserId("user1")) to listOf(Task("LocalTask assigned to user1"))) override fun allTasksByUser(user: User): Kind<F, List<Task>> = Option.fromNullable(localCache[user]).fold( { raiseError(UserNotInLocalStorage(user)) }, { just(it) } ) } 

メモリに保存されているMap<User, List<Task>>は同じままですが、この関数は新しい機能をいくつか実行します。



そのため、クラスを使用してデータソースの実装を抽象化し、クラスFどのコンテナが使用されるかを認識しないようにしました。


ネットワークDataSource実装は次のようになります。


 class RemoteDataSource<F>(A: Async<F>) : DataSource<F>, Async<F> by A { private val internetStorage: Map<User, List<Task>> = mapOf(User(UserId("user2")) to listOf(Task("Remote Task assigned to user2"))) override fun allTasksByUser(user: User): Kind<F, List<Task>> = async { callback: (Either<Throwable, List<Task>>) -> Unit -> Option.fromNullable(internetStorage[user]).fold( { callback(UserNotInRemoteStorage(user).left()) }, { callback(it.right()) } ) } } 

ただし、1つの小さな違いがありますApplicativeErrorインスタンスに委任する代わりに、 Asyncような別のクラスを使用します。


これは、ネットワーク呼び出しが本質的に非同期であるためです。 非同期に実行されるコードを書きたいのですが、このために設計された型のクラスを使用するのが論理的です。


Async非同期操作をシミュレートするために使用されます。 任意のコールバック操作をシミュレートできます。 特定のデータ型はまだわからないことに注意してください;本質的に非同期な操作を説明するだけです。


次の機能を検討してください。


 override fun allTasksByUser(user: User): Kind<F, List<Task>> = async { callback: (Either<Throwable, List<Task>>) -> Unit -> Option.fromNullable(internetStorage[user]).fold( { callback(UserNotInRemoteStorage(user).left()) }, { callback(it.right()) } ) } 

操作をモデル化するためにAsync型のクラスによって提供されるasync {}関数を使用し、 async {}で作成されるKind<F, List<Task>>型のインスタンスを作成できます。


ObservableようObservable固定データ型を使用した場合、 Async.async {}Observable.create()と同等になります。 ThreadAsyncTaskなどの同期または非同期コードから呼び出すことができる操作を作成します。


callbackパラメーターは、結果のコールバックをコンテナコンテキストFにリンクするために使用されます。コンテナーコンテキストFはハイタイプです。


したがって、 RemoteDataSource抽象化され、まだ不明なタイプFコンテナーに依存していますF


抽象化のレベルに進み、リポジトリをもう一度見てみましょう。 覚えている場合は、まずLocalDataSourceTaskオブジェクトを検索し、次に(ローカルで見つからなかった場合) RemoteLocalDataSourceからそれらを要求する必要がRemoteLocalDataSourceます。


 class TaskRepository<F>( private val localDS: DataSource<F>, private val remoteDS: RemoteDataSource<F>, AE: ApplicativeError<F, Throwable>) : ApplicativeError<F, Throwable> by AE { fun allTasksByUser(user: User): Kind<F, List<Task>> = localDS.allTasksByUser(user).handleErrorWith { when (it) { is UserNotInLocalStorage -> remoteDS.allTasksByUser(user) else -> raiseError(UnknownError(it)) } } } 

ApplicativeError<F, Throwable>が再び登場しました! また、ハイエンドレシーバー上で実行されるhandleErrorWith()関数も提供します。


次のようになります。


 fun <A> Kind<F, A>.handleErrorWith(f: (E) -> Kind<F, A>): Kind<F, A> 

なぜなら localDS.allTasksByUser(user)Kind<F, List<Task>>返します。これはF<List<Task>>として考えることができます。ここで、 Fはジェネリック型のままで、その上でhandleErrorWith()を呼び出すことができます。


handleErrorWith()使用すると、渡されたラムダを使用してエラーに応答できます。 関数を詳しく見てみましょう:


 fun allTasksByUser(user: User): Kind<F, List<Task>> = localDS.allTasksByUser(user).handleErrorWith { when (it) { is UserNotInLocalStorage -> remoteDS.allTasksByUser(user) else -> raiseError(UnknownError(it)) } } 

したがって、例外がスローされた場合を除き、最初の操作の結果を取得します。 例外はラムダによって処理されます。 エラーがUserNotInLocalStorageタイプに属する場合、リモートDataSourceTasksタイプのオブジェクトを見つけようとします。 他のすべてのケースでは、不明なエラーをタイプFコンテナにラップしますF


依存関係モジュールは以前のバージョンと非常によく似ています。


 class Module<F>(A: Async<F>) { private val localDataSource: LocalDataSource<F> = LocalDataSource(A) private val remoteDataSource: RemoteDataSource<F> = RemoteDataSource(A) val repository: TaskRepository<F> = TaskRepository(localDataSource, remoteDataSource, A) } 

唯一の違いは、現在抽象的であり、多態のままであるFに依存していることです。 私は意図的にノイズレベルを下げるためにこれに注意を払いませんでしたが、 AsyncApplicativeError継承するため、プログラム実行のすべてのレベルでインスタンスとして使用できます。


多型のテスト


最後に、アプリケーションはコンテナ( F )の特定のデータ型の使用から完全に抽象化されており、実行時のポリフォームのテストに集中できます。 タイプF異なるタイプのデータを渡す同じコードをテストしますF シナリオは、 Observableを使用したときと同じです。


このプログラムは、抽象化の境界を完全に取り除き、必要に応じて実装の詳細を伝えることができるように書かれています。


最初に、RxJavaのF Singleをコンテナとして使用してみましょう。


 object test { @JvmStatic fun main(args: Array<String>): Unit { val user1 = User(UserId("user1")) val user2 = User(UserId("user2")) val user3 = User(UserId("unknown user")) val singleModule = Module(SingleK.async()) singleModule.run { repository.allTasksByUser(user1).fix().single.subscribe(::println, ::println) repository.allTasksByUser(user2).fix().single.subscribe(::println, ::println) repository.allTasksByUser(user3).fix().single.subscribe(::println, ::println) } } } 

互換性のために、Arrowは既知のライブラリデータ型のラッパーを提供します。 たとえば、便利なSingleKラッパーがあります。 これらのラッパーを使用すると、型クラスをデータ型と組み合わせて上位型として使用できます。


以下がコンソールに表示されます。


 [Task(value=LocalTask assigned to user1)] [Task(value=Remote Task assigned to user2)] UserNotInRemoteStorage(user=User(userId=UserId(value=unknown user))) 

Observableを使用する場合も同じ結果になります。


次に、 MaybeラッパーをMaybeK MaybeMaybeK


 @JvmStatic fun main(args: Array<String>): Unit { val user1 = User(UserId("user1")) val user2 = User(UserId("user2")) val user3 = User(UserId("unknown user")) val maybeModule = Module(MaybeK.async()) maybeModule.run { repository.allTasksByUser(user1).fix().maybe.subscribe(::println, ::println) repository.allTasksByUser(user2).fix().maybe.subscribe(::println, ::println) repository.allTasksByUser(user3).fix().maybe.subscribe(::println, ::println) } } 

同じ結果がコンソールに表示されますが、現在は異なるデータ型を使用しています:


 [Task(value=LocalTask assigned to user1)] [Task(value=Remote Task assigned to user2)] UserNotInRemoteStorage(user=User(userId=UserId(value=unknown user))) 

ObservableK / FlowableKどうですか?
試してみましょう:


 object test { @JvmStatic fun main(args: Array<String>): Unit { val user1 = User(UserId("user1")) val user2 = User(UserId("user2")) val user3 = User(UserId("unknown user")) val observableModule = Module(ObservableK.async()) observableModule.run { repository.allTasksByUser(user1).fix().observable.subscribe(::println, ::println) repository.allTasksByUser(user2).fix().observable.subscribe(::println, ::println) repository.allTasksByUser(user3).fix().observable.subscribe(::println, ::println) } val flowableModule = Module(FlowableK.async()) flowableModule.run { repository.allTasksByUser(user1).fix().flowable.subscribe(::println) repository.allTasksByUser(user2).fix().flowable.subscribe(::println) repository.allTasksByUser(user3).fix().flowable.subscribe(::println, ::println) } } } 

コンソールに表示されます:


 [Task(value=LocalTask assigned to user1)] [Task(value=Remote Task assigned to user2)] UserNotInRemoteStorage(user=User(userId=UserId(value=unknown user))) [Task(value=LocalTask assigned to user1)] [Task(value=Remote Task assigned to user2)] UserNotInRemoteStorage(user=User(userId=UserId(value=unknown user))) 

すべてが期待どおりに機能します。


タイプkotlinx.coroutines.DeferredラッパーであるDeferredKを使用してみましょう。


 object test { @JvmStatic fun main(args: Array<String>): Unit { val user1 = User(UserId("user1")) val user2 = User(UserId("user2")) val user3 = User(UserId("unknown user")) val deferredModule = Module(DeferredK.async()) deferredModule.run { runBlocking { try { println(repository.allTasksByUser(user1).fix().deferred.await()) println(repository.allTasksByUser(user2).fix().deferred.await()) println(repository.allTasksByUser(user3).fix().deferred.await()) } catch (e: UserNotInRemoteStorage) { println(e) } } } } } 

ご存じのように、コルチン使用時の例外処理は明示的に規定する必要があります。 例外処理などの実装の詳細は、使用されるデータ型に依存するため、最高レベルの抽象化で定義されます。


もう一度-同じ結果:


 [Task(value=LocalTask assigned to user1)] [Task(value=Remote Task assigned to user2)] UserNotInRemoteStorage(user=User(userId=UserId(value=unknown user))) 

Arrow API DeferredK . runBlocking :


 object test { @JvmStatic fun main(args: Array<String>): Unit { val user1 = User(UserId("user1")) val user2 = User(UserId("user2")) val user3 = User(UserId("unknown user")) val deferredModuleAlt = Module(DeferredK.async()) deferredModuleAlt.run { println(repository.allTasksByUser(user1).fix().unsafeAttemptSync()) println(repository.allTasksByUser(user2).fix().unsafeAttemptSync()) println(repository.allTasksByUser(user3).fix().unsafeAttemptSync()) } } } 

[ Try ]({{ '/docs/arrow/core/try/ru' | relative_url }}) (.., Success Failure ).


 Success(value=[Task(value=LocalTask assigned to user1)]) Success(value=[Task(value=Remote Task assigned to user2)]) Failure(exception=UserNotInRemoteStorage(user=User(userId=UserId(value=unknown user)))) 

, , IO .
IO , in/out , , .


 object test { @JvmStatic fun main(args: Array<String>): Unit { val user1 = User(UserId("user1")) val user2 = User(UserId("user2")) val user3 = User(UserId("unknown user")) val ioModule = Module(IO.async()) ioModule.run { println(repository.allTasksByUser(user1).fix().attempt().unsafeRunSync()) println(repository.allTasksByUser(user2).fix().attempt().unsafeRunSync()) println(repository.allTasksByUser(user3).fix().attempt().unsafeRunSync()) } } } 

 Right(b=[Task(value=LocalTask assigned to user1)]) Right(b=[Task(value=Remote Task assigned to user2)]) Left(a=UserNotInRemoteStorage(user=User(userId=UserId(value=unknown user)))) 

IO — . Either<L,R> ( ). , "" Either , "" , . Right(...) , , Left(...) .


.


, . , , , .


.


… ?


, , . .



オプショナル


, .
, , , , .


, . — Twitter: @JorgeCastilloPR .


(, ) :



FP to the max John De Goes FpToTheMax.kt , arrow-examples . , , .



Source: https://habr.com/ru/post/J447234/


All Articles