Несколько дней назад в твите Мартина Одерски (Martin Odersky) была анонсирована новая экспериментальная фича под названием "проверка захвата".

Эта фича — новая глава в десятилетней борьбе за добавление чего-то похожего на систему эффектов в scala 3. Она имеет некоторое сходство с предложением линейных ограничений для Haskell и лайфтайма rust.

Дисклеймер: все приведенные здесь сниппеты не типизированы и должны рассматриваться как псевдоязык, похожий на scala

Системы эффектов

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

Полноценная система алгебраических эффектов нуждается в нескольких компонентах:

  • Композиция, которая объединяет все эффекты на ходу.

  • Система типов, которая ведет себя как полурешетка.

  • Обработка эффекта, которая в логике похожа на правило "Cut" (сечения).

Первый означает, что если мы пишем что-то вроде foo(bar(x)) или foo(x) + bar(y), где foo имеет эффект A, а bar имеет эффект B, то вся операция должна иметь эффект 'A + B'. Где "A" и "B" могут быть чем угодно, от мутирования некоторых переменных до параллельного межузлового взаимодействия.

Второй означает, что у вас есть некоторая операция "сложения" эффектов, коммутативная и идемпотентная. То есть a + b + a, a + b и b + a — это одно и то же.

Третий означает, что иногда вы можете взять некоторое выражение со сложным эффектом, например, a + b, и как только у вас появится некий "обработчик" для b, вы сможете применить его к своему выражению и получить какое-то новое выражение только с отметкой a на нем.

В Scala 3 было несколько возможных механизмов, обеспечивающих такую систему, наиболее заметными являются два:

  • Контравариантность.

  • Контекстная абстракция.

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

Первый вариант основан на том, что в Scala 3 уже существует хорошая решетка подтипов. То есть, имея некоторый контравариантный тип Expr[-_], вы можете получить правило, когда составление выражений с Expr[A] и Expr[B] приведет вас к некоторому Expr[A & B] со всей коммутативностью и идемпотентностью бесплатно. Это было полностью использовано в первой версии шаблона модуля ZIO..

Однако, "обработка" была хлопотной, поскольку не так просто написать корректный рантайм, который может "вычесть" один трейт из другого, что в конечном итоге привело бы вас к необходимости "мерджинга" трейтов, т.е. предоставлению общей функции, делающей (A, B) => A & B.

Сначала это было отчасти решено с помощью макросов в библиотеке "zio-macros".

Затем в ZIO появилось частичное решение на основе HashMap с ключами TypeTag под названием Has. А ZIO-2 собирается сделать эту хеш-карту полностью скрытой, оставляя пользователю типы типа Console & ReadUser & Postgres, где все типы Console, ReadUser и Postgres являются чистыми трейтами, а не псевдонимами Has.

Второй вариант, контекстная абстракция, был особенно интересен для Мартина Одерски. Он считает, что механизм неявной передачи аргументов во время их использования является наилучшей формой возможностей системы эффектов. Таким образом, получить эффекты A и B просто, как и контекстные аргументы типов A и B, т.е. иметь тип (A, B) ?=> Whatever.

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

Также ему не хватает базовых свойств, поскольку (A, B) ?=> X не является тем же типом, что и (B, A) ?=> X. И что еще хуже, если B является подтипом A, так что A ?=> X является подтипом B ?=> X для любого X, то неверно, что (A, B) ?=> X является тем же самым, что и B ?=> X.

Но это были не те неприятности, которые огорчали Мартина. Самой значительной для него стала проблема "утечки возможностей". Итак, представьте, что у вас есть Database ?=> User. Технически вы можете предоставить некоторую Database и теперь иметь чистое значение User, но нет никакой гарантии, что какой-то метод или функция внутри User не захватила этот экземпляр Database. Итак, вы продолжаете вычисления, теперь уже формально не привязанные к  Database, и где-то внезапно начинаете SQL-транзакцию, используя старую и, возможно, закрытую сессию базы данных.

Команда dotty была настолько озадачена этой напастью, что заново имплементировала одну из самых интересных вещей в системе типов rust: времена жизни (lifetimes).

Времена жизни

Представьте, что у вас есть компилятор, который создает приложение без поддержки сборки мусора и копирует как можно меньше данных. Поэтому, когда вы аллоцируете что-то в куче или стеке, вам нужно стимулировать повторное использование этих данных, используя некоторые не отслеживаемые GC (Garbage Collector. сборщик мусора) чистые ссылки на объект.

Поэтому в rust появились параметры времени жизни.

Каждая функция или тип может иметь общие параметры, вроде 'a 'b, они могут быть применены к некоторой ссылке или другой общей сущности и имеют значение "нижняя граница того, как долго эта ссылка будет жить" или "интервал, в котором эта ссылка гарантированно будет корректна".

Таким образом, следующая пара определений имеет совершенно различную семантику

fn get_something<'a>(src: &'a Source) -> Something
fn make_something<'a>(src: &'a Source) -> Something<'a>

Если в возвращаемом типе не упоминается время жизни параметра, то функция, возможно, что-то выполняет, обращается к аргументам во время этого и формирует результат, совершенно независимый от аргумента.

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

Проверка захвата

Команда dotty предлагает нечто подобное в scala 3. Но вместо дополнительных эфемерных типов, она представляет времена жизни с именами соответствующих параметров. Первый вариант при этом смотрелся бы традиционно.

def getSomething(using src: Source): Something

А во втором, теперь есть изысканная отметка на типе

def makeSomething(using src: Source): {src} Something

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

Почему же это вообще релевантно для языка с рантаймом с поддержкой GC, такого как scala?

Во-первых, и это наиболее очевидно, это решает проблему "утечки возможностей". Теперь, когда у  Database есть трейт, аннотированный @capability, вы не можете написать функцию Database ?=> User, только если результатом не будет значение, не относящееся к Database. Вместо этого должно быть что-то вроде

(db: Database) ?=> {db} User // syntax is uncofirmed

Поэтому вы должны где-то явно пометить, что тип результата не свободен от db. Позволяет ли это использовать контекстные абстракции в качестве хорошей системы эффектов? Трудно ответить на этот вопрос, но я думаю, что это дает гораздо более интересную вещь.

Следует помнить, что проверка захвата не включает в себя "линейность" или другую форму подструктурной типизации. Если проверить внимательно, то ни одно из трех наиболее распространенных структурных правил (ослабление, сокращение, обмен) не будет нарушено. Самое сложное из них, " сокращение", которое обеспечивает возможность "повторного использования", по-прежнему безопасно. Хотя конечные типы могут явно ссылаться на имена переменных контекста, так же поступает и система зависимых типов, и соответствующая логика не является подструктурной. Мы можем просто "переназначить" несколько имен к одной переменной во время сокращения.

Скоупы ресурса

Типичное ресурсно-ориентированное параллельное приложение в cats-effect или ZIO использует монадический тип Resource или ZManaged. Этот тип обычно основан на некоторой базовой асинхронной монаде, например, F[_] или ZIO, и включает в себя:

  • шаг для инициализации ресурса;

  • шаг освобождения ресурса.

Поэтому стандартным использованием такого типа будет resource.use(actions), что примерно эквивалентно:

for
  (r, release) <- resource.allocate
  x <- actions(r)
  _ <- release
yield x

Ресурс может быть составным, у вас может быть несколько процедур аллокации ресурсов и получится что-то вроде:

val resource = 
  for 
    a <- resA
    b <- resB(a)
    c <- resC(a, b)) 
  yield f(a, b, c)

Когда вы пишете что-то типа resource.use(actions) вся последовательность будет выглядеть как:

for
  (a, releaseA) <- resA.allocate
  (b, releaseB) <- resB(a).allocate
  (c, releaseC) <- resC(a, b).allocate
  x <- actions(f(a, b, c))
  _ <- releaseC
  _ <- releaseB
  _ <- releaseA
yield x

Ресурсы имеют некое статически известное время жизни. Ресурс c живет с 4 по 6 строку, b - с 3 по 7, а a - с 2 по 8.

Что если нам нужно что-то похожее на:

for
  (a, releaseA) <- resA.allocate
  (b, releaseB) <- resB(a).allocate
  x <- f(a, b)
  (c, releaseC) <- resC(a, b, c).allocate
  _ <- releaseA
  y <- g(b, c)
  _ <- releaseB
  _ <- releaseC
yield h(x, y)

В этом коде мы исходим из того, что resB и resC используют результат resA только во время инициализации, но не нуждаются в нем во время жизни, так что любая функция g может ссылаться на них без опасений. Но похоже, что подобные потоки трудно выразить, используя текущую форму ресурса со статическим скоупом.

RAII

Один из самых приятных аспектов времени жизни в rust — это то, что оно помогает эффективно работать с ресурсами.

Приведенный выше код можно преобразовать в нечто вроде:

let a = res_a()
let b = res_b(&a)
let x = f(&a, &b)
drop(a) // optional
let c = res_c(&a, &b)
let y = g(&b, &c)

Первое, что мы можем здесь увидеть — нет фактической необходимости в деаллокаторах, даже drop(a) необязателен, поскольку rust может автоматически рассчитать время сброса для каждой переменной.

Второе: хотя a отбрасывается, мы можем свободно использовать b и c, поскольку их время жизни, предположительно, не связано с временем жизни a.

Третье: у нас нет типа "ресурс". Каждая конструкция может служить в качестве аллокации ресурса.

К сожалению, эти тонкости трудно использовать в параллельном коде. Большинство популярных rust-имплементаций асинхронности используют глобальный цикл для планирования задач. Каждый элемент Task является частным случаем Future и должен иметь 'static время жизни, для того чтобы его можно было запланировать. Это означает, что переменные, аллоцированные в одной задаче, не могут быть использованы в качестве "отслеживаемых на протяжении всего времени жизни" ресурсов в другой. Так что если ваш "ресурс" должен использоваться параллельно, трудно отследить его "время жизни".

Проверка захвата и RAII

Используя новое предложение, вы добьетесь чего-то подобного. Можно выделить три типа использования, такие как:

def useA(a: A): {a} IO[B]
def useA(a: A): IO[{a} B]
def useA(a: A): {a} IO[{a} B]

Аннотация захвата перед IO означает, что вы можете ссылаться на {a} во время расчета. С другой стороны, аннотация перед типами B отражает, что результат вычисления все равно будет так или иначе ссылаться на аргумент.

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

Скажем, стандартный способ вычисления будет иметь тип IO[normal, A], а ресурсоподобный — IO[resource, A], мы должны соответствующим образом адаптировать наш flatMap. Т.е.

//ordinary calculation
extension [A] (io: IO[normal, A)
    def flatMap[flag, B](f: (a: A) -> {a} IO[flag, {a} B]) : IO[flag, B]

//resource allocation
extension [A](io: IO[resource, A])
// using the resource
  def flatMap[flag, B](f: (a: A) -> {a} IO[flag, B]): IO[flag, B]
// defering the deallocation
  def flatMap[B](f: (a: A) -> {a} IO[Any, {a} B]): IO[resource, B]

Это также означает, что нам нужен какое-то более сложное for-сравнение (или другой синтаксический сахар), которое могло бы завершать flatMap раньше остановки всего выражения, так что:

for
  a <- resA
  b <- resB(a)
  c <- resC(a, b)
  x <- foo(b, c)
yield bar(x)

можно преобразовать как:

resA
  .flatMap(a =>
    resB(a)
      .flatMap(b =>
        resC(a, b)  
          .map(c => (b, c))
  )
  .flatMap((b, c) => 
    foo(b, c)
      .map(bar))

Обратите внимание на левоассоциированный flatMap на resA, для предварительного закрытия ресурса на основании того, что b и c не захватывают ссылку a.

Мы также можем добавить сюда drop(a), чтобы обеспечить корректное завершение жизни.

Эти флаги `normal` и `resource`, скорее всего, могут быть отброшены с помощью дополнительной аннотации "capture", такой как (r: Release) ?-> {r}. Async[A] может быть псевдонимом для IO[normal, A] и (r: Release) ?-> {r} Async[ {r} A] будет псевдонимом для IO[resource, A] — расчет, требующий завершения.

Таким образом, вместо того, чтобы иметь конкретную монаду для ресурсов, мы можем иметь метку скоупа, представляющую скоуп-операции.

Заключительные мысли

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

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

Есть гораздо больше сопутствующих проблем связанных со скоупом, таких как

  • Конкурентные блокировки.

  • Сессии базы данных.

  • Пользовательские HTTP-сессии.

  • STM.

Каждый раз, когда у нас есть какой-то вспомогательный монадический тип, преобразованный в IO, мы вводим специальную область видимости, и тогда взаимодействие между скоупами становится сложным.

Проверка захвата добавляет возможность для пересечения скоупов и заменяет все узкоспециализированные типы маркировки скоупов одной лексической "возможностью".

Это позволяет "пересекать" области видимости и, возможно, устранит зоопарки функторов в существующих параллельных библиотеках.


Сегодня вечером в Otus пройдет открытый урок «Эффекты в Scala», на котором:

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

  • Введем понятие функционального эффекта, рассмотрим его свойства.

  • Реализуем свой небольшой функциональный эффект.

Регистрация — по ссылке.

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