Объяснение direct-style эффектов

от автора

Императивные эффекты (direct-style effects), также известные как «алгебраические эффекты» и «обработчики эффектов», — это следующий шаг в развитии языков программирования. Они уже доступны в Unison и OCaml, появятся в Scala, и я вижу обсуждения о них в других областях, тесно связанных с индустрией.

Цель очень проста: позволить нам писать код в естественном стиле без упоминания монад, но при этом получать преимущества логической корректности и композиции, которые предоставляют монады. В то же время я вижу некоторую путаницу в отношении императивных эффектов. В данном статье я хочу устранить эту путаницу, объяснив, что такое императивные эффекты, почему они нужны и как их использовать в Scala 3.

Сначала мы обсудим проблему и связанные с нею ограничения. Затем рассмотрим простую реализацию на Scala 3 и опишем языковую особенность, которая её обеспечивает — контекстные функции. Далее мы увидим некоторые недостатки этой реализации и посмотрим, как их можно решить с помощью двух языковых особенностей: одной хорошо известной (ограниченные продолжения) и одной, находящейся в разработке (нововведения в системе типов).

Что для нас важно

Когда мы отстаиваем один стиль программирования перед альтернативами, мы выносим оценочное суждение о программировании. Полезно четко обозначить эти ценности. Как я уже писал ранее , я считаю, что основными ценностями функционального программирования являются рассуждение и композиция. Побочные эффекты мешают нам достичь и того, и другого, но каждая полезная программа должна каким-то образом взаимодействовать с миром. (Если вы не уверены, что подразумевается под побочным эффектом, эта глава книги «Creative Scala» подробно это объясняет.) Поэтому замена побочных эффектов чем-то, соответствующим этим основным принципам, считается важной проблемой в функциональном программировании. Решения этой проблемы называются системами эффектов .

Примечание: в этом посте я использую термин «побочный эффект» для обозначения неконтролируемых эффектов, а просто «эффект» — для эффектов, которые контролируются более желательным способом.

Монады — наиболее распространённая система эффектов в современном функциональном программировании, но это не значит, что это единственный подход. В более старых версиях Haskell использовались потоки. В языке Clean используются типы уникальности, которые очень тесно связаны с аффинными типами, используемыми в проверке заимствований Rust. Большая часть современных исследований сосредоточена на так называемых алгебраических эффектах и ​​обработчиках эффектов . Именно такой подход мы и будем изучать, хотя сначала нам нужно немного разобраться в основах.

Теперь, когда мы знаем, почему системы эффектов интересны, давайте рассмотрим некоторые дизайнерские решения в системах эффектов.

Пространство проектирования систем эффектов

Доказуемость корректности и композиция являются обязательными критериями для любой системы эффектов. Однако существуют и другие желательные критерии. Здесь мы рассмотрим стиль написания кода, разделение между описанием и действием, а также некоторые нюансы того, как системы эффектов могут помочь нам рассуждать и составлять код с эффектами.

Императивный и монадический стиль

Стиль кода, который нам приходится писать для использования системы эффектов, является важным фактором, определяющим удобство её применения. Если система эффектов требует от программиста слишком много работы, она непригодна для использования на практике, независимо от других её свойств. Здесь мы рассмотрим императивный стиль, то есть код, который мы хотим написать, и монадический стиль, то есть код, который монадические системы эффектов вынуждают нас писать.

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

val a: A = ???val b = doSomething(a)val c = doSomething2(b)val d = doSomething3(c)

Об императивном стиле говорить особо нечего, кроме того, что писать в таком стиле желательно.

Как известно большинству программистов на Scala, для использования монад необходимо писать код в другом стиле. В монадическом стиле приведенный выше код в итоге выглядит примерно так:

doSomething(a)  .flatMap(b => doSomething(b))  .flatMap(c => doSomething(c))

Это считается достаточно раздражающим, поэтому языки, поддерживающие монады, обычно предоставляют для них специальный синтаксис. В Scala мы можем написать

for {  b <- doSomething(a)  c <- doSomething(b)  d <- doSomething(c)} yield d

Это не так уж плохо. Многие разработчики писали подобный код. Однако это всё ещё другой стиль кодирования, которому нужно научиться, и, следовательно, это барьер для входа. Это также полная трансформация программы. Как только одна часть нашего кода начинает использовать монады, обычно весь наш код приходится переводить на монадический стиль. Поэтому в идеале альтернативная система эффектов позволила бы нам продолжать писать в императивном стиле.

Описание и действие

В любой системе эффектов должно быть разделение между описанием эффектов, которые должны произойти, и фактическим их выполнением. Это требование композиции. Рассмотрим, пожалуй, самый простой эффект в любом языке программирования: вывод текста на консоль. В Scala мы можем реализовать это как побочный эффект с помощью println:

println("OMG, it's an effect")

Представьте, что мы хотим скомбинировать эффект вывода текста в консоль с эффектом изменения цвета текста на консоли. Из-за побочного эффекта printlnэто невозможно. После вызова функции printlnвывод уже сделан, изменить цвет уже не получится.

Хочу подчеркнуть, что цель — композиция . Безусловно, мы можем использовать два побочных эффекта, которые совпадут в правильном порядке, чтобы получить желаемый цвет.

println("\u001b[91m") // Color code for bright red textprintln("OMG, it's an effect")

Однако это не то же самое, что создание эффекта, который объединяет эти два эффекта. Например, в приведенном выше примере цвет переднего плана не сбрасывается, поэтому весь последующий вывод будет ярко-красным. Это классическая проблема побочных эффектов: у них есть «действие на расстоянии», то есть одна часть программы может изменить смысл другой части программы. Это, в свою очередь, означает, что мы не можем рассуждать локально и не можем создавать программы композиционным способом.

На самом деле мы хотим писать код, подобный этому:

Effect.println("OMG, it's an effect").foregroundBrightRed

Это ограничит цвет переднего плана только заданным текстом. Мы можем это сделать только если у нас есть разделение между описанием эффекта, как мы сделали выше, и его фактическим применением.

Рассуждения и композиция с эффектами

Системы эффектов должны помогать нам рассуждать о том, что делает код. Возьмем, к примеру, следующую сигнатуру метода:

def cthulhuFhtagn(): Unit

Что произойдёт, если мы вызовем этот метод? Возвращаемое значение Unitуказывает на наличие побочных эффектов, но каких именно? Это может быть вывод в консоль, возникновение исключения, или пробуждение Ктулху, дабы уничтожить Землю. Мы не можем сказать наверняка.

Использование IOмонады аналогично. Если же мы вместо этого увидим сигнатуру метода

def cthulhuFhtagn(): IO[Unit]

Мы снова не знаем, какие последствия произойдут, но у нас есть способ ими манипулировать. Мы можем попытаться нейтрализовать последствия, например, написав

IO.canceled *> cthulhuFhtagn()

или же восстановиться после ошибок, используя handleError.

Важно отметить, что мы можем манипулировать эффектами комбинируемым способом. Например, мы можем передать их другому методу, который выберет способ их обработки.

def cancelOrRecover(effect: IO[Unit]): IO[Unit] =  // Continue only if the stars are right  IO.realTimeInstant    .map(time => starsAreRight(time))    .ifM(      true = effect.handleError(...),      false = IO.cancel *> effect     )    cancelOrRecover(cthulhuFhtagn())

В первом случае, когда используются побочные эффекты, мы не можем этого сделать.

Прежде чем мы перейдем к системам эффектов, я хочу быстро затронуть еще один вопрос — композицию эффектов. Один из недостатков IO заключается в том, что она объединяет все эффекты в один тип. Нам, возможно, потребуется быть более точными и сказать, например, что этот метод требует логирования и доступа к базе данных, в то время как тот метод считывает данные с клавиатуры и выводит их на экран. Монадные трансформеры — один из способов добиться этого, но их сложно использовать. Более распространенной альтернативой является Tagless Final:

def cthulhuFhtagn[F[_]: WakeGreatOldOne](): F[Unit]

Это означает, что данный метод требует выполнения какого-либо действия WakeGreatOldOne, которое мы можем использовать, чтобы решить, вызывать ли этот метод вообще. Использование Tagless Final также неудобно, но не настолько, чтобы это помешало его распространению в мире Scala.

Системы императивных эффектов в Scala 3

Давайте теперь реализуем систему императивных эффектов на Scala 3. Для этого потребуется некоторый механизм, новый для Scala 3. Поскольку он, вероятно, незнаком многим читателям, мы начнем с примера, объясним лежащие в его основе методы программирования, а затем разберем концепции, которые он воплощает.

В качестве примера мы приводим простую систему эффектов для вывода информации в консоль. Реализация приведена ниже. Вы можете сохранить её в файле (например, с именем Print.scala) и запустить с scala-cliпомощью команды scala-cli Print.scala.

//> using scala 3// For convenience, so we don't have to write Console.type everywhere.type Console = Console.typetype Print[A] = Console ?=> Aextension [A](print: Print[A]) {  /** Insert a prefix before `print` */  def prefix(first: Print[Unit]): Print[A] =    Print {      first      print    }  /** Use red foreground color when printing */  def red: Print[A] =    Print {      Print.print(Console.RED)      val result = print      Print.print(Console.RESET)      result    }}object Print {  def print(msg: Any)(using c: Console): Unit =    c.print(msg)  def println(msg: Any)(using c: Console): Unit =    c.println(msg)  def run[A](print: Print[A]): A = {    given c: Console = Console    print  }  /** Constructor for `Print` values */  inline def apply[A](inline body: Console ?=> A): Print[A] =    body}@main def go(): Unit = {  // Declare some `Prints`  val message: Print[Unit] =    Print.println("Hello from direct-style land!")  // Composition  val red: Print[Unit] =    Print.println("Amazing!").prefix(Print.print("> ").red)  // Make some output  Print.run(message)  Print.run(red)}

Здесь Print[A]— это описание: программа, которая при запуске может выводить текст на консоль, а также вычислять значение типа A. Она реализована как контекстная функция. Контекстную функцию можно рассматривать как обычную функцию с неявными параметрами. В нашем случае Print[A]— это контекстная функция с заданным параметром Console (тип объекта из стандартной библиотеки).

Для контекстных функций существует особое правило, упрощающее их конструирование: обычное выражение будет преобразовано в выражение, которое порождает контекстную функцию, если тип выражения является контекстной функцией. Давайте разберем это на практике. В приведенном выше примере у нас есть строка

val message: Print[Unit] =  Print.println("Hello from direct-style land!")

Выражение Print.println имеет тип Unit, а не тип контекстной функции. Однако Print[Unit]является типом контекстной функции. Эта аннотация типа приводит к тому, что Print.println преобразуется в тип контекстной функции. Вы можете проверить это сами, удалив аннотацию типа:

val message =  Print.println("Hello from direct-style land!")

Этот код не скомпилируется.

Мы используем тот же приём с Print.apply, конструктором общего назначения. Вы можете вызвать его с любым выражением, и оно будет преобразовано в контекстную функцию. (Насколько я знаю, использование не обязательно inline, но во всех примерах, которые я изучал, это делается именно так, поэтому я тоже так делаю. Предполагаю, это оптимизация.)

При выполнении функции Print[A]используется ещё одна уникальная особенность: если в контекстной функции присутствует заданное значение соответствующего типа, это значение будет автоматически применено к функции. Именно это обеспечивает работу композиции в императивном стиле, пример которой показан ниже. Вызовы Print.print происходят в контексте, где доступна Console, и поэтому будут оценены после выполнения окружающей контекстной функции.

def red: Print[A] =  Print {    Print.print(Console.RED)    val result = print    Print.print(Console.RESET)    result  }

Таков принцип работы систем императивных эффектов в Scala: всё сводится к контекстным функциям.

Обратите внимание на то, что мы видим в этих примерах: мы пишем код в естественном императивном стиле, но у нас по-прежнему есть информативный тип, Print[A], который помогает нам рассуждать об эффектах, и мы можем комбинировать значения типа Print[A].

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

Обратите внимание, что мы разделяем императивные эффектына две части: контекстные функции, определяющие необходимые нам эффекты, и их фактическую реализацию. В литературе они называются соответственно алгебраическими эффектами и обработчиками эффектов. Это важное отличие от подхода IO, где один и тот же тип указывает на необходимость эффектов и предоставляет реализацию этих эффектов.

Обратите внимание, что для указания необходимых эффектов мы используем тип аргумента контекстных функций, а не тип результата, как в случае монадических эффектов. Это различие позволяет избежать проблемы «цветных функций» в монадах. Аргументы можно рассматривать как указание требований к среде или контексту, в котором функционирует контекст, отсюда и название.

Теперь давайте рассмотрим состав эффектов и эффекты, которые изменяют поток управления.

Устройство императивных эффектов

Императивные эффекты компонуются очень просто: мы просто добавляем дополнительные параметры к нашей контекстной функции. Вот простой пример, который определяет другой эффект, Sample, для генерации случайных значений, а затем создает программу, которая требует как , так Printи Sample.

Сначала мы определяем эффект, используя тот же шаблон, что и раньше.

import scala.util.Random// A `Sample[A]` is a description of an effect that, when run, will generate a// values of type `A` possibly using a random number generatortype Sample[A] = Random ?=> Aobject Sample {  // This runs a `Sample[A]` producing a value of type `A`. By default it uses  // the global random number generator, but the user can pass in a different  // generator as the first argument.  def run[A](sample: Sample[A])(using random: Random = scala.util.Random): A = {    given r: Random = random    sample  }  // Utility to use inside a `Sample[A]` to produce a random `Int`  def int(using r: Random): Int =    r.nextInt()  // Utility to use inside a `Sample[A]` to produce a random `Double`  def double(using r: Random): Double =    r.nextDouble()  // Constructs a `Sample[A]`.  inline def apply[A](inline body: Random ?=> A): Sample[A] =    body}

Теперь мы можем использовать и Print, и Sample.

val printSample: (Console, Random) ?=> Unit =  Print {    val i = Sample { Sample.int }    Print.println(i)  }Print.run(Sample.run(printSample))

Эффекты, управляющие потоком исполнения

До сих пор рассмотренные нами эффекты имели очень простую структуру управления потоком выполнения. Фактически, они вообще его не изменяли. Многие интересные эффекты, такие как обработка ошибок и параллелизм, требуют манипулирования потоком выполнения программы. Как мы будем обрабатывать это в нашей модели?

Нам необходимо небольшое расширение, чтобы это учесть: когда пользовательская программа вызывает метод обработчика эффекта, ему передаются не только аргументы этого метода, но и продолжение, которое можно возобновить после завершения эффекта. Что такое продолжение? Оно представляет собой «остальную часть программы»: значение, которое можно вызвать для продолжения выполнения с точки, вызвавшей обработчик эффекта. Кооперативные потоки, волокна, генераторы и сопрограммы — все это примеры абстракций, использующих форму продолжения.

Продолжения можно реализовать как преобразование программы, но для повышения производительности в идеале необходима поддержка во время выполнения. Именно поэтому в Scala Native появляются продолжения . В JVM их добавляет Project Loom .

В Scala 3 пока нет API для работы с продолжениями, но есть нелокальные выходы, scala.util.boundaryкоторые могут выражать несколько интересных вещей. Вот пример реализации обработки ошибок в стиле исключений.

//> using scala 3import scala.util.boundaryimport scala.util.boundary.{break, Label}final class Error[-A](using label: Label[A]) {  def raise(error: A): Nothing =    break(error)}type Raise[A] = Error[A] ?=> Aobject Raise {  inline def apply[A](inline body: Error[A] ?=> A): Raise[A] =    body  def raise[A](error: A)(using e: Error[A]): Nothing =    e.raise(error)  def run[A](raise: Raise[A]): A = {    boundary[A] {      given error: Error[A] = new Error[A]      raise    }  }}@main def go(): Unit = {  val program: Raise[String] =    Raise {      // This early return is difficult to write in a purely functional style      List(1, 2, 3, 4)        .foreach(x => if x == 3 then Raise.raise("Found 3"))      "No 3 found"    }  val result = Raise.run(program)  println(result)}

Обратите внимание, что у нас по-прежнему сохраняется разделение между описанием и действием. Операция programне выполняется до тех пор, пока мы не вызовем метод Raise.run, и управление потоком выполнения завершается в точке её выполнения, а не в точке её определения.

Используя императивные эффекты, мы можем писать программы, которые в противном случае должны были бы использовать traverse или другие комбинаторы в монадическом стиле. Вот пример, который создает Option[List[Int]]из List[Int].

val traverse: Raise[Option[List[Int]]] =  Raise {    Some(      List(1, 2, 3, 4).map(x => if x == 3 then Raise.raise(None) else x)    )  }println(Raise.run(traverse))

Это эквивалент следующей программы, использующей Cats:

val traverseCats: Option[List[Int]] =  List(1, 2, 3, 4).traverse(x => if x == 3 then None else Some(x))

Возможно, вас заинтересует, как монады реализуют эффекты, управляющие потоком выполнения, без необходимости поддержки во время выполнения. Ответ заключается в том, что монады требуют от пользователя явного указания потока выполнения. Именно это и делает монада: она выражает, что должно происходить и в каком порядке, и, предоставляя монаде эту информацию в виде цепочки flatMap, может оценивать их в порядке, который имеет смысл для конкретной реализации. Фактически, монады эквивалентны ограниченным продолжениям . Таким образом, императивные эффекты и монады можно рассматривать как два разных синтаксиса для написания одного и того же.

Захват, типы и эффекты

Как мы уже видели, эффекты довольно просты в реализации и использовании, и в большинстве случаев это так. Однако есть как минимум один нюанс, о котором нам нужно помнить: захват эффектов.

В приведенном ниже коде мы захватываем переменную Error[String]в замыкании, а затем пытаемся вызвать метод raise этой переменной вне блока кода Error, где она допустима. Это приводит к исключению во время выполнения.

val capture: Raise[() => String] =  Raise { error ?=> () =>    if 3 < 2 then "Nothing to see here"    else Raise.raise(() => "Hahahahaha!")(using error)  }val closure = Raise.run(capture)println(closure())

Является ли это серьезным недостатком во всей основе императивных эффектов? Нет! До сих пор мы видели только ту часть системы эффектов, которая сейчас есть в Scala 3. Проверка захвата , которая все еще находится в экспериментальной стадии, исключает этот тип ошибки. В статье о типах захвата содержатся все технические подробности.

Проверка захвата на самом деле выходит за рамки примеров, которые мы видели до сих пор. Она отслеживает использование возможностей в динамической области видимости программы. Ее можно использовать для реализации композиции различных эффектов. Ранее мы видели пример, где мы создали контекстную функцию с типом (Console, Random) ?=> Unit. Возможно, вы отнеслись к этому типу с некоторым скептицизмом, поскольку он не использует псевдонимы типов, которые мы использовали для двух эффектов ( Printи Sample). При проверке захвата система типов определяет эти типы за нас. Её также можно использовать для защиты от утечки ресурсов, например, для обеспечения закрытия всех открытых файлов или для реализации управления памятью.

Выводы и дополнительная литература

Мы убедились, что императивные эффекты позволяют писать код в естественном стиле, сохраняя при этом полезные типы, которые помогают в рассуждениях и позволяют комбинировать эффекты. Реализация представляет собой комбинацию:

  1. контекстные функции;

  2. продолжения; и

  3. усовершенствования типовой системы

что в совокупности позволяет нам выражать эффекты в императивном стиле как операции над обработчиками эффектов.

Меня очень радуют императивные эффекты в целом и, в частности, в Scala. Я думаю, они гораздо эргономичнее, чем монадические эффекты, что, в свою очередь, делает их доступными для более широкого круга программистов. Меня также радует доступ к продолжениям и, предположительно, к хвостовым вызовам в большем количестве языков. Хвостовые вызовы действительно полезны для решения определенных задач, таких как диспетчеризация виртуальных машин .

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

Если вы хотите узнать больше об императивных эффектах, вот несколько рекомендаций, представляющих собой сочетание доступных вводных материалов и научных статей:

  • Abilities for the monadically inclined — очень хорошая статья от команды Unison, которая охватывает большую часть того же материала с другой точки зрения.

  • Статья Дина Вамплера What is «Direct Style

  • Библиотека параллельного программирования Gears разрабатывается для Scala 3.

  • Raise4s — простой пример обработки ошибок в виде эффекта, аналогичного тем, что используются в программах императивного стиля.

  • An Introduction to Algebraic Effects and Handlers — это учебное пособие, рассматривающее проблему с более теоретической точки зрения.

  • Handlers in Action — одна из наиболее доступных для чтения научных работ по реализации обработчиков эффектов.

  • Capturing Types — описываются расширения системы типов Scala, необходимые для корректной реализации эффектов.

Наконец, если вам это показалось интересным, думаю, вам понравится моя книга Functional Programming Strategies. В ней рассматриваются многие концепции, упомянутые в этом посте, такие как стиль передачи продолжений и интерпретаторы, и многое другое.

ссылка на оригинал статьи https://habr.com/ru/articles/1059768/