Привет, Хабр!
Меня зовут Артём Добровинский, я работаю в компании Finch. Предлагаю к прочтению статью одного из отцов библиотеки функционального программирования
Arrow
о том, как писать полиморфические программы. Часто люди, которые только начинают писать в функциональном стиле, не спешат расставаться со старыми привычками, и на самом деле пишут чуть более изящную императивщину, с DI-контейнерами и наследованием. Идея переиспользования функций вне зависимости от используемых ими типов может подтолкнуть многих думать в правильном направлении.Enjoy!
***
Что если мы могли бы писать приложения не задумываясь о типах данных, которые будут использованы в рантайме, а просто описывать то, как эти данные будут обработаны?
Представим, что у нас есть приложение, которое работает с типом Observable
из библиотеки RxJava. Этот тип позволяет нам написать цепочки вызовов и манипуляций с данными, но в итоге не будет ли этот Observable
просто контейнером с дополнительными свойствами?
Та же история с типами вроде Flowable
, Deferred
(корутины), Future
, IO
, и множеством других.
Концептуально все эти типы представляют собой операцию (уже сделанную или планируемую к выполнению в будущем), которая поддерживает манипуляции вроде приведения внутреннего значения к другому типу (map
), использование flatMap
для создания цепочки операций схожего типа, объединение с другими инстансами этого же типа (zip
), и т.п.
Для того, чтобы писать программы, основываясь на этих поведениях, при этом сохраняя декларативность описания, а также чтобы сделать свои программы независимыми от конкретных типов данных вроде Observable
достаточно того, чтобы используемые типы данных соответствовали определенным контрактам, таким как map
, flatMap
, и прочие.
Такой подход может показаться странным или чересчур усложненным, но у него есть интересные преимущества. Сначала рассмотрим простой пример, а потом поговорим о них.
Каноническая проблема
Представим, что у нас есть приложение со списком дел, и мы хотели бы извлечь из локального кэша список объектов типа Task
. Если они не будут найдены в локальном хранилище, мы попробуем запросить их по сети. Нам нужен единый контракт для обоих источников данных, чтобы они оба могли получить список объектов типа Task
для подходящего объекта User
, вне зависимости от источника:
interface DataSource {
fun allTasksByUser(user: User): Observable<List<Task>>
}
Здесь для простоты мы возвращаем Observable
, но это может быть Single
, Maybe
, Flowable
, Deferred
— что угодно, подходящее для достижения цели.
Добавим пару моковых имплементаций источников данных, одну для локального
, вторую для дистанционного
.
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>>
.
Т.к. у нас два источника данных, нам надо как-то их координировать. Создадим репозиторий:
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) }
}
Он просто пытается загрузить List<Task>
из LocalDataSource
, и если тот не найден — пробует запросить их из сети с помощью 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) })
}
}
}
Весь вышеприведенный код можно найти на гитхабе.
Эта программа композирует цепочку выполнения для трех пользователей, затем подписывается на полученный в результате Observable
.
Первые два объекта типа User
доступны, с этим нам повезло. User1
доступен в местном DataSource
, и User2
доступен на дистанционном.
Но есть проблема с User3
, т.к., он недоступен в локальном хранилище. Программа попытается загрузить его из дистанционного сервиса — но там его тоже нет. Поиск закончится неудачей, и мы выведем в консоль сообщение об ошибке.
Вот что будет выведено в консоль для всех трех случаев:
> [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>>
}
Всё вроде бы похоже, но есть два важных отличия:
- Появилось зависимость на обобщенный тип (generic)
F
. - Тип, возвращаемый функцией теперь
Kind<F, List<Task>>
.
Kind
это то, как Arrow кодирует то, что обычно называют высоким типом (higher kind)
.
Поясню этот концепт на простом примере.
У Observable<A>
есть 2 части:
Observable
: контейнер, фиксированный тип.A
: аргумент обобщенного типа. Абстракция, в которую можно передать другие типы.
Мы привыкли воспринимать обобщенные типы вроде A
, как абстракции. Но не многие знают, что мы можем также абстрагировать типы контейнеров вроде Observable
. Для этого и существуют высокие типы.
Идея в том, что у нас может быть конструктор вроде F<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>
. Другими словами, нам всё равно, какой будет использован контейнер типа F
, до тех пор, пока он содержит в себе List<Task>
. Мы можем передавать в функцию разные контейнеры данных. Уже понятней? Идем дальше.
Взглянем на имплементированные таким образом 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
сохраняет обобщенный тип F
т.к., имплементирует DataSource<F>
. Мы хотим сохранить возможность передачи этого типа извне.
Теперь, забудем о возможно незнакомой ApplicativeError
в конструкторе и сфокусируемся на функции allTasksByUser()
. А к ApplicativeError
мы еще вернемся.
override fun allTasksByUser(user: User): Kind<F, List<Task>> =
Option.fromNullable(localCache[user]).fold(
{ raiseError(UserNotInLocalStorage(user)) },
{ just(it) }
)
Видно, что она возвращает Kind<F, List<Task>>
. Нам по-прежнему все равно, что из себя представляет контейнер F
до тех пор, пока он содержит List<Task>
.
Но есть проблема. В зависимости от того, можем ли мы найти список объектов Task
для нужного пользователя в локальном хранилище или нет, мы хотим сообщить о ошибке (Task
не найдены) или вернуть Task
уже обернутыми в F
(Task
найдены).
И для обоих случаев нам надо вернуть: Kind<F, List<Task>>
.
Другими словами: есть тип, о котором мы ничего не знаем (F
), и нам нужен способ возвращения ошибки, завернутой в этот тип. Плюс, нам нужен способ создания инстанса этого типа, в который будет завернуто значение, полученное после успешного завершения функции. Звучит как что-то невозможное?
Вернемся к декларации класса и обратим внимание, что ApplicativeError
передается в конструктор и потом используется как делегат для класса (by A
).
class LocalDataSource<F>(A: ApplicativeError<F, Throwable>) : DataSource<F>, ApplicativeError<F, Throwable> by A {
//...
}
ApplicativeError
наследуется от Applicative
, они оба — классы типа.
Классы типа определяют поведения (контракты). Они закодированы как интерфейсы, которые работают с аргументами в виде обобщенных типов, как в Monad<F>
, Functor<F>
и многих других. Этот F
является типом данных. Таким образом мы можем передать типы вроде Either
, Option
, IO
, Observable
, Flowable
и множество других.
Итак, вернемся к двум нашим проблемам:
- Обернуть значение, полученное после успешного завершения функции в
Kind<F, List<Task>>
Для этого мы можем использовать класс типа Applicative
. Т.к., ApplicativeError
наследуется от него, мы можем делегировать его свойства.
Applicative
просто предоставляет функцию just(a)
. just(a)
оборачивает значение в контекст любого высокого типа. Таким образом, если у нас есть Applicative<F>
, он может вызвать just(a)
, чтобы обернуть значение в контейнер F
, каким бы это значение не было. Допустим, мы используем Observable
, у нас будет Applicative<Observable>
, который знает, как обернуть a
в Observable
, чтобы в итоге получить Observable.just(a)
.
- Обернуть ошибку в инстанс
Kind<F, List<Task>>
Для этого мы можем использовать ApplicativeError
. Он предоставляет функцию raiseError(e)
, которая оборачивает ошибку в контейнер типа F
. Для примера с Observable
, появление ошибки создаст что-то вроде Observable.error<A>(t)
, где t
это Throwable
, раз мы задекларировали наш тип ошибки в виде класса типа ApplicativeError<F, Throwable>
.
Посмотрим на нашу абстрактную имплементацию 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
, то он моделирует присутствие или отсутствие значения, которое в него завернуто.
После получения опционального значения, мы вызываем поверх него
fold
. Это эквивалент использования условного выраженияwhen
над опциональным значением. Если значение отсутствует, тоOption
оборачивает ошибку в тип данныхF
(первая переданная лямбда). А если значение присутствуетOption
создает инстанс обертки для типа данныхF
(вторая лямбда). В обоих случаях используются свойства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()) }
)
}
}
Но есть одно небольшое различие: вместо делегирования в инстанс 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
для моделирования операции и создать инстанс типа Kind<F, List<Task>>
который будет создан асинхронно.
Если бы мы использовали фиксированных тип данных вроде Observable
, Async.async {}
был бы эквивалентен Observable.create()
, т.е. созданию операции, которая может быть вызвана из синхронного или асинхронного кода, например Thread
или AsyncTask
.
Параметр callback
используется для связки результирующих колбеков в контекст контейнера F
, который является высоким типом.
Таким образом наш RemoteDataSource
абстрагирован и зависит от всё ещё неизвестного контейнера типа F
.
Поднимемся на уровень абстракции повыше и еще раз взглянем на наш репозиторий. Если ты помнишь, сначала нам необходимо выполнить поиск объектов Task
в LocalDataSource
, и только затем (если их не было найдено локально) запросить их из 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
, мы попробуем найти объекты типа Tasks
в дистанционном DataSource
. Во всех остальных случаях мы оборачиваем неизвестную ошибку в контейнер типа 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
. Сценарий тот же самый, как когда мы использовали Observable
.
Программа написана таким образом, что мы полностью избавились от границ абстракций и можем передавать детали имплементации, как пожелается.
Для начала попробуем использовать в качестве контейнера для F
Single
из RxJava.
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
:
@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)))
Всё работает, как и ожидалось.
Попробуем использовать DeferredK
, обертку для типа 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)
}
}
}
}
}
Как известно, обработку исключений при использовании корутин приходится прописывать явно. Такие детали имплементации, как обработка исключения зависят от используемого типа данных, а поэтому и определяются на высшем уровне абстракции.
Еще раз — тот же результат:
[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
. Использование данной техники может показаться чрезмерным для такого простого примера, но это потому, что она должна быть использована на программах намного большего масштаба.