Ленивые вычисления

Haskell ленив: он не вычисляет значение, пока оно реально не понадобится. До этого момента оно живёт как «обещание посчитать» — thunk.
Можно взять первые три элемента бесконечного списка — и программа не зависнет. Она посчитает ровно три, потому что больше не спросили.

Большинство языков энергичны (eager): аргумент вычисляется до вызова функции. Haskell — ленив: выражение вычисляется только тогда, когда его результат действительно требуется. До этого оно хранится как отложенное вычисление — thunk (по-русски иногда «отложенка»).

let x = expensive 100   -- НЕ вычисляется сразу
                         -- x = thunk: «обещание посчитать expensive 100»

print x                  -- ВОТ здесь thunk вынуждается
                         -- и считается ровно один раз

Бесконечные структуры

Самое яркое следствие лени — бесконечные списки. Они описывают «как продолжать», а считается лишь нужный кусок:

nats     = [1 ..]              -- 1,2,3,... бесконечно
firstFive = take 5 nats        -- [1,2,3,4,5] — посчитано только 5

ones = 1 : ones                -- бесконечный список единиц
take 3 ones                    -- [1,1,1]

Поскольку take 5 запрашивает только пять элементов, остальной бесконечный список так и остаётся неподсчитанным. Программа не зацикливается.

Считается только нужное

Лень означает, что неиспользованные части выражения вообще не вычисляются:

pick :: Bool -> a -> a -> a
pick True  a _ = a       -- второй аргумент не тронут
pick False _ b = b       -- первый аргумент не тронут

-- pick True 5 (div 1 0)  -> 5, а не ошибка деления!
-- ветка с делением на ноль просто не вычисляется

В Python вычисления энергичные, но ленивость отлично моделируется генераторами: значения производятся по запросу, а не все сразу.

# Та же идея на Python: ленивость через генераторы
from itertools import count, islice

# count() — бесконечная последовательность 1,2,3,...
nats = count(1)
first_five = list(islice(nats, 5))   # считаем только 5
print(first_five)                    # [1, 2, 3, 4, 5]

# генератор бесконечен, но считается по требованию
def ones():
    while True:
        yield 1

print(list(islice(ones(), 3)))       # [1, 1, 1]

Платить только за нужное

Ленивость — пожалуй, самая необычная черта Haskell для тех, кто пришёл из энергичных языков, и одновременно одна из самых мощных. Она позволяет описывать потенциально бесконечные структуры — натуральный ряд, поток простых чисел, повторяющийся узор — а вычислять ровно тот кусок, который реально запросили. Это даёт красивую композицию «генерируй бесконечно, бери сколько надо»: определение и потребление данных разъезжаются, и каждое можно писать независимо. Но у медали есть оборотная сторона: на больших агрегациях ленивые отложенные вычисления способны незаметно накапливаться и съедать память. Поэтому для числовых свёрток берут строгие версии вроде foldl', которые сразу считают промежуточный результат, а не копят цепочку обещаний. Понимание, когда лень помогает, а когда стоит подтолкнуть вычисление, — признак зрелого хаскелиста.

Как это мыслить

Представляйте, что Haskell «откладывает работу до последнего». Значение — это не «уже посчитанное число», а «рецепт, который посчитают, когда спросят». Это позволяет описывать потенциально бесконечные данные и платить только за ту часть, что реально используется.

Стоит добавить, что ленивость влияет и на то, как проектируют функции в Haskell. Поскольку аргумент не вычисляется, пока он не понадобится, можно безопасно передавать в функцию выражения, которые в некоторых ветках вообще не будут затронуты, — и не платить за их вычисление. Это поощряет модульность: вы спокойно разделяете «генерацию данных» и «их потребление», зная, что произведётся ровно столько, сколько потребит потребитель. Классический приём — описать бесконечный поток возможных решений и лениво брать из него первое подходящее. Энергичный язык такого не позволит: он попытался бы вычислить весь поток заранее и зависнет. Ленивость же превращает «бесконечность» в обычный, удобный инструмент, за который платишь лишь по факту использования.

Частые ошибки

  • Накопление thunk'ов. При свёртке больших данных ленивые «отложенки» могут копиться и съедать память; помогает строгая свёртка foldl'.
  • Ждать побочных эффектов «по порядку». В чистом коде порядок вычислений определяет надобность, а не текст программы.
  • Печатать бесконечный список целиком. print [1..] не остановится — обрезайте через take.

Best practices

  • Пользуйтесь бесконечными списками и take — это идиоматично и элегантно.
  • Для больших агрегирующих свёрток предпочитайте строгие версии (foldl', sum и т. п.).
  • Помните: лень даёт композицию «генерируй бесконечно — бери сколько надо» почти бесплатно.

Итог. Ленивость означает «вычисляй по требованию»: выражения хранятся как thunks и считаются лишь при необходимости. Это даёт бесконечные структуры и экономию вычислений, но требует осторожности с накоплением отложенок на больших данных.

Проверьте себя
1. Что такое thunk в Haskell?
AТип ошибки
BОтложенное вычисление — «обещание посчитать значение, когда оно понадобится»
CБесконечный цикл
DСпособ объявления типа
2. Почему take 5 [1..] не зависает, хотя [1..] бесконечен?
AПотому что Haskell ограничивает списки 1000 элементами
BПотому что благодаря ленивости вычисляются только запрошенные 5 элементов
CПотому что [1..] на самом деле конечен
DПотому что take игнорирует список