
Одной из наиболее востребованных задач функционального программирования является горизонтальная композиция условно‑независимых эффектов. В этом случае (почти) не нужно задумываться о сложностях перестановки эффектов, и задачу можно решить алгебраическим способом, практически без отсылок к теории категорий.
Оглавление обзора
Содержание
Композиции контейнеров
Когда мы говорим о композиции эффектов, то, как правило, имеется в виду вычисления в контейнере, являющемся композицией из нескольких контейнеров эффектов. В предыдущей части мы говорили о вертикальной композиции, основанной на последовательном вызове конструкторов типов как функций уровня типов (F ∘ G)[A] = F[G[A]]. Но в более общем случае под композицией понимается просто бинарная операция (магма) на конструкторах типов, и помимо их функциональной (вертикальной) композиции мы можем также использовать любые алгебраические выражения, составленные из базовых операций:
infix type ==>[F[_], G[_]] = [A] =>> F[A] => G[A] infix type :×:[F[_], G[_]] = [A] =>> F[A] × G[A] infix type :+:[F[_], G[_]] = [A] =>> F[A] + G[A] // cats.data.EitherK[F, G, A]
Контейнер F ==> G получается инвариантным и, не будучи эндофунктором, не годится в роли способа композиции монад. Для произведения можно реализовать монаду:
// подключим синтаксис из библиотки Cats import cats.instances.tuple._ // предоставляет экземпляр Bifunctor для Tuple2 import cats.syntax.arrow._ // даёт оператор *** (split) import cats.syntax.bifunctor._ // добавляет синтаксический метод .bimap given prodFunctor: [F[_]: Functor as F, G[_]: Functor as G] => Functor[F :×: G] = [A, B] => (f: A => B) => F(f) *** G(f) // (faga: F[A] × G[A]) => faga.bimap(fFunctor(f), gFunctor(f)) given prodMonad: [F[_]: Monad as F, G[_]: Monad as G] => Monad[F :×: G] = ( fmap = prodFunctor, pure = [A] => (a: A) => F.pure(a) -> G.pure(a), flatten = [A] => (fgfga: (F :×: G)[(F :×: G)[A]]) => fgfga.bimap(_.flatMap(_._1), _.flatMap(_._2)) )
Вычисления в контейнерах‑множителях производятся независимо друг от друга. Например, если мы опишем монаду для константного функтора Const[String][_], то композиция Const[String] :×: IO будет эквивалентна вертикальной композиции Writer[String] ∘ IO, что позволяет вести журнал параллельно операциям ввода‑вывода. Но обычно редко возникает необходимость дублировать все вычисления с несколькими эффектами.
Для суммы контейнеров снова не получится собрать честную монаду, если контейнеры неизоморфны. Но именно такая композиция делает свободную монаду полезной на практике.
Свободные монады
Классическая техника
Устройство свободных монад уже рассматривалось ранее в обзоре. Одна из представленных там реализаций FreeU[F[_]] опирается на универсальное свойство — возможность получить экземпляр монады M[_], если предоставлен интерпретатор функтора в эту монаду:
type FreeU[F[_]] = [X] =>> [M[_]] => Monad[M] ?=> (F ~> M) => M[X]
Если подставить вместо F сумму контейнеров G :+: H, то суммарный интерпретатор распадается на произведение интерпретаторов слагаемых:
Интерпретация контейнера в монаду как раз и наделяет его эффектом. Значит, если нам нужны эффекты двух контейнеров, мы просто оборачиваем их в свободную монаду и предоставляем независимые интерпретаторы этих эффектов!
Обобщённые типы F[_] описывают эффекты как набор операции с сигнатурами, то есть, как некую алгебру. Поэтому такие контейнеры эффектов иногда называют «алгебраическими эффектами» или даже просто «алгебрами».
Подробнее технику программирования на свободных монадах я уже описывал тут. Подсвечу лишь основные моменты:
сперва собирается горизонтальная композиция контейнеров необходимых эффектов
Total = F :+: G :+: H :+: ...,затем конструкторы этих контейнеров (декларации эффектов) «поднимаются в мир» свободного контейнера
Free[Total](благодаря универсальному свойству суммы типов такое естественное преобразованиеF ~> Free[Total]существует всегда и реализуется в библиотеках, например,Free.liftInjectв Cats),из этих поднятых конструкторов строится свободная программа типа
Free[Total][A], задействующая монадную композицию,из интерпретаторов отдельных эффектов
F ~> Mсобирается (тоже алгебраически) тотальный интерпретаторTotal ~> M,в конце свободная программа интерпретируется тотальным интерпретатором.
В этом и заключается основная идея горизонтальной композиции эффектов. Важно иметь в виду, это понятие не обязательно относятся к композиции именно монад.
Библиотека Eff
Техника свободных программ обязывает заранее фиксировать перечень доступных эффектов в определении контейнера «комбинированного эффекта» Total[_]. Это оказывается не очень удобным — при добавлении к программе нового эффекта придётся изменять и тотальный контейнер. Поэтому привлекательной становится идея расширяемых эффектов, позволяющая программистам абстрагироваться от фиксированного стека эффектов, сосредоточившись только на необходимых для каждого шага.
Тотальный контейнер играет роль метки для компилятора, обеспечивающего статическую проверку связи описания эффектов и их интерпретации. Нарочно создавать экземпляры этого контейнера не нужно, так что по сути он является фантомным. Библиотека Eff предлагает использовать вместо обобщённого типа контейнера простой фантомный тип‑токен Fxn[F, G, ...]:
Freer[F :+: G :+: …, A] ≅ Eff[Fxn[F, G, ...], A]
Смысл токена заключается в том, что с его помощью неявно вытягиваются ключевые возможности суммарного контейнера: инъекции F ~> Total и проекции (с проверкой) Total ~> Option ∘ F. Они оборачиваются к класс типов примерно такого вида:
type MemberIn[F[_]] = [T[_]] =>> ( inject: F ~> T, project: T ~> (Option ∘ F) )
Библиотека Eff предоставляет макросы автоматической генерации экземпляров этого класса типов с канонической реализацией. Это позволяет полностью абстрагироваться от токена, переложив доступ к эффектам на неявные значения класса типов. Вот пример на основе официальной документации:
// Пользовательские контейнеры эффектов trait Interact[A] trait DataOp [A] // классы типов эффектов type _interact [R] = Interact |= R type _dataOp [R] = DataOp |= R type _promptReader[R] = Reader[String] |= R // R - абстрактный токен суммы эффектов, для которого неявно предоставляются эффекты def program[R: {_promptReader, _interact, _dataOp}]: Eff[R, String] = for { cat <- askUser("What's the kitty's name?") // использует эффект Interact _ <- addCat(cat) // использует эффект DataOp cats <- getAllCats // использует эффект DataOp prompt<- ask // использует эффект Reader[String] _ <- tellUser(prompt + cats.mkString(", ")) // использует эффект Interact } yield "Ok"
По сути контейнер Eff аналогичен GADT‑реализации Freer, представленного в обзоре ранее. После несколько замороченного описания интерпретаторов пользовательских эффектов (runInteract, runDataOps), исполнение свободной программы выглядит так:
val result: String = program .runReader("All cat's names: ") // интерпретация эффекта Reader[String] .runInteract // интерпретация эффекта Interact .runDataOps // интерпретация эффекта DataOp .run // финальная интерпретация в Id
Обратите внимание, эффекты можно интерпретировать по очереди в произвольном порядке, причём зачастую можно выбрать, исключить ли упоминание эффекта из типа‑токена, или же оставить его в стеке (например, обработка исключений может пригодится и до, и после интерпретаций других эффектов). Понятное дело, результат будет зависеть от порядка интерпретации.
Другие примеры программ с расширяемыми эффектами на базе контейнера Eff можно найти на страницах официальной документации библиотеки.
Библиотеки Turbolift и Kyo
Библиотека Turbolift считается идеологическим наследником библиотеки Eff. Turbolift работает только на Scala 3 с использованием его новых возможностей. Например, комбинирование эффектов производится с помощью пересечений типов, что существенно упрощает синтаксис и кодовую базу. Контейнерный тип имеет вид Computation[A, Ef1 & Ef2 & ...], но чаще используют инфиксный псевдоним A !! (Ef1 & Ef2 & ...). С его помощью можно комбинировать эффективные вычисления, но сигнатура композиции немного отличается от монадной: flatten: [A] => (A !! U1) !! U2 => A !! (U1 & U2) — у контейнера меняется тип‑параметр, отвечающий за множество эффектов. Категориально это называется градуированной (индексированной) монадой, но сейчас мы не будем углубляться в теорию.
Вот пример работы с эффектами из официальной документации:
// сигнатура эффекта (возможности) trait FlipSignature extends Signature: def flip: Boolean !! ThisEffect def fail: Nothing !! ThisEffect // DSL эффекта case object MyFlip extends Effect[FlipSignature] with FlipSignature { ... } type MyFlip = MyFlip.type val program: String !! MyFlip = for x <- MyFlip.select(1 to 4) _ <- !!.when(isOdd(x))(MyFlip.fail) // !! = Computation y <- MyFlip.select('a' to 'b') yield s"$x$y"
Ключевым отличием от Eff является переход от GADT к варианту, основанному на передаче продолжений. Эта идея позаимствована из проекта Scala Effekt и аналогична реализации Freeμ[_], представленной ранее в обзоре. Интерпретируемая программа строится теперь не как дерево данных, а как единая функция, скомпозированная из отдельных шагов и требующая на вход алгебры эффектов. Кроме того, взамен интерпретаторов мы получаем обработчики, позволяющие управлять передачей продолжений: возврат значений при обработке исключений, повторы, асинхронность и прочее:
extension (fx: FlipEffect) def findAll = new fx.impl.Stateless[Identity, Vector, Any] with fx.impl.Sequential with FlipSignature { override def onReturn(a: Unknown) = !!.pure(Vector(a)) override def fail = Control.abort(Vector()) override def flip = Control.capture: k => for // k — это продолжение вычислений as <- k(true) bs <- k(false) yield as ++ bs } .toHandler
Авторы библиотеки отмечают, что техника передачи продолжений также открывает доступ к неалгебраическим эффектам высокого порядка (Higher Order (Scoped) Effects). В частности, они дают возможность управлять областью действия эффектов, защищаться от утечек ресурсов и тому подобное
Обработка эффектов производится способом, аналогичным Eff:
val result: Vector[String] = program .handleWith(MyFlip.findAll) // последовательный запуск обработчиков .run
Библиотека Turbolift предлагает впечатляющие возможности, но, также как и Eff, требует специфичных навыков написания обработчиков эффектов.
Аналогичная механика реализована в библиотеке Kyo. Основной акцент в ней сделан на высокую производительность. Вокруг неё сформирована целая экосистема различных расширений и интеграций. Kyo предлагается как самодостаточный фреймворк, оптимизированный и готовый для коммерческой разработки.
Контейнер здесь представляет собой либо значение типа A, либо фактически функцию отложенного (pended) вычисления A на основе перечисленных эффектов S = Ef1 & Ef2 & …. Он обычно записывается в инфиксной форме:
type <[+A, -S] = A | Kyo[A, S] // A < (Ef1 & Ef2) ≅ A + (Ef1 × Ef2 => A) ну, почти))
Технически Kyo позволяет создавать и подключать и пользовательские эффекты бизнес‑логики, но рекомендуемый механизм инъекции зависимостей тут основан на использовании эффекта чтения окружения Env[R] и похож на тот, что реализован в ZIO.
Абстрактные контейнеры
Библиотека Cats.MTL
Давайте вернёмся к вертикальной композиции на монадных трансформерах:
import cats.data.* import cats.effect.IO // Stack ≅ IO ∘ State[Int] ∘ Either[Exception] type Stack[A] = EitherT[StateT[IO, Int, *], Exception, A]
Интерпретация программы в этом стеке, распаковка контейнера с материализацией эффектов, завязана на последовательном применении алгебр контейнеров в соответствии с порядком композиции:
import cats.effect.unsafe.implicits.global val program: Stack[String] = ??? // какая-то реализация val result: String = program .fold(_.getMessage, identity) // распаковка Either .runA(42) // распаковка State .unsafeRunSync() // распаковка IO
Выглядит неплохо, но основная проблема проявляется при самом построении программы — «поднятие» отдельных шагов в «мир Stack» становится очень громоздким с ростом стека контейнеров.
Эту задачу решает библиотека Cats MTL (Monad Transformer Library). Возможности поднятия отдельных шагов в стек трансформеров неявно передаются как значения классов типов в функцию построения программы, полиморфной по типу контейнера:
import cats.MonadError import cats.mtl.Stateful import cats.syntax.all.* def buildProgram[F[_]](using S: Stateful[F, Int], E: MonadError[F, Throwable]): F[String] = for currentState <- S.get result <- if currentState > 10 then E.raiseError(new Exception("Too large")) else E.pure("All good") yield result val program: Stack[String] = buildProgram[Stack]
Последняя строчка выполнится, так как библиотека сама выведет и подставит реализации Stateful[Stack, Int] и MonadError[Stack, Exception].
Библиотека Cats MTL позволяет оперировать вертикальной композицией эффектов алгебраически, предоставляя «горизонтальные» наборы возможностей и интерпретаторов. Выделим пару моментов, отвечающих за упрощение:
на момент построения программы мы абстрагируемся от фиксированного композитного эффекта;
возможности (capabilities) каждого эффекта неявно подтягиваются из контекста (как экземпляры классов типов).
Техника Tagless Final
С техникой MTL вычисления абстрагируются от конкретного контейнера эффектов. В конце мы просто указываем тотальный стек из монадных трансформеров и все его возможности будут автоматически найдены в библиотеке и подставлены в программу. Но эту же самую программу можно материализовать и иначе для совершенно произвольного контейнера.
Действительно, достаточно вручную реализовать свой экземпляр Stateful для произвольной монады:
import cats.effect.Ref given myStateful: [F[_]: Monad as F, S] => ((ref: Ref[F, S]) ?=> Stateful[F, S]) = new: override val monad: Monad[F] = F override def get: F[S] = ref.get override def set(s: S): F[Unit] = ref.set(s) end myStateful
И теперь ту же самую buildProgram можно смело интерпетировать, например, в контейнер IO (экземпляр MonadError[IO, Throwable] уже есть в Cats Effect):
val programIO: IO[String] = for given Ref[IO, Int] <- Ref[IO].of(4) res <- buildProgram[IO] yield res programIO.unsafeRunSync() // All good
Результат кажется банальным, если не задумываться о следствиях. Здесь эффекты вроде Stateful не привязаны ни к фиксированному стеку (как с MTL), ни к свободной монаде, которые пришлось бы интерпретировать в ещё какую‑то монаду. Мы без промежуточных шагов сразу получаем финальное значение в целевом контейнере! Поэтому такая техника программирования именуется как «final», а под «бестеговостью» (tagless) обычно понимается то, что эффекты определяются не во время исполнения по каким‑то тегам‑значениям, а во время компиляции с помощью типов.
Замечательно то, что аналогичным образом можно поступить не только со стандартными, но и с совершенно произвольными бизнес‑эффектами. Сперва опишем их в классе типов для абстрактного контейнера:
trait MyService[F[_]]: def doSomething(i: Int): F[String] def doSomethingElse(b: Boolean): F[Unit]
Теперь напишем программу, требующую экземпляр этого класса типов:
def programF[F[_] : {MyService as myService, Monad}]: F[String] = for str <- myService.doSomething(42) _ <- myService.doSomethingElse(str.nonEmpty) yield str
Обратите внимание: наравне с сервисом нужно явно указывать, что контейнер F[_] является монадой. В этом смысле сами монадные возможности являются своеобразным эффектом, позволяющим последовательно комбинировать другие эффекты.
Когда программа готова, для её исполнения нужно реализовать наши сервисы
object MyServiceImpl: given myServiceImpl: MyService[IO] = new: def doSomething(i: Int) = IO.pure(i.toString) def doSomethingElse(b: Boolean) = IO.println("Это ответ").whenA(b)
и разместить неявные значения этих реализаций в контексте:
import MyServiceImpl.given programF[IO].unsafeRunSync() // "42" // Это ответ
Минусами техники Tagless Final называют зачастую избыточное абстрагирование от итогового контейнера (обычно он фиксирован) и необходимость постоянно указывать, что контейнер обязан быть «монадой, причём асинхронной, причём с возможностью бросать исключения и тому подобное». Однако эта техника хорошо раскрывает область применения горизонтальной композиции эффектов — это один из механизмов инъекции зависимостей. Такие программы представляют собой функции, (неявно) принимающие на вход набор (произведение) интерпретаций/обработчиков эффектов.
Императивный стиль (Direct style)
Это главный на данный момент тренд экосистемы Scala 3. Для ознакомления с ключевыми идеями рекомендую прочесть перевод замечательной статьи Ноэля Уелша Объяснение direct‑style эффектов. Здесь же выделю лишь некоторые моменты, необходимые для раскрытия связи императивного стиля с горизонтальной композицией эффектов.
Для статического предсказуемого управления эффектами нам приходится использовать функции, возвращающие значения в контейнерах монадах, вроде IO:
def doSomething (d: Double): IO[String] = IO.pure(d.toString) def doSomethingGood(s: String): IO[Int] = IO.pure(s.length) def doSomethingElse(i: Int) : IO[Boolean] = IO.pure(i > 0)
Обычно функциональный («монадический» по Уелшу) способ композиции эффективных вычислений ассоциируется у программистов с цепочками map/flatMap:
def program1(d: Double): IO[Boolean] = doSomething(d) .flatMap(doSomethingGood) .flatMap(doSomethingElse)
Уелш называет такой стиль «раздражающим», что мотивирует на совмещение привычного императивного синтаксиса с предсказуемостью поведения и гибким управлением эффектами, которые дают монады.
В поиске привычного синтаксиса часто рекомендуют использовать for‑выражения:
def program2(d: Double): IO[Boolean] = for s <- doSomething(d) i <- doSomethingGood(s) b <- doSomethingElse(i) yield b
Тем не менее for‑выражения всё ещё не достаточно удобны — они навязывают избыточные синтаксические правила и имеют некоторые ограничения по управлению потоком исполнения.
Пожалуй, самый востребованный эффект — это асинхронность выполнения, позволяющая максимально рационально использовать вычислительные ресурсы (см., например, статью Нити и волокна). Такая задача по‑разному решается в различных языках программирования. Например, в проекте Loom (Java) реализовано отслеживание блокирующих операций и превращение их в асинхронные вызовы. Такая механика решает «проблему раскрашенных функций» — программистам не нужно задумываться над асинхронностью, так как компилятор и среда исполнения сделают всё сами:
val s = doSomething(d) // ожидание пользовательского ввода val i = doSomethingGood(s) // запрос к удалённому узлу val b = doSomethingElse(i) // обращение к БД
Важно понимать, что императивный стиль не меняет монадический композиционный принцип, а скрывает его. Вместо типично монадических приёмов вы просто вызываете функции, которые могут приостанавливаться, бросать ошибки, читать окружение и тому подобное
Другой способ управления этим эффектом — техника async/await, популяризованная языком C#. Идея в том, что код можно обернуть в блок async, внутри которого он автоматически разбивается маркерами await на матрёшку асинхронных продолжений. Сами операторы await по сути «вызывают эффект» асинхронности. Они как бы «распаковывают значения», позволяя композировать контейнерные вычисления в императивном стиле. При этом сохраняется контроль над потоком исполнения.
async: val s = doSomething(d) .await // ожидание пользовательского ввода val i = doSomethingGood(s).await // запрос к удалённому узлу val b = doSomethingElse(i).await // обращение к БД
В Scala есть несколько библиотек, поддерживающих синтаксис async/await, но есть и такие, где используются другие ключевые слова. И это внезапно возвращает нас к теме статьи — разные блоки на подобие async могут позволить композировать разные эффекты! Тогда вкладывая такие блоки друг в друга, мы получаем «горизонтальный» доступ сразу к нескольким эффектам.
Впрочем, мы уже видели, что композицию монадических вычислений в случае горизонтального стека эффектов можно описать и единообразно. Например, для упоминавшегося ранее Kyo есть дополнительная библиотека kyo‑direct, в которой предусмотрен единственный блок direct, внутри которого открывается императивный доступ ко всем необходимым эффектам.
Как же реализовать типизированную монадическую композицию вычислений в контейнерах без намёков на сами контейнеры? Для этой задачи уже недостаточно языка классического типизированного λ‑исчисления. Нам нужны неявные контейнеры, а значит, не обойтись без контекстных абстракций Scala.
Как мы уже убедились, контейнеры горизонтальной композиции эффектов представляют собой просто механизм инъекции зависимостей‑эффектов, то есть они изоморфны функциям, принимающим на вход произведение необходимых эффектов. Остаётся сделать эти функции неявными:
type HIO[S] = [A] =>> S ?=> A // индексированная монада Horizontal IO
Эффективные функции A => HIO[S][B] возвращают «значения в контейнере», но если в контексте размещён эффект S, мы можем смело использовать HIO[S][B] там, где ожидаются чистые B — “распаковка” HIO будет неявной. Тогда нужно уметь объявлять блок (scope), в котором будет доступно значение S, и весь этот блок также будет неявной функцией, ожидающей такое значение. То есть по сути все вычисления останутся ленивыми.
Следуя примерам из упомянутой статьи, объявим два эффекта Print[_] и Sample[_], а также реализуем для них объявления блоков (через apply) и обращение к эффектам (println, int):
type Console = Console.type type Print = HIO[Console] object Print: inline def apply[A](inline body: Print[A]): Print[A] = body def println[A](msg: Any): Print[Unit] = summon[Console].println(msg) import scala.util.Random type Sample = HIO[Random] object Sample: inline def apply[A](inline body: Sample[A]): Sample[A] = body def int: Sample[Int] = summon[Random].nextInt()
Горизонтальная композиция эффектов осуществляется путём размещения логики в блоках контекста эффектов:
val printSample: (Console, Random) ?=> Unit = Print: // пробрасывает Console в контекст блока val i = Sample.int // пробрасывает Random внутрь себя Print.println(s"Совершенно случайный ответ $i")
Финальная распаковка будет такой:
printSample(using Console, Random) // Совершенно случайный ответ 42
В чистом виде алгебраические эффекты не позволяют манипулировать потоком исполнения, в частности, реализовать асинхронность, или безопасное управление ресурсами. Некоторые библиотеки императивного стиля решают задачу асинхронности посредством метапрограммирования — блоки async представляют собой макросы, внутри которых код автоматически декомпозируется на продолжения (callbacks). Язык Scala не имеет встроенной поддержки управления продолжениями.
С другой стороны средства для управления потоком исполнения и так систематически внедряются Scala. Начиная с версии 3.3 появляются механизм раннего выхода boundary/break и элементы интеграции с упомянутым выше проектом Loom. Всё это позволяет без использования макросов горизонтально композировать более сложные эффекты, в том числе и асинхронность.
Но императивный стиль по‑прежнему несёт фундаментальную проблему контроля — обращения к эффектам могут быть завёрнуты в функции‑замыкания и «утекать» из блока (scope), где эти эффекты доступны. В таких случаях, например, исключения будут выбрасываться там, где их уже не поймает обработчик, а асинхронные вызовы сломают структурированный параллелизм или просто заблокируют главный поток.
В основном по этой причине в последних версиях Scala появилась экспериментальная возможность (-language:experimental.captureChecking) — типы могут теперь захватывать возможности (capabilities capturing) и компилятор может проверить, что их значения доступны только внутри своего блока (capture checking). Например, если мы хотим, чтобы какое‑то значение типа A не могло быть использовано за пределами текущего блока кода, мы объявим его как a: A^{any}. Здесь any — автоматически привязываемое к каждому блоку кода (скрытое) значение, позволяющее компилятору различать типы A^{any}, упоминаемые в разных блоках. Переменная такого типа захватывает локальную возможность any, но это может быть и произвольный набор локальных переменных и компилятор будет следить, чтобы никакое замыкание не вытащило их наружу. Для этого в системе типов Scala появился также новый синтаксис для чистых функций:
import caps.any A -> B =:= Function[A, B] // чистая функция A => B =:= Function[A, B]^{any} // нечистая функция, которая может захватывать любые возможности A ->{a1, a2} B =:= Function[A, B]^{a1, a2} // функция, захватывающая конкретные возможности
Вообще говоря, техника работы с отслеживанием захвата значений весьма нетривиальна и опирается на мощную теорию зависимых типов. Мы не будем здесь углубляться в неё. Для данной же статьи важно то, что отслеживание захвата помогает защититься от утечек при горизонтальной композиции эффектов в императивном стиле.
В итоге получается такая картина: благие намерения упростить программистам безопасную, статически типизированную композицию эффективных функций привели, наоборот, к усложнению синтаксиса, понимание которого потребует погружения в теорию зависимых типов… Стремление избавиться от «раздражающей» монадической композиции через цепочки .flatMap лишь породило нового монстра, причём он унаследовал старые недостатки императивщины. Хотя конкретно эта задача «упрощения монадической композиции» (см. начало раздела) решается гораздо проще не в императивном, а чисто функциональном стиле, с помощью композиции Клейсли:
import cats.syntax.all.* val programF = doSomething andThenF doSomethingGood andThenF doSomethingElse // Double => IO[Boolean]
Дополнительная литература
Свободные монады
Перевод стати Олега Киселёва Freer Monads, More Extensible Effects,
Документация к библиотеке Eff
Документация к библиотеке Turbolift
Документация к библиотеке Kyo
Абстрактные контейнеры
Документация к библиотеке Cats MTL
Tagless Final in Scala статья Даниэля Чокырлана в его блоге Rock the JVM.
Императивный стиль
Перевод стати Direct‑style Effects Explained Ноэля Уелша
Scala's Gamble with Direct Style статья Александру Недельку
Scala 3: What Is “Direct Style”? статья Дина Вамплера
Controlling program flow with capabilities статья Николя Ринаудо
Capture Checking официальная документация Scala
Промежуточный итог
В управлении эффектами можно выделить два аспекта:
предоставление доступа к эффекту,
предоставление обработчика эффекта.
Иногда их объединяют, предоставляя как доступ, так и обработчик. Так или иначе, речь идёт о неких функциях от эффекта к итоговому значению.
Часто возникают задачи, в которых эффекты не взаимодействуют друг с другом (то есть без «вертикальности»), или же если для нас не принципиальны нюансы такого взаимодействия. В каждый момент времени требуется доступ лишь к одному из горизонтальной суммы независимых эффектов. Предоставление эффектов — это функция, то есть, экспоненциал от суммы, который чисто алгебраически распадается на произведение (набор) экспоненциалов от каждого эффекта. Поэтому такую технику и называют горизонтальной композицией алгебраических эффектов.
В статье рассмотрены три подхода к реализации этой техники:
Свободная монада, универсальное свойство которой как раз требует тотальный интерпретатор всех бизнес‑эффектов. Отдельные интерпретаторы либо комбинируются («перемножаются») в тотальный (классический подход), либо последовательно применяются к свободной программе. Традиционный подход основан на GADT реализации свободной монады, когда сперва собирается программа‑как‑данные ожидающая свой интерпретации. Современные же библиотеки используют реализации, основанные на передаче продолжений, когда программа сразу строится как оптимизированная композиция функций. Кроме того, сейчас активно используются типы объединения и пересечений Scala 3 для оперирования суммой эффектов и произведением интерпретаторов.
Абстрактные контейнеры. Идея заключается в том, что доступ к эффектам и их обработчикам предоставляется через неявные экземпляры классов типов неизвестного тотального контейнера эффектов. Идея, использованная для вертикальной композиции в библиотеке Cats.MTL, превращается в технику Tagless Final. С ней, в отличие от свободных монад, мы не просто получаем программу как цепочку продолжений — она автоматически сворачивается при каждом вызове
flatMapв подставляемый контейнер. Однако обычно эта техника применяется с контейнерамиcats.effect.IOилиzio.ZIO, которые также представляют собой свободные монады, так что зачастую подход библиотек Turbolift и Kyo будет (хоть и немного, но) эффективнее Tagless Final.Императивный стиль. Это
переоценённый, мотивированный надуманной проблемойсинтаксический сахар над монадическими вычислениями, возвращающий привычный многим программистам стиль с сохранением безопасного контроля эффектов на этапе компиляции. Тем не менее, он также предоставляет возможность горизонтальной композиции эффектов.
Данная часть обзора является финальной, но итоговые тезисы вынесены в отдельную публикацию.
