
Olá Habr!
Meu nome é Artyom Dobrovinsky, trabalho para
Finch . Sugiro a leitura de um artigo de um dos pais da biblioteca de programação funcional
Arrow
sobre como escrever programas polimórficos. Muitas vezes, as pessoas que estão começando a escrever em um estilo funcional não têm pressa em abandonar hábitos antigos e, de fato, escrevem um imperativo um pouco mais elegante, com recipientes e herança de DI. A idéia de reutilizar funções, independentemente dos tipos que elas usam, pode levar muitos a pensar na direção certa.
Aproveite!
***
E se pudéssemos escrever aplicativos sem pensar nos tipos de dados que serão usados em tempo de execução, mas simplesmente descrever como esses dados serão processados?
Imagine que temos um aplicativo que funciona com o tipo Observable
da biblioteca RxJava. Esse tipo nos permite escrever cadeias de chamadas e manipulações com dados, mas no final, esse Observable
não será apenas um contêiner com propriedades adicionais?
A mesma história com tipos como Flowable
, Adiado (Coroutines), Future
, IO
e muitos outros.
Conceitualmente, todos esses tipos representam uma operação (já executada ou planejada para ser implementada no futuro) que suporta manipulações como converter um valor interno para outro tipo ( map
), usando flatMap
para criar uma cadeia de operações de um tipo semelhante, combinando-se com outras instâncias do mesmo tipo ( zip
) etc.
Para escrever programas com base nesses comportamentos, mantendo uma descrição declarativa e também para tornar seus programas independentes de tipos de dados específicos como Observable
basta que os tipos de dados utilizados correspondam a determinados contratos, como map
, flatMap
e outros .
Essa abordagem pode parecer estranha ou muito complicada, mas tem vantagens interessantes. Primeiro, considere um exemplo simples e depois fale sobre eles.
Problema canônico
Suponha que tenhamos um aplicativo com uma lista de tarefas, e gostaríamos de extrair uma lista de objetos do tipo Task
do cache local. Se eles não forem encontrados no armazenamento local, tentaremos consultá-los pela rede. Precisamos de um contrato único para ambas as fontes de dados, para que ambas possam obter uma lista de objetos do tipo Task
para um objeto User
adequado, independentemente da fonte:
interface DataSource { fun allTasksByUser(user: User): Observable<List<Task>> }
Aqui, por simplicidade, retornamos Observable
, mas pode ser Single
, Maybe
, Flowable
, Deferred
- qualquer coisa adequada para alcançar a meta.
Adicione algumas implementações mocha de fontes de dados, uma para
e outra 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)) } } }
As implementações de ambas as fontes de dados são quase idênticas. Essas são simplesmente versões simuladas dessas fontes que, idealmente, extraem dados do armazenamento local ou da API da rede. Nos dois casos, o Map<User, List<Task>>
é usado para armazenar dados.
Porque temos duas fontes de dados, precisamos coordená-las de alguma forma. Crie um repositório:
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) } }
Ele apenas tenta carregar a List<Task>
do LocalDataSource
e, se não for encontrado, tenta solicitá-los da rede usando RemoteDataSource
.
Vamos criar um módulo simples para fornecer dependências sem usar nenhuma estrutura para injeção de dependência (DI):
class Module { private val localDataSource: LocalDataSource = LocalDataSource() private val remoteDataSource: RemoteDataSource = RemoteDataSource() val repository: TaskRepository = TaskRepository(localDataSource, remoteDataSource) }
E, finalmente, precisamos de um teste simples que execute toda a pilha de operações:
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 o código acima pode ser encontrado no github .
Este programa compõe a cadeia de execução para três usuários e depois assina o Observable
resultante.
Os dois primeiros objetos do tipo User
estão disponíveis, com isso tivemos sorte. User1
está disponível no DataSource
local e User2
está disponível no controle remoto.
Mas há um problema com o User3
, porque ele não está disponível no armazenamento local. O programa tentará fazer o download de um serviço remoto - mas também não está lá. A pesquisa falhará e exibiremos uma mensagem de erro no console.
Aqui está o que será exibido no console para todos os três casos:
> [Task(value=LocalTask assigned to user1)] > [Task(value=Remote Task assigned to user2)] > UserNotInRemoteStorage(user=User(userId=UserId(value=unknown user)))
Terminamos com um exemplo. Agora vamos tentar programar essa lógica no estilo do
.
Abstração de tipo de dados
Agora, o contrato para a interface DataSource
ficará assim:
interface DataSource<F> { fun allTasksByUser(user: User): Kind<F, List<Task>> }
Tudo parece ser semelhante, mas há duas diferenças importantes:
- Existe uma dependência do tipo generalizado (genérico)
F
- O tipo retornado pela função agora é
Kind<F, List<Task>>
.
Kind
é como a Arrow codifica o que é comumente chamado de (higher kind)
.
Vou explicar esse conceito com um exemplo simples.
Observable<A>
tem 2 partes:
Observable
: contêiner, tipo fixo.A
: argumento de um tipo genérico. Uma abstração para a qual outros tipos podem ser transmitidos.
Estamos acostumados a usar tipos genéricos como A
como abstrações. Mas poucas pessoas sabem que também podemos abstrair tipos de contêineres como Observable
. Para isso, existem tipos altos.
A idéia é que podemos ter um construtor como F<A>
no qual F
e A
podem ser do tipo genérico. Essa sintaxe ainda não é suportada pelo compilador Kotlin ( ainda? ). Portanto, nós a imitaremos com uma abordagem semelhante.
O Arrow suporta isso através do uso de uma meta interface intermediária Kind<F, A>
, que contém links para ambos os tipos, e também gera conversores em ambas as direções durante a compilação, para que você possa seguir o caminho de Kind<Observable, List<Task>>
para Observable<List<Task>>
e vice-versa. Não é uma solução ideal, mas funcional.
Então, novamente, veja a interface do nosso repositório:
interface DataSource<F> { fun allTasksByUser(user: User): Kind<F, List<Task>> }
A função DataSource
retorna um tipo alto: Kind<F, List<Task>>
. Ele se traduz em F<List<Task>>
, onde F
permanece generalizado.
Capturamos apenas a List<Task>
na assinatura. Em outras palavras, não nos importamos com o contêiner do tipo F
, desde que contenha uma List<Task>
. Podemos passar diferentes contêineres de dados para a função. Já está claro? Vá em frente.
Vamos dar uma olhada no DataSource
implementado dessa maneira, mas desta vez para cada um individualmente. Primeiro ao 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) } ) }
Muitas coisas novas foram adicionadas, analisaremos tudo passo a passo.
Este DataSource
retém o tipo genérico F
porque implementa um DataSource<F>
. Queremos manter a possibilidade de transmitir esse tipo de fora.
Agora, esqueça o ApplicativeError
possivelmente desconhecido no construtor e concentre-se na função allTasksByUser()
. E retornaremos ao ApplicativeError
.
override fun allTasksByUser(user: User): Kind<F, List<Task>> = Option.fromNullable(localCache[user]).fold( { raiseError(UserNotInLocalStorage(user)) }, { just(it) } )
Pode-se ver que ele retorna Kind<F, List<Task>>
. Ainda não nos importamos com o contêiner F
, contanto que contenha uma List<Task>
.
Mas há um problema. Dependendo de encontrar ou não a lista de objetos de Task
para o usuário desejado no armazenamento local, queremos relatar um erro (nenhuma Task
encontrada) ou retornar a Task
já encapsulada em F
( Task
encontrada).
E para ambos os casos, precisamos retornar: Kind<F, List<Task>>
.
Em outras palavras: existe um tipo sobre o qual não sabemos nada ( F
) e precisamos de uma maneira de retornar um erro envolvido nesse tipo. Além disso, precisamos de uma maneira de criar uma instância desse tipo, na qual o valor obtido após a conclusão bem-sucedida da função seja agrupado. Parece algo impossível?
Vamos voltar à declaração de classe e observar que ApplicativeError
é passado para o construtor e, em seguida, usado como delegado para a classe ( by A
).
class LocalDataSource<F>(A: ApplicativeError<F, Throwable>) : DataSource<F>, ApplicativeError<F, Throwable> by A {
ApplicativeError
herdado de Applicative
, ambos são classes de tipos.
As classes de tipo definem comportamentos (contratos). Eles são codificados como interfaces que funcionam com argumentos na forma de tipos genéricos, como em Monad<F>
, Functor<F>
e muitos outros. Este F
é um tipo de dados. Dessa forma, podemos passar tipos como Either
, Option
, IO
, Observable
, Flowable
e muitos mais.
Então, voltando aos nossos dois problemas:
- Quebra o valor obtido após a função ser concluída com êxito em
Kind<F, List<Task>>
Para isso, podemos usar uma classe do tipo Applicative
. Como ApplicativeError
herdado, podemos delegar suas propriedades.
Applicative
just fornece a função just(a)
. just(a)
agrupa o valor no contexto de qualquer tipo alto. Assim, se tivermos o Applicative<F>
, ele pode chamar just(a)
para agrupar o valor no contêiner F
, qualquer que seja esse valor. Digamos que usamos Observable
, teremos um Applicative<Observable>
que sabe como agrupar a
em um Observable
, para obtermos Observable.just(a)
.
- Quebra do erro na instância
Kind<F, List<Task>>
Para isso, podemos usar ApplicativeError
. Ele fornece uma função raiseError(e)
, que envolve o erro em um contêiner do tipo F
Para o exemplo Observable
, um erro criará algo como Observable.error<A>(t)
, em que t
é Throwable
, pois declaramos nosso tipo de erro como uma classe do tipo ApplicativeError<F, Throwable>
.
Dê uma olhada em nossa implementação abstrata 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) } ) }
O Map<User, List<Task>>
armazenado na memória permanece o mesmo, mas agora a função faz algumas coisas que podem ser novas para você:
Ela tenta carregar a lista de Task
do cache local e, como o valor de retorno pode ser null
(a Task
pode não ser encontrada), modelamos isso usando Option
. Se não estiver claro como o Option
funciona, ele modela a presença ou a ausência do valor envolvido nele.
Depois de receber o valor opcional, chamamos fold
em cima dele. Isso equivale a usar a when
sobre um valor opcional. Se o valor estiver faltando, a Option
oculta o erro no tipo de dados F
(primeiro lambda passado). E se o valor estiver presente, o Option
criará uma instância do wrapper para o tipo de dados F
(segundo lambda). Nos dois casos, as propriedades ApplicativeError
mencionadas anteriormente são usadas: raiseError()
e just()
.
Assim, abstraímos a implementação de fontes de dados usando classes para que elas não saibam qual contêiner será usado para o tipo F
.
A implementação de um DataSource
rede se parece com isso:
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()) } ) } }
Mas há uma pequena diferença: em vez de delegar para a instância ApplicativeError
, usamos outra classe como: Async
.
Isso ocorre porque as chamadas de rede são de natureza assíncrona. Queremos escrever código que será executado de forma assíncrona; é lógico usar uma classe de tipo projetada para isso.
Async
usado para simular operações assíncronas. Pode simular qualquer operação de retorno de chamada. Observe que ainda não conhecemos os tipos de dados específicos; simplesmente descrevemos uma operação de natureza assíncrona.
Considere a seguinte função:
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 a função async {}
, que é fornecida com uma classe do tipo Async
para simular a operação e criar uma instância do tipo Kind<F, List<Task>>
que será criada de forma assíncrona.
Se Async.async {}
um tipo de dados fixo como Observable
, Async.async {}
seria equivalente a Observable.create()
, ou seja, criando uma operação que pode ser chamada a partir de código síncrono ou assíncrono, como Thread
ou AsyncTask
.
O parâmetro de callback
é usado para vincular os retornos de chamada resultantes ao contexto do contêiner F
, que é do tipo alto.
Assim, nosso RemoteDataSource
abstraído e depende do contêiner ainda desconhecido do tipo F
Vamos subir para o nível de abstração e dar uma nova olhada no nosso repositório. Se você se lembra, primeiro precisamos procurar objetos Task
em LocalDataSource
e somente depois (se não foram encontrados localmente) para solicitá-los ao 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á conosco novamente! Ele também fornece uma função handleErrorWith()
que funciona sobre qualquer receptor high-end.
É assim:
fun <A> Kind<F, A>.handleErrorWith(f: (E) -> Kind<F, A>): Kind<F, A>
Porque localDS.allTasksByUser(user)
retorna Kind<F, List<Task>>
, que pode ser considerado como F<List<Task>>
, onde F
permanece um tipo genérico, podemos chamar handleErrorWith()
sobre ele.
handleErrorWith()
permite responder a erros usando a lambda passada. Vamos dar uma olhada na função:
fun allTasksByUser(user: User): Kind<F, List<Task>> = localDS.allTasksByUser(user).handleErrorWith { when (it) { is UserNotInLocalStorage -> remoteDS.allTasksByUser(user) else -> raiseError(UnknownError(it)) } }
Assim, obtemos o resultado da primeira operação, exceto quando uma exceção foi lançada. A exceção será tratada pelo lambda. Se o erro pertencer ao tipo UserNotInLocalStorage
, tentaremos encontrar objetos do tipo Tasks
no DataSource
remoto. Em todos os outros casos, envolvemos o erro desconhecido em um contêiner do tipo F
O módulo de dependência permanece muito semelhante à versão 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) }
A única diferença é que agora é abstrato e depende de F
, que permanece polimórfico. Eu deliberadamente não prestei atenção a isso para reduzir o nível de ruído, mas o Async
herda de ApplicativeError
; portanto, ele pode ser usado como instância em todos os níveis de execução do programa.
Teste de polimorfismo
Finalmente, nosso aplicativo é completamente abstraído do uso de tipos de dados específicos para contêineres ( F
) e podemos nos concentrar em testar o polformismo em tempo de execução. Testaremos o mesmo trecho de código que passa diferentes tipos de dados para o tipo F
O cenário é o mesmo de quando usamos o Observable
.
O programa é escrito de tal maneira que nos livramos completamente dos limites das abstrações e podemos transmitir os detalhes da implementação, conforme desejado.
Primeiro, vamos tentar usar o F
Single
do RxJava como um contêiner.
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) } } }
Para compatibilidade, o Arrow fornece wrappers para tipos de dados de biblioteca conhecidos. Por exemplo, existe um invólucro SingleK
conveniente. Esses wrappers permitem usar classes de tipos em conjunto com tipos de dados como tipos altos.
O seguinte será exibido no console:
[Task(value=LocalTask assigned to user1)] [Task(value=Remote Task assigned to user2)] UserNotInRemoteStorage(user=User(userId=UserId(value=unknown user)))
O mesmo resultado será se usar Observable
.
Agora vamos trabalhar com o Maybe
, para o qual o 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) } }
O mesmo resultado será exibido no console, mas agora usando um tipo de dados diferente:
[Task(value=LocalTask assigned to user1)] [Task(value=Remote Task assigned to user2)] UserNotInRemoteStorage(user=User(userId=UserId(value=unknown user)))
E o ObservableK
/ FlowableK
?
Vamos tentar:
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) } } }
Vamos ver no 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)))
Tudo funciona como esperado.
Vamos tentar usar o DeferredK
, um wrapper para o 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 você sabe, o tratamento de exceções ao usar corutin deve ser explicitamente prescrito. , , .
— :
[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
. , , .