こんにちは、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) }) } } }
このプログラムは、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 .
(, ) :
- Kotlin Functional Programming: Does it make sense? Jorge Castillo
- Kotlin purity and Function Memoization Jorge Castillo
FP to the max John De Goes FpToTheMax.kt
, arrow-examples
. , , .