Вы когда-нибудь задумывались о том, как выделяется память для переменных, и в какой конкретно момент она очищается? Как сборщик мусора «решает», что переменная уже не нужна и можно ли как-то повлиять на его решение?
В новой статье директор департамента разработки компании «Криптонит» Алексей Шуксто рассказал об интересных особенностях управления жизненным циклом объектов в 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 можно было работать еще удобнее и безопаснее.
Вместо заключения приводим ссылки на несколько реализаций средств управления ресурсами, которыми автор вдохновлялся в процессе подготовки этого материала:
Кстати! Мы ищем людей в нашу команду разработки компании "Криптонит". Переходите по ссылкам, откликайтесь на вакансии и делитесь ими с друзьями!