Привет, Хабр!

Меня зовут Артём Добровинский, я работаю в компании 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.


Некоторые из озвученных концепций (например, чистота функций) описаны в следующих постах:



Также советую посмотреть видео FP to the max от John De Goes и ознакомиться с примером FpToTheMax.kt, расположенным в модуле arrow-examples. Использование данной техники может показаться чрезмерным для такого простого примера, но это потому, что она должна быть использована на программах намного большего масштаба.

Комментарии (5)


  1. xGromMx
    08.04.2019 18:41

    полиморфические :D


  1. vdem
    08.04.2019 19:48
    +1

    Вы бы сначала дали определение «полиморфической» программы. Слышал про полиморфные вирусы, которые модифицируют собственный код для усложнения их обнаружения по сигнатурам. Знаю о полиморфизме в ООП. Но о «полиморфических» программах не слышал никогда ранее.


  1. dougrinch
    09.04.2019 13:25

    Спасибо за пост, было очень интересно прочитать.


    Концепция прикольная, но в целом настроен довольно скептично. Если привнесение функциональных map/flatMap/filter/etc. в контейнеры действительно сильно облегчает работу с ними, то этот следующий уровень абстракции уже кажется перебором. Глобально я вижу две проблемы:


    1. Это очень сильно увеличивает сложность, давая взамен непонятно что. И User, и Task — это объекты доменной модели, а значит весь DataSource имеет смысл только внутри проекта, я не буду выносить его в "глобальную общую либу". А внутри проекта иметь такую мощную параметризацию уже не так критично, все равно внутри проекта везде используется один и тот же контейнер, а для тех редких мест, где его надо передать в либу, всегда есть экстеншн методы типа Iterable<T>.asSequence(): Sequence<T>. Да, иметь теоретическую возможность в любой момент перейти с Rx на корутины/что-нибудь еще, приятно, но давайте будем честны, все равно не получится. Все равно завязка на конкретную либу гораздо больше, нежели один вызов DeferredK.async(). Это как с БД — sql как бы стандартизован, но, почему-то, смена базы никогда не бывает простой.
    2. Это ведь все равно не работает! Потому что в реальности я не хочу Observable<List<Task>>, я хочу Observable<Task>. Концепция последовательности элементов там уже есть внутри. В конце концов, мои таски могут получаться батчами из какого-нибудь микросервиса, и я не хочу ждать все батчи лишь для того, чтобы начать обрабатывать первый таск. А если и на моей стороне обрабатывать лучше батчами, то я хочу написать Observable<Task>.batched(limit, timeout): Observable<List<Task>> (на rx не писал, поэтому с названием мог промазать, но подобное там обязано быть), а не полагаться на сабмитера этих тасков. Получается, что заменить Observable<Task> на Single<Task> просто нельзя. И если для семантики контейнера с отложенным содержимым авторы библиотеки Async<F> написали, то для семантики контейнера с неопределенным числом элементов — нет.


    1. artem_dobrovinskiy Автор
      09.04.2019 13:54

      Спасибо за отличный комментарий.

      Описанный подход — не серебрянная пуля, а больше пример того, как можно думать. Могу ошибаться, но мне кажется, Вы пытаетесь примерить подход на какие-то существующие решения, возможно на те, с которыми сейчас работаете. Если это так, то да, ничего не заработает. Потому что люди, которые писали Вам api и которые работали над проектом до вас скорее всего не оставили Вам места для маневров с высокими типами.

      А по моему опыту трудности смены базы/фреймворка в проекте чаще всего связаны именно с отсутствием абстракции высокого уровня требуемых к замене частей. Тут-то что-то вроде Kind<T, R> очень может выручить.


      1. dougrinch
        09.04.2019 14:02
        +1

        Как можно думать — это да, именно за это и люблю подобные посты. Я не столько пытаюсь примерить подход на существующие решения, сколько вижу проблему в общем случае — мы пытаемся внести абстракцию туда, где нам важны детали реализации.

        Банально, возвращаясь к моему примеру, как правильно сделать, если я хочу не «какой-то контейнер, содержащий один лист чего-то», а «какой-то контейнер, содержащий несколько элементов чего-то»?