в scala 3
Несколько дней назад мы увидели новую экспериментальную фичу под названием “проверка захвата” (capture checking), анонсированную в твите Мартина Одерски (Martin Odersky).
Эта фича является новой главой в десятилетней борьбе за добавление какой-либо формы системы эффектов в scala 3. Она имеет некоторое сходство с предложением линейных ограничений (linear constraints) для Haskell и временами жизни (lifetimes) Rust.
Дисклеймер:
для всех приведенные здесь фрагментов кода соответствие типов не гарантируется, они должны рассматриваться как псевдоязык подобный scala
Системы эффектов
Система эффектов представляет из себя что-то вроде метаинформации, обычно внутри системы типов. Она проверяет и предупреждает, что рантайм значение или другая семантика текущего выражения имеет некоторые особенности, такие как побочные эффекты, асинхронность, ошибки, индетерминизм, множественность и тому подобное.
Полноценная система алгебраических эффектов нуждается в нескольких компонентах:
Композиция, объединяющая все эффекты на ходу.
Система типов, свои поведением сравнимая с полурешетками.
Обработка эффекта, которая в своей логике воплощает правило сечения.
Первый означет, что если мы напишем что-то вроде 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, вы cможете применить его к своему выражению и получить какое-то новое выражение только с отметкой a на нем.
В Scala 3 было несколько возможных механизмов, обеспечивающих такую систему, примечательны из них эти два:
Контравариантность.
Контекстуальная абстракция.
Оба предлагают эффекты как возможности, т.е. требование объекта некоторого типа является эффектом, а предоставление требуемого объекта является обработкой.
Первый основан на том факте, что в scala 3 уже есть хорошая решетка подтипов. Это означает, что, имея некоторый контравариантный тип Expr[-_]
, вы можете получить правило, когда составление выражений с Expr[A]
и Expr[B]
приведет вас к некоторому Expr[A & B]
со всей коммутативностью и идемпотентностью забесплатно. На это очень сильно полагалась первая версия шаблона модуля ZIO.
Однако “обработка” была проблематичной, так как не так просто написать правильный рантайм, который может “вычесть” один трейт из другого, что в конечном итоге привело бы вас к необходимости “слияния” трейтов, т. е. предоставлению общей функции, выполняющей (A , В) => А & В
.
Сначала это было частично решено макросами в библиотеке "zio-macros".
Затем было частичное решение на основе HashMap
с TypeTag
-ключами под названием Has в ZIO. И ZIO-2 собирается сделать эту хэш-мапу полностью скрытой, оставив пользователя с такими типами, как Console & ReadUser & Postgres
, где все Console
, ReadUser
и Postres
являются голыми трейтами, а не псевдонимами 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:
время жизни.
Время жизни
Представьте, что у вас есть компилятор, который создает приложение без поддержки сборки мусора и копирует как можно меньше данных. Поэтому, когда вы выделяете что-то в куче или стеке, вам нужно поощрять повторное использование этих данных, используя некоторые голые ссылки на объект, не отслеживаемые сборщиком мусора.
Вот rust и ввел параметры времени жизни (lifetime).
Каждая функция или тип могут иметь общие параметры, такие как '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
Итак, вместо того, чтобы добавлять специальные именованные метки типов, мы можем просто использовать имена аргументов в качестве их “времени жизни”.
Так почему же это вообще релевантно для языка с рантаймом с поддержкой сборщика мусора, такого как scala?
Во-первых, и это наиболее очевидно, это решает проблему “протекающих возможностей”. Теперь всякий раз, когда Database
имеет трейт @capability
, вы не сможете написать Database ?=> User
, если только результатом не является значение, не имеющее отношение к Database. Вместо этого должно быть что-то вроде
capture dependent function.scala
(db: Database) ?=> {db} User // синтаксис не утвержден
Таким образом, вы должны где-то явно указать, что тип результата не свободен от db. Делает ли это контекстуальные абстракции хорошей системой эффектов? Трудно ответить на этот вопрос, но я думаю, что это дает нам гораздо более интересную вещь.
Следует отметить, что проверка захвата не включает “линейность” или другую форму подструктурной типизации. Если мы внимательно проверим, ни одно из трех наиболее распространенных структурных правил (ослабление, сокращение, обмен - weakening, contraction, exchange) не будет нарушено. Самый сложный из них, “сокращение” которое обеспечивает возможность “повторного использования”, по-прежнему безопасно. Хотя конечные типы могут явно ссылаться на имена переменных контекста, то же самое делает и система зависимых типов, и соответствующая логика не является подструктурной. Мы можем просто “переназначить” несколько имен к одной переменной во время сжатия.
Области видимости ресурсов
Типичное параллельное приложение, берущее в рассчет ресурсы, в 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.
Мы также можем добавить (a), чтобы обеспечить правильное завершение жизни.
Эти флаги `normal
` and `resource
`, скорее всего, могут быть выброшены с помощью какой-либо дополнительной аннотации "capture", так как (r: Release) ?-> {r} Async[A]
может быть псевдонимом для IO[normal, A]
а также (r: Release) ?-> {r} Async[ {r} A]
будет псевдонимом для IO[ресурс, A]
— вычисление, требующее завершения работы.
Таким образом, вместо того, чтобы иметь конкретную монаду для ресурсов, мы можем иметь метку области видимости, представляющую операции с областью видимости.
Заключительные мысли
Распределение ресурсов — это лишь один из примеров того, как можно использовать новую механику проверки захвата.
Но на этом история не заканчивается. Короче говоря, я не думаю, что проверка захвата заканчивается на отслеживании эффектов. Я считаю, что это прекрасный инструмент для отслеживания областей видимости.
Существует гораздо больше параллельных проблем с областью видимости, таких как
Конкурентные блокировки.
Сессии баз данных.
Пользовательские HTTP-сессии.
STM.
Каждый раз, когда у нас есть какой-то вспомогательный монадический тип, транслированный в IO, мы вводим специальную область видимости, и взаимодействие областей становится сложным.
Проверка захвата добавляет возможность иметь пересечения областей и заменяет каждый узкоспециализированный тип маркировки области одной лексической “возможностью”.
Это позволяет “пересекать” области видимости и, возможно, устранит зоопарки функторов в существующих параллельных библиотеках.
Скоро в OTUS пройдет открытый урок «Functional Design в Scala», на который приглашаем всех заинтересованных. На этом занятии мы:
— поговорим о 2-ух подходах в функциональном дизайне;
— рассмотрим основные компоненты, особенности;
— решим задачу, используя каждый из них.
Регистрация по ссылке.
Комментарии (2)
AnthonyMikh
07.03.2022 01:41-2Читал статью в оригинале. Как и ожидалось, переводчик, который не шарит, нагородил херни.
Des96
for-comprehension точно неправильно переводить как for-сравнение