
こんにちは、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つの重要な違いがあります。
- 一般化タイプ(ジェネリック)
F依存していますF - 関数によって返される型は、現在
Kind<F, List<Task>>です。
Kindは、Arrowが一般に (higher kind)と呼ばれるものをエンコードする方法 (higher kind) 。
この概念を簡単な例で説明します。
Observable<A>は2つの部分があります。
Observable :コンテナ、固定タイプ。A :ジェネリック型の引数。 他の型を渡すことができる抽象化。
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つの問題に戻ります。
- 関数が正常に完了した後に取得した値を
Kind<F, List<Task>>ラップします
このために、 Applicativeタイプのクラスを使用できます。 ApplicativeErrorそこから継承されているため、そのプロパティを委任できます。
Applicativeはjust(a)関数を提供します。 just(a)任意の高型のコンテキストで値をラップします。 したがって、 Applicative<F>がある場合は、 just(a)を呼び出して、その値が何であれ、コンテナFに値をラップできます。 Observableを使用すると、 ObservableでラップしてObservable.just(a)を最終的に取得する方法を知っているApplicative<Observable>がありApplicative<Observable> 。
- インスタンスの
Kind<F, List<Task>>ラップエラー
このために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>>は同じままですが、この関数は新しい機能をいくつか実行します。
彼女はローカルキャッシュからTaskリストを読み込もうとしますが、戻り値がnull ( Taskが見つからない可能性がある)の可能性があるため、 Optionを使用してこれをモデル化します。 Optionがどのように機能するかが明確でない場合は、 Optionでラップされる値の有無をモデル化します。
オプションの値を受け取った後、その上でfoldを呼び出します。 これは、オプション値に対してwhenを使用することと同等です。 値が欠落している場合、 Optionはデータ型F (最初のラムダが渡された)でエラーをラップします。 値が存在する場合、 Optionはデータ型F (2番目のラムダ)のラッパーインスタンスを作成します。 どちらの場合も、前述のApplicativeErrorプロパティ、 raiseError()およびjust()が使用されます。
そのため、クラスを使用してデータソースの実装を抽象化し、クラス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(...) .
.
, . , , , .
.
… ?
, , . .
: , (, ), — . , .
, . . () ( ) , .
(), , (). , .
, . , ( ).
, API . ( map , flatMap , fold , ). , , Kotlin, Arrow — .
DI ( ), .., DI " ". , , . DI, .., , .
, , . , .., , .
オプショナル
, .
, , , , .
, . — Twitter: @JorgeCastilloPR .
(, ) :
FP to the max John De Goes FpToTheMax.kt , arrow-examples . , , .