Как работает ленивое вычисление (lazy val)?

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

Синтаксис

lazy val x = {
println("Вычисление x")
42
}
println("Перед обращением к x")
println(x) // произойдёт вычисление
println(x) // уже не вычисляется, используется кэш

Вывод:

Перед обращением к x
Вычисление x
42
42

Принцип работы

  • lazy val реализуется как thread-safe (безопасный для многопоточности) механизм.

  • При первом обращении происходит вычисление и результат сохраняется.

  • При последующих обращениях возвращается уже сохранённое значение.

  • Значение вычисляется ровно один раз.

Применение lazy val

1. Отложенные тяжёлые вычисления

Если инициализация значения требует значительных затрат, но может не понадобиться, lazy помогает избежать лишней работы:

lazy val heavyComputation = computeSomethingBig()

2. Циклические зависимости

В случаях, когда один объект ссылается на другой, и есть обратная ссылка, lazy val помогает избежать NullPointerException или проблем с порядком инициализации:

class A {
lazy val b = new B(this)
}
class B(a: A)

3. Загрузка ресурсов по требованию

Если данные или файлы загружаются только при необходимости:

lazy val config: Map\[String, String\] = loadConfigFile("app.conf")

Поведение при ошибках

Если при первом вычислении lazy val произошла ошибка (например, исключение), значение не будет кэшироваться, и при следующем обращении попытка вычисления повторится:

lazy val failing = {
println("Попытка")
throw new RuntimeException("Ошибка")
}
try failing catch { case \_: Throwable => () }
try failing catch { case \_: Throwable => () }

Вывод:

Попытка
Попытка

Это отличается от поведения обычных val, которые инициализируются при создании и могут упасть немедленно.

Отличия от val

val lazy val
Инициализация Немедленно При первом обращении
--- --- ---
Вычисление Всегда выполняется Только при необходимости
--- --- ---
Кэш Да Да
--- --- ---
Потокобезопасность Нет гарантии Гарантирована (с lock'ом)
--- --- ---
Подходит для тяжёлых вычислений Нет Да
--- --- ---

Потокобезопасность и производительность

  • В Scala реализация lazy val использует блокировку (synchronized) для обеспечения безопасности при первом доступе из нескольких потоков.

  • Это делает lazy val безопасным, но дорогим по сравнению с обычным val.

  • Если многопоточность не важна, можно использовать сторонние оптимизации (@volatile var, memoization, java.util.concurrent и др.).

Ограничения

  • Нельзя использовать lazy val в качестве параметра конструктора.

  • Нельзя использовать lazy val внутри case class для сравнения (equals/hashCode), т.к. val вычисляется немедленно, а lazy val — отложено.

Примеры использования

Пример 1: отложенная инициализация ресурса

class Database {
lazy val connection = {
println("Устанавливаем соединение с БД")
new Connection("url", "user", "pass")
}
}

Пример 2: кэширование результата

def expensive(): Int = {
println("Считаем")
100
}
lazy val cached = expensive()

Пример 3: ленивое чтение из файла

lazy val content: String = scala.io.Source.fromFile("big.txt").mkString

Техническая реализация

Под капотом lazy val реализован через двойную проверку блокировки (double-checked locking). При первом обращении:

  1. Проверяется, инициализировано ли значение.

  2. Если нет — поток синхронизируется.

  3. Внутри критической секции проверка повторяется.

  4. Производится инициализация и сохраняется результат.

Совместное использование с def и val

Ключевое слово Описание
val Немедленно вычисляется, одно значение
--- ---
def Функция, вызывается каждый раз заново
--- ---
lazy val Вычисляется один раз, при первом обращении
--- ---

Пример:

val now1 = System.currentTimeMillis()
def now2 = System.currentTimeMillis()
lazy val now3 = System.currentTimeMillis()
println(now1)
println(now2)
println(now3)
Thread.sleep(1000)
println(now1)
println(now2)
println(now3)

Вывод покажет разницу во времени между val, def и lazy val.