Comment écrire des programmes polymorphes en utilisant Arrow



Bonjour, Habr!

Je m'appelle Artyom Dobrovinsky, je travaille pour Finch . Je suggère de lire un article d'un des pères de la bibliothèque de programmation fonctionnelle Arrow sur la façon d'écrire des programmes polymorphes. Souvent, les personnes qui commencent tout juste à écrire dans un style fonctionnel ne sont pas pressées de se séparer des vieilles habitudes, et écrivent en fait un impératif un peu plus élégant, avec des conteneurs DI et l'héritage. L'idée de réutiliser les fonctions quels que soient les types qu'elles utilisent peut inciter plusieurs à penser dans la bonne direction.

Profitez-en!


***


Et si nous pouvions écrire des applications sans penser aux types de données qui seront utilisées lors de l'exécution, mais simplement décrire comment ces données seront traitées?


Imaginez que nous ayons une application qui fonctionne avec le type Observable de la bibliothèque RxJava. Ce type nous permet d'écrire des chaînes d'appels et de manipulations avec des données, mais au final, cet Observable ne sera-t-il pas simplement un conteneur avec des propriétés supplémentaires?


La mĂŞme histoire avec des types comme Flowable , Deferred (Coroutines), Future , IO et bien d'autres.


Conceptuellement, tous ces types représentent une opération (déjà effectuée ou prévue pour être implémentée dans le futur) qui prend en charge des manipulations telles que la conversion d'une valeur interne en un autre type ( map ), en utilisant flatMap pour créer une chaîne d'opérations d'un type similaire, en combinant avec d'autres instances du même type ( zip ), etc.


Afin d'écrire des programmes basés sur ces comportements, tout en conservant une description déclarative, et aussi pour rendre vos programmes indépendants de types de données spécifiques comme Observable suffit que les types de données utilisés correspondent à certains contrats, tels que map , flatMap et autres .


Une telle approche peut sembler étrange ou trop compliquée, mais elle présente des avantages intéressants. Tout d'abord, considérez un exemple simple, puis parlez-en.


Problème canonique


Supposons que nous ayons une application avec une liste de Task et que nous souhaitions extraire une liste d'objets de type Task du cache local. S'ils ne sont pas trouvés dans le stockage local, nous essaierons de les interroger sur le réseau. Nous avons besoin d'un contrat unique pour les deux sources de données afin que les deux puissent obtenir une liste d'objets de type Task pour un objet User approprié, quelle que soit la source:


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

Ici, pour plus de simplicité, nous Observable , mais il peut être Single , Maybe - Maybe , Flowable , Deferred - tout ce qui convient pour atteindre l'objectif.


Ajoutez quelques implémentations mocha de sources de données, une pour et une pour .


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

Les implémentations des deux sources de données sont presque identiques. Ce sont simplement des versions simulées de ces sources qui tirent idéalement des données du stockage local ou de l'API réseau. Dans les deux cas, Map<User, List<Task>> est utilisé pour stocker les données.


Parce que nous avons deux sources de données, nous devons les coordonner en quelque sorte. Créez un référentiel:


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

Il essaie simplement de charger la List<Task> partir de LocalDataSource , et s'il n'est pas trouvé, il essaie de les demander au réseau à l'aide de RemoteDataSource .


Créons un module simple pour fournir des dépendances sans utiliser de framework pour l'injection de dépendances (DI):


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

Et enfin, nous avons besoin d'un test simple qui exécute toute la pile d'opérations:


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

Tout le code ci-dessus peut être trouvé sur le github .


Ce programme compose la chaîne d'exécution pour trois utilisateurs, puis s'abonne à l' Observable résultant.


Les deux premiers objets de type User sont disponibles, avec cela nous avons eu de la chance. User1 est disponible dans la DataSource locale et l'utilisateur 2 est disponible dans la télécommande.


Mais il y a un problème avec User3 , car il n'est pas disponible dans le stockage local. Le programme essaiera de le télécharger à partir d'un service distant - mais il n'y est pas non plus. La recherche échouera et nous afficherons un message d'erreur dans la console.


Voici ce qui sera affiché dans la console pour les trois cas:


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

Nous en avons terminé avec un exemple. Essayons maintenant de programmer cette logique dans le style du .


Abstraction des types de données


Maintenant, le contrat pour l'interface DataSource ressemblera Ă  ceci:


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

Tout semble similaire, mais il existe deux différences importantes:


  • Il y a une dĂ©pendance au type gĂ©nĂ©ralisĂ© (gĂ©nĂ©rique) F
  • Le type renvoyĂ© par la fonction est dĂ©sormais Kind<F, List<Task>> .

Kind est la façon dont Arrow code ce qui est communément appelé un (higher kind) .
Je vais expliquer ce concept avec un exemple simple.


Observable<A> comprend 2 parties:


  • Observable : conteneur, type fixe.
  • A : argument de type gĂ©nĂ©rique. Une abstraction Ă  laquelle d'autres types peuvent ĂŞtre transmis.

Nous sommes habitués à prendre des types génériques comme A comme abstractions. Mais peu de gens savent que nous pouvons également abstraire des types de conteneurs comme Observable . Pour cela, il existe des types élevés.


L'idée est que nous pouvons avoir un constructeur comme F<A> dans lequel F et A peuvent être un type générique. Cette syntaxe n'est pas encore prise en charge par le compilateur Kotlin ( encore? ), Nous allons donc l'imiter avec une approche similaire.


Arrow prend cela en charge en utilisant une méta-interface intermédiaire Kind<F, A> , qui contient des liens vers les deux types, et génère également des convertisseurs dans les deux sens pendant la compilation afin que vous puissiez suivre le chemin de Kind<Observable, List<Task>> à Observable<List<Task>> et vice versa. Pas une solution idéale, mais une solution qui fonctionne.


Encore une fois, regardez l'interface de notre référentiel:


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

La fonction DataSource renvoie un type élevé: Kind<F, List<Task>> . Cela se traduit par F<List<Task>> , où F reste généralisé.


Nous capturons uniquement List<Task> dans la signature. En d'autres termes, peu nous importe quel conteneur de type F sera utilisé, tant qu'il contient une List<Task> . Nous pouvons transmettre différents conteneurs de données à la fonction. Déjà clair? Allez-y.


Jetons un coup d'œil au DataSource implémenté de cette manière, mais cette fois pour chacun individuellement. Du premier au local:


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

Beaucoup de nouvelles choses ont été ajoutées, nous analyserons tout étape par étape.


Ce DataSource conserve le type générique F car il implémente un DataSource<F> . Nous voulons garder la possibilité de transmettre ce type de l'extérieur.


Maintenant, oubliez l' ApplicativeError éventuellement inconnue dans le constructeur et concentrez-vous sur la fonction allTasksByUser() . Et nous reviendrons sur ApplicativeError .


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

On peut voir qu'il retourne Kind<F, List<Task>> . Peu nous importe le contenu du conteneur F tant qu'il contient une List<Task> .


Mais il y a un problème. Selon que nous pouvons trouver la liste des objets de Task pour l'utilisateur requis dans le stockage local ou non, nous voulons signaler une erreur (aucune Task trouvée) ou renvoyer la Task déjà enveloppée dans F ( Task trouvée).


Et dans les deux cas, nous devons retourner: Kind<F, List<Task>> .


En d'autres termes: il existe un type dont nous ne savons rien sur ( F ), et nous avons besoin d'un moyen de renvoyer une erreur enveloppée dans ce type. De plus, nous avons besoin d'un moyen de créer une instance de ce type, dans laquelle la valeur obtenue après la réussite de la fonction sera encapsulée. Cela ressemble à quelque chose d'impossible?


Revenons à la déclaration de classe et notons que ApplicativeError est passée au constructeur puis utilisée comme délégué pour la classe ( by A ).


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

ApplicativeError hérité de Applicative , les deux sont des classes de type.


Les classes de type définissent des comportements (contrats). Ils sont encodés sous forme d'interfaces qui fonctionnent avec des arguments sous forme de types génériques, comme dans Monad<F> , Functor<F> et bien d'autres. Ce F est un type de données. De cette façon, nous pouvons passer des types comme Either , Option , IO , Observable , Flowable et bien d'autres.


Revenons donc à nos deux problèmes:


  • Envelopper la valeur obtenue après la rĂ©ussite de la fonction dans Kind<F, List<Task>>

Pour cela nous pouvons utiliser une classe de type Applicative . Puisque ApplicativeError hérité, nous pouvons déléguer ses propriétés.


Applicative fournit simplement la fonction just(a) . just(a) encapsule la valeur dans le contexte de tout type élevé. Ainsi, si nous avons Applicative<F> , il peut appeler just(a) pour envelopper la valeur dans le conteneur F , quelle que soit cette valeur. Supposons que nous utilisons un Observable , nous aurons un Applicative<Observable> qui sait comment envelopper a dans un Observable pour obtenir Observable.just(a) en conséquence.


  • Envelopper l'erreur dans l'instance Kind<F, List<Task>>

Pour cela, nous pouvons utiliser ApplicativeError . Il fournit une fonction raiseError(e) , qui encapsule l'erreur dans un conteneur de type F Pour l'exemple Observable , une erreur créera quelque chose comme Observable.error<A>(t) , où t est Throwable , car nous avons déclaré notre type d'erreur en tant que classe de type ApplicativeError<F, Throwable> .


Jetez un œil à notre implémentation abstraite de 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) } ) } 

La Map<User, List<Task>> stockée dans la mémoire reste la même, mais maintenant la fonction fait deux ou trois choses qui peuvent être nouvelles pour vous:


  • Elle essaie de charger la liste des Task partir du cache local, et comme la valeur de retour peut ĂŞtre null (la Task peut ne pas ĂŞtre trouvĂ©e), nous modĂ©lisons cela en utilisant Option . Si le fonctionnement d' Option n'est pas clair, il modĂ©lise la prĂ©sence ou l'absence de la valeur qui y est enveloppĂ©e.


  • Après avoir reçu la valeur facultative, nous appelons fold par-dessus. Cela Ă©quivaut Ă  utiliser la when sur une valeur facultative. Si la valeur est manquante, Option encapsule l'erreur dans le type de donnĂ©es F (premier lambda passĂ©). Et si la valeur est prĂ©sente, Option crĂ©e une instance d'encapsuleur pour le type de donnĂ©es F (deuxième lambda). Dans les deux cas, les propriĂ©tĂ©s ApplicativeError mentionnĂ©es prĂ©cĂ©demment sont utilisĂ©es: raiseError() et just() .



Ainsi, nous avons résumé l'implémentation de sources de données à l'aide de classes afin qu'elles ne sachent pas quel conteneur sera utilisé pour le type F .


L'implémentation d'un DataSource réseau ressemble à ceci:


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

Mais il y a une petite différence: au lieu de déléguer à l'instance ApplicativeError , nous utilisons une autre classe comme: Async .


En effet, les appels réseau sont de nature asynchrone. Nous voulons écrire du code qui sera exécuté de manière asynchrone, il est logique d'utiliser une classe de type conçue pour cela.


Async utilisé pour simuler des opérations asynchrones. Il peut simuler n'importe quelle opération de rappel. Notez que nous ne connaissons toujours pas les types de données spécifiques; nous décrivons simplement une opération de nature asynchrone.


Considérez la fonction suivante:


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

Nous pouvons utiliser la fonction async {} , qui nous est fournie avec une classe de type Async pour simuler l'opération et créer une instance de type Kind<F, List<Task>> qui sera créée de manière asynchrone.


Si nous avons utilisé un type de données fixe comme Observable , Async.async {} serait équivalent à Observable.create() , c'est-à-dire créer une opération qui peut être appelée à partir d'un code synchrone ou asynchrone, tel que Thread ou AsyncTask .


Le paramètre de callback est utilisé pour lier les rappels résultants au contexte de conteneur F , qui est un type élevé.


Ainsi, notre RemoteDataSource abstraite et dépend du conteneur encore inconnu de type F


Montons au niveau de l'abstraction et jetons un coup d'œil à notre référentiel. Si vous vous en souvenez, nous devons d'abord rechercher les objets Task dans LocalDataSource , puis seulement (s'ils ne sont pas trouvés localement) pour les demander à 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> est à nouveau avec nous! Il fournit également une fonction handleErrorWith() qui fonctionne au-dessus de tout récepteur haut de gamme.


Cela ressemble Ă  ceci:


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

Parce que localDS.allTasksByUser(user) renvoie Kind<F, List<Task>> , qui peut être considéré comme F<List<Task>> , où F reste un type générique, nous pouvons appeler handleErrorWith() par-dessus.


handleErrorWith() vous permet de répondre aux erreurs en utilisant le lambda passé. Examinons de plus près la fonction:


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

Ainsi, nous obtenons le résultat de la première opération, sauf lorsqu'une exception a été levée. L'exception sera gérée par la lambda. Si l'erreur appartient au type UserNotInLocalStorage , nous essaierons de trouver des objets du type Tasks dans le DataSource distant. Dans tous les autres cas, nous enveloppons l'erreur inconnue dans un conteneur de type F


Le module de dépendance reste très similaire à la version précédente:


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

La seule différence est qu'il est désormais abstrait et dépend de F , qui reste polymorphe. Je n'ai délibérément pas fait attention à cela afin de réduire le niveau de bruit, mais Async hérité de ApplicativeError , il peut donc être utilisé comme instance à tous les niveaux d'exécution du programme.


Test du polymorphisme


Enfin, notre application est complètement abstraite de l'utilisation de types de données spécifiques pour les conteneurs ( F ) et nous pouvons nous concentrer sur le test du polyformisme en runtime. Nous allons tester le même morceau de code en lui transmettant différents types de données pour le type F Le scénario est le même que lorsque nous avons utilisé Observable .


Le programme est écrit de telle manière que nous nous débarrassons complètement des limites des abstractions et pouvons transmettre les détails de l'implémentation comme souhaité.


Tout d'abord, essayons d'utiliser F Single de RxJava comme conteneur.


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

Pour des raisons de compatibilité, Arrow fournit des wrappers pour les types de données de bibliothèque bien connus. Par exemple, il existe un wrapper SingleK pratique. Ces wrappers vous permettent d'utiliser des classes de types conjointement avec des types de données en tant que types élevés.


Les éléments suivants seront affichés sur la console:


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

Le même résultat sera si vous utilisez Observable .


Travaillons maintenant avec Maybe , pour lequel le wrapper MaybeK est 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) } } 

Le même résultat sera affiché sur la console, mais en utilisant maintenant un type de données différent:


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

Qu'en est-il d' ObservableK / FlowableK ?
Essayons-le:


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

Nous verrons dans la console:


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

Tout fonctionne comme prévu.


Essayons d'utiliser DeferredK , un wrapper pour le type kotlinx.coroutines.Deferred .


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

Comme vous le savez, la gestion des exceptions lors de l'utilisation de la corutine doit ĂŞtre explicitement prescrite. , , .


— :


 [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 . , , .

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


All Articles