
Hola Habr!
Mi nombre es Artyom Dobrovinsky, trabajo para
Finch . Sugiero leer un artículo de uno de los padres de la biblioteca de programación funcional
Arrow
sobre cómo escribir programas polimórficos. A menudo, las personas que recién comienzan a escribir en un estilo funcional no tienen prisa por deshacerse de los viejos hábitos, y de hecho escriben un imperativo un poco más elegante, con contenedores DI y herencia. La idea de reutilizar funciones independientemente de los tipos que usan puede hacer que muchos piensen en la dirección correcta.
¡A disfrutar!
***
¿Qué pasaría si pudiéramos escribir aplicaciones sin pensar en los tipos de datos que se utilizarán en tiempo de ejecución, sino simplemente describir cómo se procesarán estos datos?
Imagine que tenemos una aplicación que funciona con el tipo Observable
de la biblioteca RxJava. Este tipo nos permite escribir cadenas de llamadas y manipulaciones con datos, pero al final, ¿este Observable
no solo será un contenedor con propiedades adicionales?
La misma historia con tipos como Flowable
, Deferred
(Coroutines), Future
, IO
y muchos otros.
Conceptualmente, todos estos tipos representan una operación (ya realizada o planificada para implementarse en el futuro) que admite manipulaciones como convertir un valor interno a otro tipo ( map
), utilizando flatMap
para crear una cadena de operaciones de un tipo similar, que se combina con otras instancias del mismo tipo ( zip
), etc.
Para escribir programas basados en estos comportamientos, manteniendo una descripción declarativa, y también para hacer que sus programas sean independientes de tipos de datos específicos como Observable
suficiente que los tipos de datos utilizados correspondan a ciertos contratos, como map
, flatMap
y otros .
Tal enfoque puede parecer extraño o demasiado complicado, pero tiene ventajas interesantes. Primero, considere un ejemplo simple y luego hable sobre ellos.
Problema canónico
Supongamos que tenemos una aplicación con una lista de tareas pendientes, y nos gustaría extraer una lista de objetos de tipo Task
del caché local. Si no se encuentran en el almacenamiento local, intentaremos consultarlos a través de la red. Necesitamos un contrato único para ambas fuentes de datos para que ambas puedan obtener una lista de objetos de tipo Task
para un objeto de User
adecuado, independientemente de la fuente:
interface DataSource { fun allTasksByUser(user: User): Observable<List<Task>> }
Aquí, por simplicidad, devolvemos Observable
, pero puede ser Single
, Maybe
, Flowable
, Deferred
, cualquier cosa adecuada para lograr el objetivo.
Agregue un par de implementaciones mocha de fuentes de datos, una para
y otra para
.
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)) } } }
Las implementaciones de ambas fuentes de datos son casi idénticas. Estas son simplemente versiones simuladas de estas fuentes que idealmente obtienen datos del almacenamiento local o de la API de la red. En ambos casos, Map<User, List<Task>>
se usa para almacenar datos.
Porque Tenemos dos fuentes de datos, necesitamos coordinarlas de alguna manera. Crea un repositorio:
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) } }
Solo intenta cargar la List<Task>
de LocalDataSource
, y si no se encuentra, intenta solicitarlos desde la red utilizando RemoteDataSource
.
Creemos un módulo simple para proporcionar dependencias sin usar ningún marco para la inyección de dependencias (DI):
class Module { private val localDataSource: LocalDataSource = LocalDataSource() private val remoteDataSource: RemoteDataSource = RemoteDataSource() val repository: TaskRepository = TaskRepository(localDataSource, remoteDataSource) }
Y finalmente, necesitamos una prueba simple que ejecute toda la pila de operaciones:
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) }) } } }
Todo el código anterior se puede encontrar en el github .
Este programa compone la cadena de ejecución para tres usuarios, luego se suscribe al Observable
resultante.
Los primeros dos objetos de tipo User
están disponibles, con esto tuvimos suerte. User1
está disponible en el DataSource
local y User2
está disponible en el remoto.
Pero hay un problema con User3
, porque no está disponible en el almacenamiento local. El programa intentará descargarlo desde un servicio remoto, pero tampoco está allí. La búsqueda fallará y mostraremos un mensaje de error en la consola.
Esto es lo que se mostrará en la consola para los tres casos:
> [Task(value=LocalTask assigned to user1)] > [Task(value=Remote Task assigned to user2)] > UserNotInRemoteStorage(user=User(userId=UserId(value=unknown user)))
Hemos terminado con un ejemplo. Ahora intentemos programar esta lógica al estilo del
.
Abstracción de tipo de datos
Ahora el contrato para la interfaz DataSource
se verá así:
interface DataSource<F> { fun allTasksByUser(user: User): Kind<F, List<Task>> }
Todo parece ser similar, pero hay dos diferencias importantes:
- Existe una dependencia del tipo generalizado (genérico)
F
- El tipo devuelto por la función ahora es
Kind<F, List<Task>>
.
Kind
es cómo Arrow codifica lo que comúnmente se llama un (higher kind)
.
Explicaré este concepto con un ejemplo simple.
Observable<A>
tiene 2 partes:
Observable
: contenedor, tipo fijo.A
: argumento de un tipo genérico. Una abstracción a la que se pueden pasar otros tipos.
Estamos acostumbrados a tomar tipos genéricos como A
como abstracciones. Pero no mucha gente sabe que también podemos abstraer tipos de contenedores como Observable
. Para esto, hay tipos altos.
La idea es que podemos tener un constructor como F<A>
en el que tanto F
como A
pueden ser de tipo genérico. Esta sintaxis aún no es compatible con el compilador de Kotlin ( ¿todavía? ), Por lo que la imitaremos con un enfoque similar.
Arrow admite esto mediante el uso de una metainterfaz intermedia Kind<F, A>
, que contiene enlaces a ambos tipos, y también genera convertidores en ambas direcciones durante la compilación para que pueda seguir la ruta desde Kind<Observable, List<Task>>
a Observable<List<Task>>
y viceversa. No es una solución ideal, sino funcional.
Nuevamente, mire la interfaz de nuestro repositorio:
interface DataSource<F> { fun allTasksByUser(user: User): Kind<F, List<Task>> }
La función DataSource
devuelve un tipo alto: Kind<F, List<Task>>
. Se traduce en F<List<Task>>
, donde F
permanece generalizado.
Capturamos solo la List<Task>
en la firma. En otras palabras, no nos importa qué contenedor tipo F
se utilizará, siempre que contenga una List<Task>
. Podemos pasar diferentes contenedores de datos a la función. Ya claro? Adelante
Echemos un vistazo al DataSource
implementado de esta manera, pero esta vez para cada uno individualmente. Primero al 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) } ) }
Se han agregado muchas cosas nuevas, analizaremos todo paso a paso.
Este DataSource
retiene el tipo genérico F
porque implementa un DataSource<F>
. Queremos mantener la posibilidad de transmitir este tipo desde el exterior.
Ahora, olvídate del ApplicativeError
posiblemente desconocido en el constructor y allTasksByUser()
en la función allTasksByUser()
. Y volveremos a ApplicativeError
.
override fun allTasksByUser(user: User): Kind<F, List<Task>> = Option.fromNullable(localCache[user]).fold( { raiseError(UserNotInLocalStorage(user)) }, { just(it) } )
Se puede ver que devuelve Kind<F, List<Task>>
. Todavía no nos importa cuál es el contenedor F
siempre que contenga una List<Task>
.
Pero hay un problema. Dependiendo de si podemos encontrar la lista de objetos de la Task
para el usuario deseado en el almacenamiento local o no, queremos informar un error (no se encontró la Task
) o devolver la Task
ya envuelta en F
( Task
encontrada).
Y para ambos casos necesitamos regresar: Kind<F, List<Task>>
.
En otras palabras: hay un tipo del que no sabemos nada ( F
), y necesitamos una forma de devolver un error envuelto en ese tipo. Además, necesitamos una forma de crear una instancia de este tipo, en la que se envolverá el valor obtenido después de completar con éxito la función. ¿Suena como algo imposible?
Volvamos a la declaración de clase y observemos que ApplicativeError
se pasa al constructor y luego se usa como delegado de la clase ( by A
).
class LocalDataSource<F>(A: ApplicativeError<F, Throwable>) : DataSource<F>, ApplicativeError<F, Throwable> by A {
ApplicativeError
hereda de Applicative
, ambos son clases de tipo.
Las clases de tipos definen comportamientos (contratos). Están codificados como interfaces que funcionan con argumentos en forma de tipos genéricos, como en Monad<F>
, Functor<F>
y muchos otros. Esta F
es un tipo de datos. De esta manera, podemos pasar tipos como Either
, Option
, IO
, Observable
, Flowable
y muchos más.
Entonces, volviendo a nuestros dos problemas:
- Ajuste el valor obtenido después de que la función se complete con éxito en
Kind<F, List<Task>>
Para esto podemos usar una clase de tipo Applicative
. Dado que ApplicativeError
hereda de él, podemos delegar sus propiedades.
Applicative
solo proporciona la función just(a)
. just(a)
envuelve el valor en el contexto de cualquier tipo alto. Por lo tanto, si tenemos Applicative<F>
, puede llamar just(a)
para ajustar el valor en el contenedor F
, cualquiera que sea ese valor. Digamos que usamos Observable
, tendremos un Applicative<Observable>
que sabe cómo envolver a
en un Observable
, para que obtengamos Observable.just(a)
.
- Envuelva el error en la instancia
Kind<F, List<Task>>
Para esto podemos usar ApplicativeError
. Proporciona una función raiseError(e)
, que envuelve el error en un contenedor de tipo F
Para el ejemplo Observable
, un error creará algo como Observable.error<A>(t)
, donde t
es Throwable
, ya que declaramos nuestro tipo de error como una clase de tipo ApplicativeError<F, Throwable>
.
Eche un vistazo a nuestra implementación abstracta 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) } ) }
El Map<User, List<Task>>
almacenado en la memoria permanece igual, pero ahora la función hace un par de cosas que pueden ser nuevas para usted:
Intenta cargar la lista de Task
desde la memoria caché local, y dado que el valor de retorno puede ser null
(es posible que no se encuentre la Task
), modelamos esto mediante la Option
. Si no está claro cómo funciona Option
, entonces modela la presencia o ausencia del valor que está envuelto en ella.
Después de recibir el valor opcional, llamamos a fold
en la parte superior. Esto es equivalente a usar la when
sobre un valor opcional. Si falta el valor, entonces Option
envuelve el error en el tipo de datos F
(primer lambda pasado). Y si el valor está presente, Option
crea una instancia de contenedor para el tipo de datos F
(segundo lambda). En ambos casos, se utilizan las propiedades ApplicativeError
mencionadas anteriormente: raiseError()
y just()
.
Por lo tanto, resumimos la implementación de fuentes de datos utilizando clases para que no sepan qué contenedor se usará para el tipo F
.
La implementación de una red DataSource
ve así:
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()) } ) } }
Pero hay una pequeña diferencia: en lugar de delegar a la instancia ApplicativeError
, usamos otra clase como: Async
.
Esto se debe a que las llamadas de red son de naturaleza asíncrona. Queremos escribir código que se ejecutará de forma asíncrona, es lógico utilizar una clase de tipo diseñada para esto.
Async
usa para simular operaciones asincrónicas. Puede simular cualquier operación de devolución de llamada. Tenga en cuenta que todavía no conocemos los tipos de datos específicos; simplemente describimos una operación de naturaleza asíncrona.
Considere la siguiente función:
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()) } ) }
Podemos usar la función async {}
, que se nos proporciona una clase de tipo Async
para simular la operación y crear una instancia de tipo Kind<F, List<Task>>
que se creará de forma asincrónica.
Si utilizamos un tipo de datos fijo como Observable
, Async.async {}
sería equivalente a Observable.create()
, es decir creando una operación que se puede AsyncTask
desde código síncrono o asíncrono, como Thread
o AsyncTask
.
El parámetro de callback
se utiliza para vincular las devoluciones de llamada resultantes al contexto de contenedor F
, que es un tipo alto.
Por lo tanto, nuestro RemoteDataSource
abstrae y depende del contenedor aún desconocido de tipo F
Subamos al nivel de abstracción y echemos otro vistazo a nuestro repositorio. Si recuerda, primero necesitamos buscar objetos de Task
en LocalDataSource
, y solo entonces (si no se encontraron localmente) para solicitarlos de 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á con nosotros nuevamente. También proporciona una función handleErrorWith()
que funciona sobre cualquier receptor de gama alta.
Se ve así:
fun <A> Kind<F, A>.handleErrorWith(f: (E) -> Kind<F, A>): Kind<F, A>
Porque localDS.allTasksByUser(user)
devuelve Kind<F, List<Task>>
, que puede considerarse como F<List<Task>>
, donde F
sigue siendo un tipo genérico, podemos llamar a handleErrorWith()
por encima.
handleErrorWith()
permite responder a errores utilizando el lambda pasado. Echemos un vistazo más de cerca a la función:
fun allTasksByUser(user: User): Kind<F, List<Task>> = localDS.allTasksByUser(user).handleErrorWith { when (it) { is UserNotInLocalStorage -> remoteDS.allTasksByUser(user) else -> raiseError(UnknownError(it)) } }
Por lo tanto, obtenemos el resultado de la primera operación, excepto cuando se lanzó una excepción. La excepción será manejada por la lambda. Si el error pertenece al tipo UserNotInLocalStorage
, intentaremos encontrar objetos del tipo Tasks
en el DataSource
remoto. En todos los demás casos, envolvemos el error desconocido en un contenedor de tipo F
El módulo de dependencia sigue siendo muy similar a la versión anterior:
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 única diferencia es que ahora es abstracto y depende de F
, que sigue siendo polimórfico. Deliberadamente, no presté atención a esto para reducir el nivel de ruido, pero Async
hereda de ApplicativeError
, por lo tanto, puede usarse como su instancia en todos los niveles de ejecución del programa.
Prueba de polimorfismo
Finalmente, nuestra aplicación se abstrae completamente del uso de tipos de datos específicos para contenedores ( F
) y podemos centrarnos en probar el polformismo en tiempo de ejecución. Vamos a probar el mismo fragmento de código que le pasa diferentes tipos de datos para el tipo F
El escenario es el mismo que cuando usamos Observable
.
El programa está escrito de tal manera que nos deshacemos por completo de los límites de las abstracciones y podemos transmitir detalles de implementación según lo desee.
Primero, intentemos usar F
Single
de RxJava como contenedor.
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) } } }
Por compatibilidad, Arrow proporciona contenedores para tipos de datos de biblioteca conocidos. Por ejemplo, hay un conveniente contenedor SingleK
. Estos contenedores le permiten usar clases de tipos junto con tipos de datos como tipos altos.
Lo siguiente se mostrará en la consola:
[Task(value=LocalTask assigned to user1)] [Task(value=Remote Task assigned to user2)] UserNotInRemoteStorage(user=User(userId=UserId(value=unknown user)))
El mismo resultado será si usar Observable
.
Ahora MaybeK
con Maybe
, para el cual el contenedor de 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) } }
El mismo resultado se mostrará en la consola, pero ahora usa un tipo de datos diferente:
[Task(value=LocalTask assigned to user1)] [Task(value=Remote Task assigned to user2)] UserNotInRemoteStorage(user=User(userId=UserId(value=unknown user)))
¿Qué pasa con ObservableK
/ FlowableK
?
Probémoslo:
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) } } }
Veremos en la consola:
[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)))
Todo funciona como se esperaba.
Intentemos usar DeferredK
, un contenedor para el tipo 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) } } } } }
Como sabe, el manejo de excepciones cuando se usa corutina debe prescribirse explícitamente. , , .
— :
[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
. , , .