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





こんにちは、Habr!



私の名前はArtyom Dobrovinskyです。 フィンチで働いています。 ポリモーフィックプログラムの記述方法に関するArrow



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

お楽しみください!







***



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







RxJavaライブラリのObservable



型で動作するアプリケーションがあると想像してください。 このタイプにより、データの呼び出しと操作のチェーンを書くことができますが、最終的に、このObservable



は単なる追加のプロパティを持つコンテナではありませんか?







Flowable



Deferred



(Coroutines)、 Future



IO



など、多くの種類の同じストーリー。







概念的には、これらのすべてのタイプは、内部値を別のタイプ( map



)にキャストするような操作をサポートする操作(既に行われているか、将来実装される予定)です。 )など







これらの動作に基づいてプログラムを記述し、宣言的な記述を維持し、 Observable



などの特定のデータ型からプログラムを独立させるObservable



使用するデータ型がmap



flatMap



などの特定のコントラクトに対応していれば十分です。 。







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







正規の問題



To Doリストを持つアプリケーションがあり、ローカルキャッシュからTask



タイプのオブジェクトのリストを抽出するとします。 ローカルストレージで見つからない場合は、ネットワーク経由でリクエストを試みます。 ソースに関係なく、両方のデータソースが適切なUser



オブジェクトのTask



タイプのオブジェクトのリストを取得できるように、両方のデータソースに単一のコントラクトが必要です。







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





ここでは、簡単にするためにObservable



を返しますが、 Single



Maybe



Flowable



Deferred



目標を達成するのに適したものであれば何でも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



コンテナ型を抽象化できることを知っている人はあまりいません。 このために、ハイタイプがあります。







F



A



両方をジェネリック型に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



見つからない)か、既にラップされているTask



Task



見つかった)を返します。







そして、どちらの場合も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



Option



IO



Observable



Flowable



型を渡すことができます。







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









このために、 Applicative



タイプのクラスを使用できます。 ApplicativeError



そこから継承されているため、そのプロパティを委任できます。







Applicative



just(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)



t



Throwable



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()



と同等になります。 Thread



AsyncTask



などの同期または非同期コードから呼び出すことができる操作を作成します。







callback



パラメーターは、結果のコールバックをコンテナコンテキストF



にリンクするために使用されます。コンテナーコンテキストF



はハイタイプです。







したがって、 RemoteDataSource



抽象化され、まだ不明なタイプF



コンテナーに依存していますF









抽象化のレベルに進み、リポジトリをもう一度見てみましょう。 覚えている場合は、まずLocalDataSource



Task



オブジェクトを検索し、次に(ローカルで見つからなかった場合) 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



タイプに属する場合、リモートDataSource



Tasks



タイプのオブジェクトを見つけようとします。 他のすべてのケースでは、不明なエラーをタイプ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



に依存していることです。 私は意図的にノイズレベルを下げるためにこれに注意を払いませんでしたが、 Async



ApplicativeError



継承するため、プログラム実行のすべてのレベルでインスタンスとして使用できます。







多型のテスト



最後に、アプリケーションはコンテナ( 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



Maybe



MaybeK









 @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



. , , .








All Articles