Вы когда-нибудь задумывались о том, как выделяется память для переменных, и в какой конкретно момент она очищается? Как сборщик мусора «решает», что переменная уже не нужна и можно ли как-то повлиять на его решение?
В новой статье директор департамента разработки компании «Криптонит» Алексей Шуксто рассказал об интересных особенностях управления жизненным циклом объектов в Scala и Java разных версий. С необходимостью вникать в эту внутреннюю кухню сталкиваются все, кто использует в своих программах потоки, подключения к БД и другим сторонним сервисам, анализирует метрики, обрабатывает исключения… все, кто пишет что-то сложнее «Hello World!» и хочет добиться предсказуемого результата.
Для начала давайте определимся с тем, что именно я подразумеваю под словами «жизненный цикл» переменной в Scala в частности или в JVM вообще?
Самый простой случай — это инициализация переменной на стеке выполняющегося метода (желательно примитивного типа или объекта, попавшего под escape analysis).
val i = 1
В данном отрывке кода все очень просто: сначала поместили значение переменной на стек, потом сняли с вершины стека при использовании и всё.
Следующий случай — создание экземпляра класса в куче:
val o = new Object
Сильно сложнее не стало: выделяется память под объект, происходит его инициализация в конструкторе, потом его использование, потом его приберёт сборщик мусора. Когда? Никто не знает ответа на этот вопрос, как и на тот, когда будут (если вообще будут) вызваны методы Cleaner.Cleanable#clean() и Object#finalize().
Третий случай — инициализация чего-то похожего на потоки выполнения или ввода/вывода и получение данных из них:
val t = new Thread(...)
t.start()
Теперь, помимо инициализации объекта в конструкторе, появляется ещё и шаг «запуска» выполнения потока, а также вопрос: что делать с потоком по его выполнению? Нужен ли нам полученный результат? Если да, то как его получить? Завершится ли этот поток вообще и когда?
Четвертая ситуация — подключение к «стороннему» сервису, например, базе данных:
val session = DbSession(...).connect()
В данном случае помимо инициализации объекта и факта подключения у нас появляется ещё и внутреннее состояние самого подключения: могут произойти ошибки во время взаимодействия, потребуется переподключение, по окончании использования важно завершить сессию и т.д.
Пятый, самый сложный случай: «транзакции» — последовательности действий внутри сессий взаимодействия со сторонними сервисами:
val rx = session.beginTransaction()
Помимо того, что у транзакции есть своё состояние (ошибка, откат, подтверждение), нам ещё нужно учитывать текущее состояние «внешнего» подключения к сервису. То есть, в случае переподключения к сервису, нужно «переповторить» и все 'in-flight' транзакции, выполнявшиеся в момент переподключения.
try-with-resources
Для того чтобы дать пользователю стандартный механизм управления (по крайней мере «освобождением» объектов), в 2011 году в Java 7 появился механизм try-with-resources.
Он весьма прост: пользователь может инициализировать переменную любого класса, наследующего интерфейс AutoClosable вначале блока try(...):
var file = File.createTempFile("rcl-", ".tmp");
try(
var fw = new FileWriter(file);
var bw = new BufferedWriter(fw);
) {
bw.write("Hello World!");
}
Java гарантирует нам, что для всех инициализированных таким образом переменных метод .close() будет вызван в порядке, обратном порядку инициализации, по завершению блока try, но до выполнения возможных блоков catch или finally.
Звучит неплохо, однако остаются несколько моментов:
Внешне никак не отличить, почему
var fw = new FileWriter(file)можно написать внутриtry(...), аvar file = new File(..)— нельзя;Инициализировав
var fw = new FileWriter(file)вне блока мы не получим никакого предупреждения (или ошибки) от компилятора — это совершенно законно. Но и вызова.close()не случится;AutoClosable— интерфейс, и мы никак не можем добавить его постфактум к уже существующим классам, кроме как создав «делегата»;Данный механизм работает только для «освобождения» ресурсов, вся инициализация должна быть сосредоточена в конструкторе.
scala.util.Using
Долгое время Scala не могла похвастать наличием даже такого механизма управления жизненным циклом, однако в 2019 году, вместе с выходом Scala 2.13 там появился «механизм» scala.util.Using.
Не будем приводить тут его исходный код, вместо этого давайте попробуем сообразить, как он может выглядеть в переложении на Scala 3:
object Using:
trait Releasable[-A]:
def release(a: A): Unit
given [A <: AutoCloseable]: Releasable[A] with
def release(a: A): Unit = a.close()
def apply[A: Releasable, B](a: A)(f: A => B): B =
var toThrow: Throwable = null
try f(a)
catch
case NonFatal(e) =>
toThrow = e
null.asInstanceOf[B]
finally
try summon[Releasable[A]].release(a)
catch
case NonFatal(e) =>
if toThrow ne null then toThrow.addSuppressed(e)
else toThrow = e
if toThrow ne null then throw toThrow
end try
end apply
Внутри объекта Using объявлены:
типаж
Releasable[A], содержащий метод def release(a: A): Unit, освобождающий вса ресурсы, связанные с объектом типаA;givens(«данность»¯\_(ツ)_/¯, он жеimplicit, оно же неявное значение) наличия экземпляраReleasableдля любого потомкаAutoClosableметод
apply, который для любого типаA: Releasableи замыканияf: A => Bгарантирует, что после выполненияfвсе ресурсы переданного экземпляраa: Aбудут освобождены вне зависимости от статуса выполненияf(успешно, с ошибкой и т.д.).
Предложенная реализация apply несколько наивна в деле обработки исключений, однако в целом справляется со своими обязанностями. Мы перехватываем все NonFatal исключения, который могут произойти в процессе выполнения f(a), освобождаем связанные с a ресурсы и, дополнительно, также обрабатываем NonFatal ошибки, которые могут произойти в процессе освобождения, добавляя их по необходимости к уже существующим через механизм .addSuppressed.
Logger & Metrics
Для иллюстрации предлагаемых в данной статье механизмов управления ресурсами мы будем использовать два простых интерфейса:
final class Logger(name: String) extends AutoCloseable:
def printLine(s: String): Unit = ???
def close(): Unit = ???
Logger представляет нам метод .printLine(..), который печает переданную строку в консоль (вместе с собственным именем, s"$name: $s") и умеет "ругаться", если .printLine(..) был вызван после вызова .close(). Кроме того, он наследует AutoClosable, а значит для него есть given Releasable[Logger].
trait Metrics:
def apply[A](metric: String)(f: => A): A
def get: SortedMap[String, Metrics.Metric]
def clear(): Unit
Metrics умеет собирать данные о времени выполнения замыкания f под заданным именем, возвращать все собранные на какой-то момент времени метрики, а также удаляет все накопленные данные при вызове .clear(). Metrics ничего не знает про AutoClosable и, в целом, .clear() может быть вызван несколько раз, поэтому Releasable[Metrics] объявлен отдельно:
object Metrics:
def apply(): Metrics = ???
given Using.Releasable[Metrics] with
def release(a: Metrics): Unit = a.clear()
Как же выглядит использование Using вместе с этими интерфейсами?
def sum(x: Int, y: Int): Int =
Using(Logger("log")): log =>
log.printLine(s"will sum x = $x and y = $y")
x + y
Выполнив этот код в scala-cli repl мы увидим следующее:
scala> sum(2, 2)
— Printer 'log' is acquired.
log: will sum x = 2 and y = 2
— Printer 'log' is released
val res6: Int = 4
Мы инициализировали логгер по имени
log;Напечатали строку;
Произвели вычисления;
«Освободили» логгер;
Вернули результат вычисления «наружу».
Как будет выглядеть более сложное совместное использование Logger и Metrics в пределах одного метода?
def sumN_twice(n: Int): Seq[Int] =
Using(Logger("log")): log =>
Using(Metrics()): meter =>
log.printLine(s"will sum $n ranges [i..$n] where i in [0..$n]")
(0 to n).map: i =>
meter("vector"):
log.printLine(s"will sum range [$i, $n]")
(i to n).foldLeft(0): (acc, i) =>
meter("sum")(acc + i)
В данном куске кода уже начинают быть видны некоторые проблемы:
Появился заметный «сдвиг» («дрифт») вправо с каждым инициализированным ресурсом;
Мы забыли напечатать наши метрики. :(
Первую проблему не очень трудно поправить добавлением перегруженных методов apply для нескольких ресурсов:
object Using:
def apply[A1: Releasable, A2: Releasable, B](
a1: A1, a2: => A2
)(f: (A1, A2) => B): B =
apply(a1)(a1 => apply(a2)(a2 => f(a1, a2)))
Попробуем использовать новый метод, заодно вспомнив про печать метрик:
def sumN(n: Int): Seq[Int] =
Using(Logger("log"), Metrics()): (log, meter) =>
log.printLine(s"will sum $n ranges [i..$n] where i in [0..$n]")
val result = (0 to n).map: i =>
meter("vector"):
log.printLine(s"will sum range [$i, $n]")
(i to n).foldLeft(0): (acc, i) =>
meter("sum")(acc + i)
log.printLine(s"metrics:")
meter.get.foreach: (name, metric) =>
log.printLine(s" $name -> ${metric.mean} ± ${math.sqrt(metric.variance)} (${metric.count} samples)")
result
scala> sumN(3)
— Printer 'log' is acquired.
log: will sum 3 ranges [i..3] where i in [0..3]
log: will sum range [0, 3]
log: will sum range [1, 3]
log: will sum range [2, 3]
log: will sum range [3, 3]
log: metrics: log: sum -> 271.19999999999993 ± 240.35257435692256 (10 samples)
log: vector -> 35505.25 ± 16556.91653622437 (4 samples)
— Printer 'log' is released
val res7: Seq[Int] = Vector(6, 6, 5, 3)
Кажется, мы получили что хотели, но было бы неплохо каким-то образом научится «не забывать» про печать метрик. Попробуем написать реализацию Metrics совмещенную с логированием результатов:
object Metrics:
def logging(): Metrics =
Using(Logger("metrics")): log =>
Logging(Metrics(), log)
private[Metrics] final class Logging(
underlying: Metrics,
log: Logger,
) extends Metrics:
def apply[A](metric: String)(f: => A): A = underlying(metric)(f)
def get: SortedMap[String, Metric] = underlying.get
def clear(): Unit =
log.printLine("collected metrics:")
get.foreach: (name, metric) =>
log.printLine(
f" $name%s -> ${metric.mean}%.2f ± ${math.sqrt(metric.variance)}%.2f (${metric.count}%d samples)"
)
underlying.clear()
Кажется, мы сделали все, что было нужно. Попробуем это использовать:
def sumN_logging_uar(n: Int): Seq[Int] =
Using(Logger("log"), Metrics.logging()): (log, meter) =>
log.printLine(s"will sum $n ranges [i..$n] where i in [0..$n]")
(0 to n).map: i =>
meter("vector"):
log.printLine(s"will sum range [$i, $n]")
(i to n).foldLeft(0): (acc, i) =>
meter("sum")(acc + i)
scala> sumN_logging_uar(3)
— Printer 'log' is acquired.
— Printer 'metrics' is acquired.
— Printer 'metrics' is released log: will sum 3 ranges [i..3] where i in [0..3]
log: will sum range [0, 3]
log: will sum range [1, 3]
log: will sum range [2, 3]
log: will sum range [3, 3]
metrics: !!! WARN !!! Use of printer 'metrics' after release.
metrics: collected metrics:
metrics: !!! WARN !!! Use of printer 'metrics' after release.
metrics: sum -> 215.00 ± 159.92 (10 samples)
metrics: !!! WARN !!! Use of printer 'metrics' after release.
metrics: vector -> 133451.00 ± 175937.21 (4 samples)
— Printer 'log' is released
val res8: Seq[Int] = Vector(6, 6, 5, 3)
Увы! Несмотря на использование Using(Logger("metrics")) (или благодаря этому?), инициализированный логгер был освобожден сразу после завершения метода Metrics.logger() (как и должен был). Поэтому печать метрик привела к ситуации 'use after release'.
К сожалению, на данный момент нам ничего не остается, кроме как полагаться на экземпляр логгера, переданный извне:
object Metrics:
def logging(log: Logger): Metrics =
Logging(Metrics(), log)
def sumN_logging_using(n: Int): Seq[Int] =
Using(Logger("log")): log =>
Using(Metrics.logging(log)): meter =>
log.printLine(s"will sum $n ranges [i..$n] where i in [0..$n]")
(0 to n).map: i =>
meter("vector"):
log.printLine(s"will sum range [$i, $n]")
(i to n).foldLeft(0): (acc, i) =>
meter("sum")(acc + i)
scala> sumN_logging_using(3)
— Printer 'log' is acquired.
log: will sum 3 ranges [i..3] where i in [0..3]
log: will sum range [0, 3]
log: will sum range [1, 3]
log: will sum range [2, 3]
log: will sum range [3, 3]
log: collected metrics:
log: sum -> 168.60 ± 122.33 (10 samples)
log: vector -> 35938.75 ± 25733.62 (4 samples)
— Printer 'log' is released
val res9: Seq[Int] = Vector(6, 6, 5, 3)
Что делать с переданными ресурсами?
Отдельно стоит поговорить про операцию underlying.close(), которая происходит на 84й строчке Metrics.scala: выполнять её или нет — вопрос не из самых простых. В данном случае Metrics.Logging внутренний класс объекта Metrics и мы точно знаем, что только мы имеем доступ к его конструктору и передаем в него «свежий» экземпляр Metrics.apply(). Однако в общем случае это не так: например, экземпляр Logger мы получаем «снаружи» и закрывать его после использования скорее всего не самая лучшая идея. Хотелось бы какой-то определённости, правда?
scala.util.Using.Manager
Для того, чтобы было удобнее работать с несколькими ресурсами в пределах одного метода, внутри Using существует дополнительный класс Manager:
final class Manager private () extends AutoCloseable:
private var closed = false
private var handles = List.empty[() => Unit]
def apply[A: Releasable](a: A): a.type =
if !closed then handles = (() => summon[Releasable[A]].release(a)) :: handles
else throw new IllegalStateException("Manager has already been closed")
a
def close(): Unit =
closed = true
val toRelease = handles
var toThrow: Throwable = null
handles = null
toRelease.foreach: release =>
try release()
catch
case NonFatal(e) =>
if toThrow ne null then e.addSuppressed(toThrow)
toThrow = e
if toThrow ne null then throw toThrow
end close
end Manager
object Manager:
def apply[A](f: Manager => A): A = Using(new Manager())(f(_))
Суть его довольно проста: через метод .apply объекта Manager можно получить экземпляр класса Manager, вызывая внутренний .apply которого можно регистрировать ресурсы в течение всего времени выполнения метода (или блока кода).
Все зарегистрированные ресурсы будут освобождены в обратном порядке после завершения выполнения блока, переданного в Manager$.apply:
def sumN_manager(n: Int): Seq[Int] =
Using.Manager: use =>
val log = use(Logger("log"))
val meter = use(Metrics.logging(log))
log.printLine(s"will sum $n ranges [i..$n] where i in [0..$n]")
(0 to n).map: i =>
meter("vector"):
log.printLine(s"will sum range [$i, $n]")
(i to n).foldLeft(0): (acc, i) =>
meter("sum")(acc + i)
scala> sumN_manager(3)
— Printer 'log' is acquired.
log: will sum 3 ranges [i..3] where i in [0..3]
log: will sum range [0, 3] log: will sum range [1, 3]
log: will sum range [2, 3]
log: will sum range [3, 3] log: collected metrics:
log: sum -> 240.30 ± 239.07 (10 samples)
log: vector -> 38695.75 ± 29251.04 (4 samples)
— Printer 'log' is released
val res6: Seq[Int] = Vector(6, 6, 5, 3) 3
Что нового в Scala 3?
scala.util.Using появился в Scala 2.13, и нашу реализацию тоже можно было написать «по-старому», так чем же нам поможет использование Scala 3? Например, контекстными функциями. Что это такое?
До Scala 3 мы могли объявить тип функции только как val f: (A1, .., An) => B. К сожалению, это приводило к тому, что если мы хотели использовать один или несколько аргументов в виде неявных параметров в теле функции, необходимо было писать их отдельно, снабжая ключевым словом implicit:
def sum_implicit(x: Int, y: Int)(implicit log: Logger) = ???
def repeat(i: Int)(f: Logger => A): Vector[A] =
val log = new Logger("repeat")
(0 until i).map(_ => f(log))
// repeat(3)(_ => sum_implicit(2, 2)) — won’t compile
repeat(3) { implicit log => sum_implicit(2, 2) }
Теперь у нас есть возможность объявить (некоторые) аргументы типа функции параметрами контекста, используя символ ?=>. Такие параметры автоматически будут использованы как givens внутри тела функции:
def sum_implicit(x: Int, y: Int)(using log: Logger) = ...
def repeat(i: Int)(f: Logger ?=> A): Vector[A] =
val log = new Logger("repeat")
(0 until i).map(_ => f(using log))
repeat(3)(sum_implicit(2, 2))
repeat(3): log ?=>
log.printLine("before 'sum_implicit'")
sum_implicit(2, 2)
log.printLine("after 'sum_implicit'")
}
Хорошо, но что мы с этим можем сделать? Мы можем написать DSL, упрощающий использование Using, но и предполагающий расширение. Для этого мы сперва несколько перепишем Manager:
final class Manager private[managed] ():
private var closed = false
private var handles = List.empty[() => Unit]
private[managed] def handle(f: () => Unit): Unit = ???
private[managed] def close(): Unit = ???
Он отличается от Using.Manager только тем, что регистрирует не экземпляры A: Releasable, а любую функцию () => Unit. При этом код методов .handle и .close остался фактически без изменений.
В дополнение к новому классу объявим функцию manage:
def manage[A](f: Manager ?=> A): A =
val manager = Manager()
var toThrow: Throwable = null
try f(using manager)
catch
case NonFatal(e) =>
toThrow = e
null.asInstanceOf[A]
finally
try manager.close()
catch
case NonFatal(e) =>
if toThrow ne null then toThrow.addSuppressed(e)
else toThrow = e
if toThrow ne null then throw toThrow
end try
end manage
Нетрудно заметить, что тело этой функции один в один повторяет Using.Manager.apply, однако обрабатываемое замыкание f теперь контекстная функция.
Осталось определить еще пару утилитарных методов:
deferпозволяет зарегистрировать любой блок кода, возвращающийUnit, для выполнения "по окончании"manage;useрегистрирует любой объект типаA, для которого существуетReleasable[A], повторяя "старый"Using.Manager#apply.
def defer(f: => Unit)(using M: Manager): Unit = M.handle(() => f)
def use[A](a: A)(using M: Manager, R: Using.Releasable[A]): A =
defer(R.release(a))
a
Давайте посмотрим, как изменится реализация наших тестовые методы с использованием этих функций:
def sumN(n: Int): Seq[Int] = manage:
val log = use(Logger("log"))
val meter = use(Metrics.logging(log))
log.printLine(s"will sum $n ranges [i..$n] where i in [0..$n]")
defer(log.printLine("'sumN' completed"))
(0 to n).map: i =>
meter("vector"):
log.printLine(s"will sum range [$i, $n]")
(i to n).foldLeft(0): (acc, i) =>
meter("sum")(acc + i)
Запустив этот код, можно убедится, что дополнительное логирование, переданное в defer происходит после выполнения тела функции, но до освобождения экземпляров Metrics и Logger, как и было задумано:
scala> sumN(3)
— Printer 'log' is acquired.
log: will sum 3 ranges [i..3] where i in [0..3]
log: will sum range [0, 3]
log: will sum range [1, 3]
log: will sum range [2, 3]
log: will sum range [3, 3]
log: 'sumN' completed
log: collected metrics:
log: sum -> 241.80 ± 46.11 (10 samples)
log: vector -> 37783.50 ± 10351.99 (4 samples)
— Printer 'log' is released
val res1: Seq[Int] = Vector(6, 6, 5, 3)
bracket — как много в этом слове...
Внимательный читатель уже наверняка заметил, что все методы, заведующие управлением ресурсами, которые мы успели написать, устроены по одному шаблону:
Инициализация ресурса;
Работа с ресурсом;
Освобождение ресурса и возврат результата работы с ресурсом.
В мире сказок функционального программирования подобный шаблон известен под именем bracket и может быть записан в следующей обобщённой форме:
def bracket[A, B](acquire: => A)(use: A => B)(release: A => Unit): B =
val a = acquire
var toThrow: Throwable = null
try use(a)
catch
case NonFatal(e) =>
toThrow = e
null.asInstanceOf[B]
finally
try release(a)
catch
case NonFatal(e) =>
if toThrow ne null then toThrow.addSuppressed(e)
else toThrow = e
if toThrow ne null then throw toThrow
end try
end bracket
Используя подобную конструкцию, мы могли бы переписать любой из примеров, реализованных выше:
def sumN(n: Int): Seq[Int] =
bracket:
val log = Logger("log")
val meter = Metrics.logging(log)
(log, meter)
.apply: (log, meter) =>
log.printLine(s"will sum $n ranges [i..$n] where i in [0..$n]")
(0 to n).map: i =>
meter("vector"):
log.printLine(s"will sum range [$i, $n]")
(i to n).foldLeft(0): (acc, i) =>
meter("sum")(acc + i)
.apply: (log, meter) =>
meter.clear()
log.close()
Однако, как уже тоже наверняка заметил внимательный читатель, нам приходится раз за разом повторять процедуры инициализации и освобождения объектов. Хочется каким-то образом делать это поменьше, а с самим ресурсом работать побольше и попроще. В этом нам поможет программная идиома "Resource Acquisition is Initialization" или «Получение ресурса есть инициализация».
RAII: Resource Acquision is Initialization
Суть данной идиомы заключается в том, что процесс получения ресурса для использования становится неотрывно связан с процессом его инициализации (например, в конструкторе), а процесс освобождения — с уничтожением ресурса (например, в деструкторе).
В некоторых языках (Rust, C++) подобные механизм реализуется непосредственно средствами компилятора, однако Scala/JVM не предлагает нам ничего подобного. Как же быть? Давайте реализуем эту идиому в виде типажа Resource[A]:
trait Resource[A]:
def allocate: (A, () => Unit)
def use[B](f: A => B): B =
val (a, release) = allocate
...
end use
def map[B](f: A => B): Resource[B] = Resource.Map(this, f)
def flatMap[B](f: A => Resource[B]): Resource[B] = Resource.Bind(this, f)
end Resource
object Resource:
def apply[A](acquire: => A)(release: A => Unit): Resource[A] = new Resource[A]:
def allocate: (A, () => Unit) =
val a = acquire
(a, () => release(a))
private[Resource] final class Map[A, B](
underlying: Resource[A], f: A => B
) extends Resource[B]:
def allocate: (B, () => Unit) = ???
private[Resource] final class Bind[A, B](
underlying: Resource[A], f: A => Resource[B]
) extends Resource[B]:
def allocate: (B, () => Unit) = ???
end Resource
Основным методом нашего интерфейса является все тот же .use(..) похожий по сигнатуре и реализации на многочисленные методы управления ресурсами выше, однако добавились и еще несколько:
.allocateдает возможность пользователю получить полный контроль над ресурсом и фукнцией его освобождения;.mapи.flatMapдают нам возможность монадической композиции ресурсов (например, черезfor-comprehension). Исходный код методаallocateдляResource.MapиResource.Bindможно посмотреть в файле Resource.scala — там нет ничего сложного, как всегда надо только аккуратно разобраться с последовательностью перехвата исключений и количеством мест, где они могут произойти.
Конструктор Resource.apply существенно отличается от Using.apply тем, что:
Не предполагает наличия специальных интерфейсов вроде
AutoClosableилиReleasable;Позволяет описывать процессы инициализации и освобождения фактически любого типа (или набора типов).
Например, мы можем переписать конструктор экземпляров Logger следующим образом:
object Logger:
def resource(name: String): Resource[Logger] =
Resource(Logger(name))(_.close())
… и его использование будет не слишком отличаться от реализованного ранее:
def sum(x: Int, y: Int): Int = Logger
.resource("log")
.use: log =>
log.printLine(s"will sum x = $x and y = $y")
x + y
В случае Metrics можно пойти даже дальше, воспользовавшись фактом, что наша реализация «метрик с логированием» никак не использует логгер вплоть до момента печати собранных метрик перед очисткой. Соответственно, инициализировать логгер можно прямо перед печатью, а освобождать — сразу после:
object Metrics:
def resource(): Resource[Metrics] =
Resource(Metrics())(_.clear())
def lazyLogging(): Resource[Metrics] =
resource().flatMap: origin =>
val log = Logger.resource("metrics")
Resource(LazyLogging(origin, log))(_.clear())
private[Metrics] final class LazyLogging(
underlying: Metrics,
log: Resource[Logger],
) extends Metrics:
def apply[A](metric: String)(f: => A): A = underlying(metric)(f)
def get: SortedMap[String, Metric] = underlying.get
def clear(): Unit = log.use: log =>
log.printLine("collected metrics:")
get.foreach: (name, metric) =>
log.printLine(
f" $name%s -> ${metric.mean}%.2f ± ${math.sqrt(metric.variance)}%.2f (${metric.count}%d samples)"
)
end LazyLogging
end Metrics
Кроме того, у нас теперь нет никакой необходимости решать вопрос «вызывать underlying.clear() или log.close(), или нет?». Тип данных метрик и логгера в виде Resource[_] однозначно указывает нам, что все необходимые действия по освобождению ресурсов будут предприняты и без нашего участия.
Немного омрачает праздник лишь тот факт, что для одновременного использования логгера и метрик придётся прибегать к монадической композиции:
def sumN(n: Int): Seq[Int] =
val resources = for
log <- Logger.resource("log")
metrics <- Metrics.lazyLogging()
yield (log, metrics)
resources.use: (log, meter) =>
log.printLine(s"will sum $n ranges [i..$n] where i in [0..$n]")
(0 to n).map: i =>
meter("vector"):
log.printLine(s"will sum range [$i, $n]")
(i to n).foldLeft(0): (acc, i) =>
meter("sum")(acc + i)
end sumN
Как это упростить? Давайте добавим еще один метод, использующий наш новый Manager:
def acquire[A](r: Resource[A])(using M: Manager): A =
val (a, release) = r.allocate
defer(release())
a
Получается очень похоже на direct style, который сейчас так в моде:
def sumN_acquire(n: Int): Seq[Int] = manage:
val log = acquire(Logger.resource("log"))
val meter = acquire(Metrics.lazyLogging())
log.printLine(s"will sum $n ranges [i..$n] where i in [0..$n]")
(0 to n).map: i =>
meter("vector"):
log.printLine(s"will sum range [$i, $n]")
(i to n).foldLeft(0): (acc, i) =>
meter("sum")(acc + i)
end sumN_acquire
scala> sumN(3)
— Printer 'log' is acquired.
log: will sum 3 ranges [i..3] where i in [0..3]
log: will sum range [0, 3]
log: will sum range [1, 3]
log: will sum range [2, 3]
log: will sum range [3, 3]
— Printer 'metrics' is acquired.
metrics: collected metrics:
metrics: sum -> 734.20 ± 1694.38 (10 samples)
metrics: vector -> 47954.25 ± 34814.71 (4 samples)
— Printer 'metrics' is released
— Printer 'log' is released
val res6: Seq[Int] = Vector(6, 6, 5, 3)
Немного метапрограммирования
Чтобы ещё больше облегчить себе использование нескольких ресурсов одновременно, вспомним про ещё два нововведения в Scala3: изменение представления кортежей и match types.
Кортежи
В отличие от Scala 2.13, где представлением для кортежей фактически был набор кейс-классов Tuple1[A1](_1: A1), ... Tuple22[A1, .., A22](_1: A1, ..., _22: A22), в Scala 3 кортежи больше похожи на гетерогенные списки из shapeless:
sealed trait Tuple
case object EmptyTuple extends Tuple
sealed trait NonEmptyTuple extends Tuple:
def head: Head[this.type]
def tail: Tail[this.type]
sealed abstract class *:[+H, +T <: Tuple]
extends NonEmptyTuple
Теперь любой кортеж вида (A, B, C) представляет собой похожую на список структуру типов A *: B *: C *: EmptyTuple, и точно также предоставляет операции получения первого элемента (.head), последнего элемента (.last) и другие.
Match Types
Что же такое странные типы Head[this.type] и Tail[this.type] в объявлении выше? Это те самые Match Types, то есть типы, конкретный тип которых в момент компиляции зависит от типа-параметра:
type Head[X <: NonEmptyTuple] = X match
case h *: _ => h
type Tail[X <: NonEmptyTuple] = X match
case _ *: t => t
В данном случае Head[_] всегда будет совпадать с типом первого элемента кортежа, а Tail[_] — c типом кортежа их остальных элементов (или EmptyTuple, если он пуст).
Managing
В нашем случае мы можем объявить match type следующего вида:
type ResourceParams[X <: NonEmptyTuple] <: NonEmptyTuple = X match
case Resource[a] *: EmptyTuple => a *: EmptyTuple
case Resource[a] *: tail => a *: ResourceParams[tail]
При помощи данного типа мы можем определить кортеж типов ресурсов, «управляемых» кортежем типов Resource[?]:
scala> type A = ResourceParams[(Resource[Logger], Resource[Metrics])] // defined alias type A = (Logger, Metrics)
С использованием этого типа мы можем написать функцию управления множеством ресурсов:
def managing[X <: NonEmptyTuple, A](rs: X)(using ev: Tuple.Union[X] <:< Resource[?])(
f: ResourceParams[X] => Manager ?=> A
): A = manage:
def loop(rest: NonEmptyTuple, acc: Tuple): NonEmptyTuple = (rest: @unchecked) match
case (r: Resource[a]) *: EmptyTuple => acc :* acquire(r)
case (r: Resource[a]) *: (tail: NonEmptyTuple) => loop(tail, acc :* acquire(r))
f(loop(rs, EmptyTuple).asInstanceOf[ResourceParams[X]])
С этой функцией наш код становится максимально похож на try-with-resources (за вычетом внутренних зависимостей между ресурсами, увы, но для этого есть acquire):
def sumN_manage(n: Int): Seq[Int] = managing(
Logger.resource("log"),
Metrics.lazyLogging()
): (log, meter) =>
log.printLine(s"will sum $n ranges [i..$n] where i in [0..$n]")
(0 to n).map: i =>
meter("vector"):
log.printLine(s"will sum range [$i, $n]")
(i to n).foldLeft(0): (acc, i) =>
meter("sum")(acc + i)
end sumN_manage
scala> sumN_manage(3)
— Printer 'log' is acquired.
log: will sum 3 ranges [i..3] where i in [0..3]
log: will sum range [0, 3]
log: will sum range [1, 3]
log: will sum range [2, 3]
log: will sum range [3, 3]
— Printer 'metrics' is acquired.
metrics: collected metrics:
metrics: sum -> 255.40 ± 260.76 (10 samples)
metrics: vector -> 388021.75 ± 591805.62 (4 samples)
— Printer 'metrics' is released
— Printer 'log' is released
val res7: Seq[Int] = Vector(6, 6, 5, 3)
Хочется отменить, что при этом мы получили поддержку произвольного количества аргументов в managing, а типы ресурсов, которые будут переданы в замыкание f известны статически в момент компиляции.
Светлое будущее: 'Caprese'
К сожалению, у нас все ещё осталась проблема использования ресурсов после их освобождения.
Мы можем легко представить себе ситуацию, когда кто-то решил не задавать имя метрики каждый раз, а вынести запись каждой именованной метрики в отдельную функцию:
def sumN(n: Int): Seq[Int] = manage:
val log = acquire(Logger.resource("log"))
val (seqMetrics, sumMetrics) = Metrics
.lazyLogging()
.use: metrics =>
(metrics[Int]("vector"), metrics[Int]("sum"))
log.printLine(s"will sum $n ranges [i..$n] where i in [0..$n]")
(0 to n).map: i =>
seqMetrics:
log.printLine(s"will sum range [$i, $n]")
(i to n).foldLeft(0): (acc, i) =>
sumMetrics:
acc + i
end sumN
Увы, запустив это код, мы не только не увидим наших метрик, но и (что существенно хуже), даже не получим никакого предупреждения от логгера, как это было в начала статьи. С точки зрения наших ресурсов всё прошло нормально: метрики инициализировали и сразу очистили.
Это происходит из-за отсутствия на данный момент возможности отследить на уровне компилятора Scala время жизни (или доступности для использования другими) объекта.
Однако, в скором будущем это изменится благодаря проекту 'Caprese: Capabilities for resources and effects', часть которого уже доступна в версиях Scala 3.4 и 3.5 под флагом -experimental:
import scala.language.experimental.captureChecking
object Metrics:
def logging(log: Logger^): Metrics^{log} = ???
// won't compile
def createMetrics(): Metrics =
val log: Logger^ = Logger("log")
val metrics: Metrics^{log} = Metrics.logging(log)
metrics
В данном случае при помощи нового синтаксиса ^{...} мы сообщили компилятору что:
метод
Metrics.loggingпринимаетLoggerтолько с известным «временем жизни» (вольный перевод терминаcapabilityавтором, более похожий наlifetimeиз Rust);тот же метод возвращает экземпляр
Metrics^{log}, который зависит от «времени жизни» переданногоlog: Logger^, т.е. не может существовать дольше.
С точки зрения capability, любой «чистый» тип (т.е. тип, не зависящий от других capabilities) является потомком такого же типа, но с известными capabilities, которые, в свою очередь являются потомками типа с универсальной capability ^{cap} (или просто ^): Metrics <: Metrics^{log} <: Metrics^.
В результате, если мы попытаемся вернуть из метода def createMetrics(): Metrics объект с типом Metrics^{log}, нам это запретит компилятор.
Также меняется и синтаксис типов функций:
f: A -> Bтеперь означает «чистую» функцию, т.е. не зависящую (не захватывающую) от каких-либо capability;f: A ->{log} Bозначает функцию, тело которой может как-либо использовать переменнуюlog, а, следовательно, доступную только одновременно с ней;f: A => Bозначает то же, что иf: A ->{cap} B, то есть старые добрые функции, захватывающие всё, что угодно, и никак это не отслеживающие.
Более подробно про Capture Checking можно прочитать в Scala Reference, мы же попытаемся использовать это на практике, изменив немного реализацию Resource:
trait Resource[A]:
def allocate: (A, () -> Unit)
def use[B](f: A^ => B): B = ???
object Resource:
def apply[A](acquire: -> A)(release: A -> Unit): Resource[A] = ???
acquireиreleaseстали «чистыми функциями», т.е. мы может инициализировать и освобождать объекты когда угодно;useтеперь принимает замыкание типаA^ => B, что означает, что мы отслеживаем capability типа A, но возвращаемый типBот нее не зависит.
Попробовав использовать новую реализацию так, чтобы захватить экземпляр log после его освобождения мы получим ошибку компиляции:
def sum_error(x: Int, y: Int): Int =
val print = logger("log").use: log =>
(s: String) => log.printLine(s)
print(s"will sum x = $x and y = $y")
x + y
$> scala-cli run project.scala captured.scala
Compiling project (Scala 3.4.2, JVM (21))
[error] ./captured.scala:105:17
[error] local reference log leaks into
[error] outer capture set of type parameter B of method use
[error] val print = logger("log").use: log =>
[error]
Добавим аналогичные изменения в реализацию Manager:
@capability
final class Manager private[captured] ():
self =>
private var handles: List[() ->{self} Unit]^{self} = Nil
private[captured] def handle(f: () ->{self} Unit): Unit = ???
...
Нам потребовалось добавить аннотацию @capability, означающую, что экземпляры Manager всегда отслеживают capabilities и нам не надо писать Manager^, а также изменить тип замыкания для handle и handles на () ->{self} Unit, указав тем самым, что зарегистрированные функции по своим возможностям не могут превосходить Manager.
Также изменим сигнатуру функции defer, чтобы она указывала, что и любое переданное в нее замыкание не может пережить Manager:
def defer(using M: Manager)(f: ->{M} Unit): Unit = M.handle(() => f)
Фактически, изменение сигнатур методов — это всё, что нам потребовалось сделать, реализации остались точно такими же. Теперь, то тех пор пока мы используем log внутри блока manage все хорошо:
def sumN(n: Int): Seq[Int] = manage:
val log = acquire(logger("log"))
log.printLine(s"will sum $n ranges [i..$n] where i in [0..$n]")
(0 to n).map: i =>
log.printLine(s"will sum range [$i, $n]")
(i to n).foldLeft(0): (acc, i) =>
acc + i
def sumN_delay(n: Int): Seq[Int] = manage:
val print =
val log = acquire(logger("log"))
(s: String) => log.printLine(s)
print(s"will sum $n ranges [i..$n] where i in [0..$n]")
(0 to n).map: i =>
print(s"will sum range [$i, $n]")
(i to n).foldLeft(0): (acc, i) =>
acc + i
Однако как только мы попытаемся захватить печать в лог в функцию, использующуюся вне пределов блока manage, мы получим ошибку компиляции:
def sumN_error(n: Int): Seq[Int] =
val print = manage:
val log = acquire(logger("log"))
(s: String) => log.printLine(s)
print(s"will sum $n ranges [i..$n] where i in [0..$n]")
(0 to n).map: i =>
print(s"will sum range [$i, $n]")
(i to n).foldLeft(0): (acc, i) =>
acc + i
$> scala-cli run project.scala captured.scala
Compiling project (Scala 3.4.2, JVM (21))
[error] ./captured.scala:132:17
[error] local reference contextual$3 leaks into
[error] outer capture set of type parameter A of method manage
[error] val print = manage:
[error]
О дивный новый мир! Ура!
Разумеется, данная функциональность не зря идёт под флагом -experimental в компиляторе и требует отдельного импорта: синтаксис не совсем понятен, ошибки весьма загадочны и так далее. Однако есть надежда, что в скором времени она достаточно подрастёт для того, чтобы с ресурсами в Scala можно было работать еще удобнее и безопаснее.
Вместо заключения приводим ссылки на несколько реализаций средств управления ресурсами, которыми автор вдохновлялся в процессе подготовки этого материала:
Кстати! Мы ищем людей в нашу команду разработки компании "Криптонит". Переходите по ссылкам, откликайтесь на вакансии и делитесь ими с друзьями!