Редукция выражений

В этой главе мы поговорим о том как вычисляются программы. В самом начале мы говорили о том, что процесса вычисления значений нет. В том смысле, что у нас нет новых значений, у нас ничего не меняется, мы лишь расшифровываем синонимы значений.

Вкратце вспомним то, что мы уже знаем о вычислениях. Сначала мы с помощью типов определяем множество всех возможных значений. Значения – это деревья в узлах которых записаны конструкторы, которые мы определяем в типах. Так например мы можем определить тип:

data Nat = Zero | Succ Nat

Этим типом мы определяем множество допустимых значений. В данном случае это цепочки конструкторов Succ, которые заканчиваются конструктором Zero:

Zero, Succ Zero, Succ (Succ Zero), ...

Затем начинаем давать им новые имена, создавая константы (простые имена-синонимы)

zero    = Zero
one     = Succ zero
two     = Succ one

и функции (составные имена-синонимы):

foldNat :: a -> (a -> a) -> Nat -> a
foldNat z  s  Zero      = z
foldNat z  s  (Succ n)  = s (foldNat z s n)

add a = foldNat a   Succ
mul a = foldNat one (add a) 

Затем мы передаём нашу программу на проверку компилятору. Мы просим у него проверить не создаём ли мы случайно какие-нибудь бессмысленные выражения. Бессмысленные потому, что они пытаются создать значение, которое не вписывается в наши типы. Например если мы где-нибудь попробуем составить выражение:

add Zero mul

Компилятор напомнит нам о том, что мы пытаемся подставить функцию mul на место обычного значения типа Nat. Тогда мы исправим выражение на:

add Zero two

Компилятор согласится. И передаст выражение вычислителю. И тут мы говорили, что вычислитель начинает проводить расшифровку нашего описания. Он подставляет на место синонимов их определения, правые части из уравнений. Этот процесс мы называли редукцией. Вычислитель видит два синонима и одно значение. С какого синонима начать? С add или two?

Стратегии вычислений

Этот вопрос приводит нас к понятию стратегии вычислений. Поскольку вычисляем мы только константы, то наше выражение также можно представить в виде дерева. Только теперь у нас в узлах записаны не только конструкторы, но и синонимы. Процесс редукции можно представить как процесс очистки такого дерева от синонимов. Посмотрим на дерево нашего значения:

Оказывается у нас есть две возможности очистки синонимов.

Cнизу-вверх

начинаем с листьев и убираем все синонимы в листьях дерева выражения. Как только в данном узле и всех дочерних узлах остались одни конструкторы можно переходить на уровень выше. Так мы поднимаемся выше и выше пока не дойдём до корня.

Cверху-вниз

начинаем с корня, самого внешнего синонима и заменяем его на определение (с помощью уравнения на правую часть от знака равно), если на верху снова окажется синоним, мы опять заменим его на определение и так пока на верху не появится конструктор, тогда мы спустимся в дочерние деревья и будем повторять эту процедуру пока не дойдём до листьев дерева.

Посмотрим как каждая из стратегий будет редуцировать наше выражение. Начнём со стратегии от листьев к корню (снизу-вверх):

        add Zero two                    
-- видим два синонима add и two 
-- раскрываем two, ведь он находится ниже всех синонимов
=>      add Zero (Succ one)    
-- ниже появился ещё один синоним, раскроем и его
=>      add Zero (Succ (Succ zero))    
-- появился синоним zero раскроем его
=>      add Zero (Succ (Suсс Zero))
-- все узлы ниже содержат конструкторы, поднимаемся вверх до синонима
-- заменяем add на его правую часть
=>      foldNat Succ Zero (Succ (Succ Zero))  
-- самый нижний синоним foldNat, раскроем его
-- сопоставление с образцом проходит во втором уравнении для foldNat
=>      Succ (foldNat Succ Zero (Succ Zero))
-- снова раскрываем foldNat
=>      Succ (Succ (foldNat Zero Zero))
-- снова раскрываем foldNat, но на этот раз нам подходит
-- первое уравнение из определения foldNat
=>      Succ (Succ Zero)
-- синонимов больше нет можно вернуть значение
-- результат:
        Succ (Succ Zero)

В этой стратегии для каждой функции мы сначала вычисляем до конца все аргументы, потом подставляем расшифрованные значения в определение функции.

Теперь посмотрим на вычисление от корня к листьям (сверху-вниз):

        add Zero two
-- видим два синонима add и two, начинаем с того, что ближе всех к корню
=>      foldNat Succ Zero two
-- теперь выше всех foldNat, раскроем его

Но для того чтобы раскрыть foldNat нам нужно узнать какое уравнение выбрать для этого нам нужно понять какой конструктор находится в корне у второго аргумента, если это Zero, то мы выберем первое уравнение, а если это Succ, то второе:

-- в уравнении для foldNat видим декомпозицию по второму 
-- аргументу. Узнаем какой конструктор в корне у two
=>      foldNat Succ Zero (Succ one)
-- Это Succ нам нужно второе уравнение:
=>      Succ (foldNat Succ Zero one)
-- В корне м ыполучили конструктор, можем спуститься ниже.
-- Там мы видим foldNat, для того чтобы раскрыть его нам
-- снова нужно понять какой конструктор в корне у второго аргумента:
=>      Succ (foldNat Succ Zero (Succ zero))
-- Это опять Succ переходим ко второму уравнению для foldNat
=>      Succ (Succ (foldNat Succ Zero zero))
-- Снова раскрываем второй аргумент у foldNat
=>      Succ (Succ (foldNat Succ Zero Zero))
-- Ага это Zero, выбираем первое уравнение
=>      Succ (Succ Zero)
-- Синонимов больше нет можно вернуть значение
-- результат:
        Succ (Succ Zero)

В этой стратегии мы всегда раскрываем самый верхний уровень выражения, можно представить как мы вытягиваем конструкторы от корня по цепочке. У этих стратегий есть специальные имена:

Отметим, что стратегию вычисления по значению также принято называть энергичными вычислениями (eqger evaluation) или аппликативной (applicative) стратегией редукции. Вычисление по имени также принято называть нормальной (normal) стратегией редукции.

Преимущества и недостатки стратегий

В чём преимущества, той и другой стратегии.

Если выражение вычисляется полностью, первая стратегия более эффективна по расходу памяти.

Вычисляется полностью означает все компоненты выражения участвуют в вычислении. Например то выражении, которое мы рассмотрели так подробно, вычисляется полностью. Приведём пример выражения, при вычислении которого нужна лишь часть аргументов, для этого определим функцию:

isZero :: Nat -> Bool
isZero Zero     = True
isZero _        = False

Она проверяет является ли нулём данное число, теперь представим как будет вычисляться выражение, в той и другой стратегии:

isZero (add Zero two)

Первая стратегия сначала вычислит все аргументы у add потом расшифрует add и только в самом конце доберётся до isZero. На это уйдёт восемь шагов (семь на вычисление add Zero two). В то время как вторая стратегия начнёт с isZero. Для вычисления isZero ей потребуется узнать какой конструктор в корне у выражения add Zero two. Она узнает это за два шага. Итого три шага. Налицо экономия усилий.

Почему вторая стратегия экономит память? Поскольку мы всегда вычисляем аргументы функции, мы можем не хранить описания в памяти а сразу при подстановке в функцию начинать редукцию. Эту ситуацию можно понять на таком примере, посчитаем сумму чисел от одного до четырёх с помощью такой функции:

sum :: Int -> [Int] -> Int
sum []      res = res
sum (x:xs)  res = sum xs (res + x) 

Посмотрим на то как вычисляет первая стратегия, с учётом того что мы вычисляем значения при подстановке:

        sum [1,2,3,4] 0
=>      sum [2,3,4]   (0 + 1)    
=>      sum [2,3,4]   1
=>      sum [3,4]     (1 + 2)
=>      sum [3,4]     3
=>      sum [4]       (3+3)
=>      sum [4]       6
=>      sum []        (6+4)
=>      sum []        10
=>      10

Теперь посмотрим на вторую стратегию:

        sum [1,2,3,4] 0
=>      sum [2,3,4]   0+1
=>      sum [3,4]     (0+1)+2
=>      sum [4]       ((0+1)+2)+3
=>      sum []        (((0+1)+2)+3)+4
=>      (((0+1)+2)+3)+4
=>      ((1+2)+3)+4
=>      (3+3)+4
=>      6+4
=>      10

А теперь представьте, что мы решили посчитать сумму чисел от 1 до миллиона. Сколько вычислений нам придётся накопить! В этом недостаток второй стратегии. Но есть и ещё один недостаток, рассмотрим выражение:

(\x -> add (add x x) x) (add Zero two)

Первая стратегия сначала редуцирует выражение add Zero two в то время как вторая подставит это выражение в функцию и утроит свою работу!

Но у второй стратегии есть одно очень веское преимущество, она может вычислять больше выражений чем вторая. Определим значение бесконечность:

infinity    :: Nat
infinity    = Succ infinity

Это рекурсивное определение, если мы попытаемся его распечатать мы получим бесконечную последовательность Succ. Чем не бесконечность? Теперь посмотрим на выражение:

isZero infinity

Первая стратегия захлебнётся, вычисляя аргумент функции isZero, в то время как вторая найдёт решение за два шага.

Подведём итоги. Плюсы вычисления по значению:

Плюсы вычисления по имени:

Какую из них выбрать? В Haskell пошли по второму пути. Всё-таки преимущество выразительности языка оказалось самым существенным. Но для того чтобы избежать недостатков стратегии вычисления по имени оно было модифицировано. Давайте посмотрим как.

Вычисление по необходимости

Вернёмся к выражению:

(\x -> add (add x x) x) (add Zero two)

Нам нужно как-то рассказать функции о том, что имя x в её теле указывает на одно и то же значение. И если в одном из x значение будет вычислено переиспользовать эти результаты в других x. Вместо значения мы будем передавать в функцию ссылку на область памяти, которая содержит рецепт получения этого значения. Напомню, что мы по-прежнему вычисляем значение сверху вниз, сейчас мы просто хотим избавиться от проблемы дублирования. Вернитесь к примеру с вычислением по имени и просмотрите его ещё раз. Обратите внимание на то, что значения вычислялись лишь при сопоставлении с образцом. Мы вычисляем верхний конструктор аргумента лишь для того, чтобы понять какое уравнение для foldNat выбрать. Теперь мы будем хранить ссылку на (add Zero two) в памяти и как только, внешняя функция запросит верхний конструктор мы обновим значение в памяти новым вычисленным до корневого конструктора значением. Если в любом другом месте функции мы вновь обратимся к значению, мы не будем его перевычислять, а сразу вернём конструктор. Посмотрим как это происходит на примере:

--  выражение                               | память:
--------------------------------------------|-------------------------
    (\x -> add (add x x) x) M               | M = (add Zero two)
-- подставим ссылку в тело функции          |
=>  add (add M M) M                         |
-- раскроем самый верхний синоним           |
=>  foldNat (add M M) Succ M                |
-- для foldNat узнаем верхний конструктор   |
-- последнего аргумента (пропуская          |
-- промежуточные шаги, такие же как выше)   |
=>                                          | M  = Succ M1
                                            | M1 = foldNat Succ Zero one
-- по M выбираем второе уравнение           |
=> Succ (foldNat (add M M) Succ M1)         |
-- запросим следующий верхний конструктор:  |
=>                                          | M  = Succ M1
                                            | M1 = Succ M2
                                            | M2 = foldNat Succ Zero zero
-- по M1 выбираем второе уравнение          |
=> Succ (Succ (foldNat (add M M) Succ M2))  | 
-- теперь для определения уравнения foldNat |
-- раскроем M2                              |
=>                                          | M  = Succ M1
                                            | M1 = Succ M2
                                            | M2 = Zero
-- выбираем первое уравнение для foldNat:   |
=> Succ (Succ (add M M))                    |
-- раскрываем самый верхний синоним:        |
=> Succ (Succ (foldNat M Succ M))           |
-- теперь, поскольку M уже вычислялось, в   |
-- памяти уже записан верхний конструктор,  |
-- мы знаем, что это Succ и выбираем второе |
-- уравнение:                               |
=> Succ (Succ (Succ (foldNat M Succ M1)))   |
-- и M1 тоже уже вычислялось, сразу         |
-- выбираем второе уравнение                |----+
=> Succ (Succ (Succ (Succ (foldNat M Succ M2)))) |
-- M2 вычислено, идём на первое уравнение   |----+
=> Succ (Succ (Succ (Succ (Succ M))))       |
-- далее остаётся только подставить уже     |
-- вычисленные значения M                   |
-- и вернуть значение.                      |

Итак подставляется не значение а ссылка на него, вычисленная часть значения используется сразу в нескольких местах. Эта стратегия редукции называется вычислением по необходимости (call by need) или ленивой стратегией вычислений (lazy evaluation).

Теперь немного терминологии. Значение может находится в четырёх состояниях:

Вы могли понаблюдать за значением в первых трёх состояниях на примере выше. Но что такое $\bot$? Вспомним определение для функции извлечения головы списка head:

head :: [a] -> a
head (a:_)  = a
head []     = error "error: empty list" 

Второе уравнение возвращает $\bot$. У нас есть две функции, которые возвращают это “значение”:

undefined   :: a
error       :: String -> a

Первая – это $\bot$ в чистом виде, а вторая не только возвращает неопределённое значение, но и приводит к выводу на экран сообщения об ошибке. Обратите внимание на тип этих функций, результат может быть значением любого типа. Это наблюдение приводит нас к ещё одной тонкости. Когда мы определяем тип:

data Bool       = False | True
data Maybe a    = Nothing | Just a

На самом деле мы пишем:

data Bool       = undefined | False | True
data Maybe a    = undefined | Nothing | Just a

Компилятор автоматически прибавляет ещё одно значение к любому определённому пользователем типу. Такие типы называют поднятыми (lifted type). А значения таких типов принято называть запакованными (boxed). Не запакованное (unboxed) значение – это простое примитивное значение. Например целое или действительное число в том виде, в котором оно хранится на компьютере. В Haskell даже числа “запакованы”. Поскольку нам необходимо, чтобы undefined могло возвращать в том числе и значение типа Int:

data Int = undefined  | I# Int# 

Тип Int# – это низкоуровневое представление ограниченного целого числа. Принято писать не запакованные типы с решёткой на конце. I# – это конструктор. Нам приходится запаковывать значения ещё и потому, что значение может принимать несколько состояний (в зависимости от того, насколько оно вычислено), всё это ведёт к тому, что у нас хранится не просто значение, а значение с какой-то дополнительной информацией, которая зависит от конкретной реализации языка Haskell.

Мы решили проблему дублирования вычислений, но наше решение усугубило проблему расхода памяти. Ведь теперь мы храним не просто значения, но ещё и дополнительную информацию, которая отвечает за проведение вычислений. Эта проблема может проявляться в очень простых задачах. Например попробуем вычислить сумму чисел от одного до миллиарда:

sum [1 .. 1e9]
<interactive>: out of memory (requested 2097152 bytes)

Интуитивно кажется, что для решения этой задачи нам нужно лишь две ячейки памяти. В одной мы будем постоянно прибавлять к значению единицу, пока не дойдём до миллиарда, так мы последовательно будем получать элементы списка, а в другой мы будем хранить значение суммы. Мы начнём с нуля и будем прибавлять значения первой ячейки. У ленивой стратегии другое мнение на этот счёт. Если вы вернётесь к примеру выше, то заметите, что sum копит отложенные выражения до самого последнего момента. Поскольку память ограничена, такой момент не наступает. Как нам быть? В Haskell по умолчанию все вычисления проводятся по необходимости, но предусмотрены и средства для имитации вычисления по значению. Давайте посмотрим на них.

Аннотации строгости

Языки с ленивой стратегией вычислений называют не строгими (non-strict), а языки с энергичной стратегией вычислений соответственно~– строгими.

Принуждение к СЗНФ с помощью seq

Мы говорили о том, что при вычислении по имени значения вычисляются только при сопоставлении с образцом или в case-выражениях. Есть специальная функция seq, которая форсирует приведение к СЗНФ:

seq :: a -> b -> b

Она принимает два аргумента, при выполнении функции первый аргумент приводится к СЗНФ и затем возвращается второй. Вернёмся к примеру с sum. Привести к СЗНФ число – означает вычислить его полностью. Определим функцию sum', которая перед рекурсивным вызовом вычисляет промежуточный результат:

sum' :: Num a => [a] -> a
sum' = iter 0 
    where iter res []        = res
          iter res (a:as)    = let res' = res + a
                               in  res' `seq` iter res' as 

Сохраним результат в отдельном модуле Strict.hs и попробуем теперь вычислить значение, придётся подождать:

Strict> sum' [1 .. 1e9]

И мы ждём, и ждём, и ждём. Но переполнения памяти не происходит. Это хорошо. Но давайте прервём вычисления. Нажмём ctrl+c. Функция sum' вычисляется, но вычисляется очень медленно. Мы можем существенно ускорить её, если скомпилируем модуль Strict. Для компиляции модуля переключимся в его текущую директорию и вызовем компилятор ghc с флагом --make:

ghc --make Strict

Появились два файла Strict.hi и Strict.o. Теперь мы можем загрузить модуль Strict в интерпретатор и сравнить выполнение двух функций:

Strict> sum' [1 .. 1e6]
5.000005e11
(0.00 secs, 89133484 bytes)
Strict> sum [1 .. 1e6]
5.000005e11
(0.57 secs, 142563064 bytes)

Обратите внимание на прирост скорости. Умение понимать в каких случаях стоит ограничить лень очень важно. И в программах на Haskell тоже. Также компилировать модули можно из интерпретатора. Для этого воспользуемся командой :!, она выполняет системные команды в интерпретаторе ghci:

Strict> :! ghc --make Strict
[1 of 1] Compiling Strict           ( Strict.hs, Strict.o )

Отметим наличие специальной функции применения, которая просит перед применением привести аргумент к СЗНФ, эта функция определена в Prelude:

($!) :: (a -> b) -> a -> b
 f $! a = a `seq` f a

С этой функцией мы можем определить функцию sum так:

sum' :: Num a => [a] -> a
sum' = iter 0 
    where iter res []        = res
          iter res (a:as)    = flip iter as $! res + a

Функции с хвостовой рекурсией

Определим функцию, которая не будет лениться при вычислении произведения чисел, мы назовём её product':

product' :: Num a => [a] -> a
product' = iter 1
    where iter res []        = res
          iter res (a:as)    = let res' = res * a
                               in  res' `seq` iter res' as 

Смотрите функция sum изменилась лишь в двух местах. Это говорит о том, что пора задуматься о том, а нет ли такой общей функции, которая включает в себя и то и другое поведение. Такая функция есть и называется она foldl', вот её определение:

foldl' :: (a -> b -> a) -> a -> [b] -> a
foldl' op init = iter init
    where iter res []        = res
          iter res (a:as)    = let res' = res `op` a
                               in  res' `seq` iter res' as 

Мы вынесли в аргументы функции бинарную операцию и начальное значение. Всё остальное осталось прежним. Эта функция живёт в модуле Data.List. Теперь мы можем определить функции sum' и prod':

sum'        = foldl' (+) 0
product'    = foldl' (*) 1

Также в Prelude определена функция foldl. Она накапливает значения в аргументе, но без принуждения вычислять промежуточные результаты:

foldl :: (a -> b -> a) -> a -> [b] -> a
foldl op init = iter init
    where iter res []        = res
          iter res (a:as)    = iter (res `op` a) as 

Такая функция называется функцией с хвостовой рекурсией (tail-recursive function). Рекурсия хвостовая тогда, когда рекурсивный вызов функции является последним действием, которое выполняется в функции. Посмотрите на второе уравнение функции iter. Мы вызываем функцию iter рекурсивно последним делом. В языках с вычислением по значению часто хвостовая рекурсия имеет преимущество за счёт экономии памяти (тот момент который мы обсуждали в самом начале). Но как видно из этого раздела в ленивых языках это не так. Библиотечная функция sum будет накапливать выражения перед вычислением с риском исчерпать всю доступную память, потому что она определена через foldl.

Тонкости применения seq

Хочу подчеркнуть, что функция seq не вычисляет свой первый аргумент полностью. Первый аргумент не приводится к нормальной форме. Мы лишь просим вычислитель узнать какой конструктор находится в корне у данного выражения. Например в выражении isZero $! infinity знак $! ничем не отличается от простого применения мы и так будем приводить аргумент infinity к СЗНФ, когда нам понадобится узнать какое из уравнений для isZero выбрать, ведь в аргументе функции есть сопоставление с образцом.

Посмотрим на один типичный пример. Вычисление среднего для списка чисел. Среднее равно сумме всех элементов списка, разделённой на длину списка. Для того чтобы вычислить значение за один проход мы будем одновременно вычислять и сумму элементов и значение длины. Также мы понимаем, что нам не нужно откладывать вычисления, воспользуемся функцией foldl':

mean :: [Double] -> Double
mean = division . foldl' count (0, 0)
    where count  (sum, leng) a = (sum+a, leng+1)
          division (sum, leng) = sum / fromIntegral leng

Проходим по списку, копим сумму в первом элементе пары и длину во втором. В самом конце делим первый элемент на второй. Обратите внимание на функцию fromIntegral она преобразует значения из целых чисел, в какие-нибудь другие из класса Num. Сохраним это определение в модуле Strict скомпилируем модуль и загрузим в интерпретатор, не забудьте импортировать модуль Data.List, он нужен для функции foldl'. Посмотрим, что у нас получилось:

Prelude Strict> mean [1 .. 1e7]
5000000.5
(49.65 secs, 2476557164 bytes)

Получилось очень медленно, странно ведь порядок этой функции должен быть таким же что и у sum'. Посмотрим на скорость sum':

Prelude Strict> sum' [1 .. 1e7]
5.0000005e13
(0.50 secs, 881855740 bytes)

В 100 раз быстрее. Теперь представьте, что у нас 10 таких функций как mean они разбросаны по всему коду и делают своё чёрное ленивое дело. Причина такого поведения кроется в том, что мы опять завернули значение в другой тип, на этот раз в пару. Когда вычислитель дойдёт до seq, он остановится на выражении (thunk, thunk) вместо двух чисел. Он вновь будет накапливать отложенные вычисления, а не значения.

Перепишем mean, теперь мы будем вычислять значения пары по отдельности и попросим вычислитель привести к СЗНФ каждое из них перед вычислением итогового значения:

mean' :: [Double] -> Double
mean' = division . iter (0, 0)
    where iter res          []      = res
          iter (sum, leng)  (a:as)  = 
                let s = sum  + a
                    l = leng + 1
                in  s `seq` l `seq` iter (s, l) as
          
          division (sum, leng) = sum / fromIntegral leng

Такой вот монстр. Функция seq право ассоциативна поэтому скобки будут группироваться в нужном порядке. В этом определении мы просим вычислитель привести к СЗНФ числа, а не пары чисел, как в прошлой версии. Для чисел СЗНФ совпадает с НФ, и всё должно пройти гладко, но сохраним это определение и проверим результат:

Prelude Strict> :! ghc --make Strict
[1 of 1] Compiling Strict           ( Strict.hs, Strict.o )
Prelude Strict> :load Strict
Ok, modules loaded: Strict.
(0.00 secs, 0 bytes)
Prelude Strict> mean' [1 .. 1e7]
5000000.5
(0.65 secs, 1083157384 bytes)

Получилось! Скорость чуть хуже чем у sum', но не в сто раз.

Энергичные образцы

В GHC предусмотрены специальные обозначения для принудительного приведения выражения к СЗНФ. Они не входят в стандарт языка Haskell, поэтому для того, чтобы воспользоваться ими, нам необходимо подключить их. Расширения подключаются с помощью специального комментария в самом начале модуля:

{-# LANGUAGE BangPatterns #-}

Эта запись активирует расширение языка с именем BangPatterns. Ядро языка Haskell фиксировано стандартом, но каждый разработчик компилятора может вносить свои дополнения. Они подключаются через директиву LANGUAGE:

{-# LANGUAGE 
        Расширение1, 
        Расширение2, 
        Расширение3 #-}

Мы заключаем директиву в специальные комментарии с решёткой, говорим LANGUAGE а затем через запятую перечисляем имена расширений, которые нам понадобятся. Расширения активны только в рамках данного модуля. Например если мы импортируем функции из модуля, в котором включены расширения, то эти расширения не распространяются дальше на другие модули. Такие комментарии с решёткой называют прагмами (pragma).

Нас интересует расширение BangPatterns (bang – восклицательный знак, вы сейчас поймёте почему оно так называется). Посмотрим на функцию, которая использует энергичные образцы:

iter (!sum, !leng) a = (step + a, leng + 1)

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

Вычислитель говорит ладно-ладно сделаю. А там числа! И получается, что они не накапливаются. С помощью энергичных образцов мы можем переписать функцию mean' через foldl', а не выписывать её целиком:

mean'' :: [Double] -> Double
mean'' = division . foldl' iter (0, 0)
    where iter (!sum, !leng) a = (sum  + a, leng + 1)
          division (sum, leng) = sum / fromIntegral leng

Проверим в интерпретаторе

*Strict> :! ghc --make Strict
[1 of 1] Compiling Strict           ( Strict.hs, Strict.o )
*Strict> :l Strict
Ok, modules loaded: Strict.
(0.00 secs, 581304 bytes)
Prelude Strict> mean'' [1 .. 1e7]
5000000.5
(0.78 secs, 1412862488 bytes)
Prelude Strict> mean' [1 .. 1e7]
5000000.5
(0.65 secs, 1082640204 bytes)

Функция работает чуть медленнее, чем исходная версия, но не сильно.

Энергичные типы данных

Расширение BangPatterns позволяет указывать какие значения привести к СЗНФ не только в образцах, но и в типах данных. Мы можем создать тип:

data P a b = P !a !b

Этот тип обозначает пару, элементы которой обязаны находиться в СЗНФ. Теперь мы можем написать ещё один вариант функции поиска среднего:

mean''' :: [Double] -> Double
mean''' = division . foldl' iter (P 0 0)
    where iter (P sum leng) a = P (sum  + a) (leng + 1)
          division (P sum leng) = sum / fromIntegral leng

Пример ленивых вычислений

У вас может сложиться ошибочное представление, что ленивые вычисления созданы только для того, чтобы с ними бороться. Пока мы рассматривали лишь недостатки, вскользь упомянув о преимуществе выразительности. Ленивые вычисления могут и экономить память! Мы можем строить огромные промежуточные данные, обрабатывать их разными способами при условии, что в конце программы нам потребуется лишь часть этих данных или конечный алгоритм будет накапливать определённую статистику.

Рассмотрим такое выражение:

let longList = produce x
in  sum' $ filter p $ map f longList 

Функция produce строит огромный список промежуточных данных. Далее мы преобразуем эти данные функцией f и фильтруем их предикатом p. Всё это делается для того, чтобы посчитать сумму всех элементов в списке. Посмотрим как повела бы себя в такой ситуации энергичная стратегия вычислений. Сначала был бы вычислен список longList, причём полностью. Затем все элементы были бы преобразованы функцией f. У нас в памяти уже два огромных списка. Теперь мы фильтруем весь список и в самом конце суммируем. Было бы очень плохо заставлять энергичный вычислитель редуцировать такое выражение.

А в это время ленивый вычислитель поступит так. Сначала всё выражение будет сохранено в виде описания, затем он скажет разверну сначала sum', эта функция запросит первый элемент списка, что приведёт к вызову filter. Фильтр будет запрашивать следующий элемент списка у подчинённых ему функций до тех пор, пока предикат p не вернёт True на одном из них. Всё это время функция map будет вытягивать из produce по одному элементу. Причём память, выделенная на промежуточные не нужные значения (на них p вернул False) будет переиспользована. Как только sum' прибавит первый элемент, она запросит следующий, проснётся фильтр и так далее. Вся функция будет работать в постоянном ограниченном объёме памяти, который не зависит от величины списка longList!

Примерам ленивых вычислений будет посвящена отдельная глава, а пока приведём один пример. Найдём корень уравнения с помощью метода неподвижной точки. У нас есть функция f :: a -> a, и нам нужно найти решение уравнения:

f x = x

Можно начать с какого-нибудь стартового значения, и подставлять, подставлять, подставлять его в f до тех пор, пока значение не перестанет изменяться. Так мы найдём решение.

x1 = f x0
x2 = f x1
x3 = f x2
...
до тех пор пока abs (x[N] - x[N-1]) <= eps

Первое наблюдение: функция принимает не произвольные значения, а те для которых имеет смысл операции: минус, поиск абсолютного значения и сравнение на больше/меньше. Тип нашей функции:

f :: (Ord a, Num a) => a -> a

Ленивые вычисления позволяют нам отделить шаг генерации решений, от шага проверки сходимости. Сначала мы сделаем список всех подстановок функции f, а затем найдём в этом списке два соседних элемента расстояние между которыми достаточно мало. Итак первый шаг, генерируем всю последовательность:

xNs = iterate f x0

Мы воспользовались стандартной функцией iterate из Prelude. Теперь ищем два соседних числа:

converge :: (Ord a, Num a) => a -> [a] -> a
converge eps (a:b:xs) 
    | abs (a - b) <= eps    = a
    | otherwise             = converge eps (b:xs)

Поскольку список бесконечный мы можем не проверять случаи для пустого списка. Итоговое решение:

roots :: (Ord a, Num a) => a -> a -> (a -> a) -> a
roots eps x0 f = converge eps $ iterate f x0

За счёт ленивых вычислений функции converge и iterate работают синхронно. Функция converge запрашивает новое значение и iterate передаёт его, но только одно! Найдём решение какого-нибудь уравнения. Запустим интерпретатор. Мы ленимся и не создаём новый модуль для такой “большой” функции. Определяем её сразу в интерпретаторе.

Prelude> let converge eps (a:b:xs) = if abs (a-b)<=eps then a else converge eps (b:xs)
Prelude> let roots eps x0 f = converge eps $ iterate f x0

Найдём корень уравнения:

$$x (x-2) = 0$$

$$x^2 - 2 x = 0$$

$$\frac{1}{2} x^2 = x$$

Prelude> roots 0.001 5 (\x -> x*x/2)

Метод завис, остаётся только нажать ctrl+c для остановки. На самом деле есть одно условие для сходимости метода. Метод сойдётся, если модуль производной функции f меньше единицы. Иначе есть возможность, что мы будем бесконечно генерировать новые подстановки. Вычислим производную нашей функции:

$$\frac{d}{dx} \frac{1}{2} x^2 = x$$

Нам следует ожидать решения в интервале от минус единицы до единицы:

Prelude> roots 0.001 0.5 (\x -> x*x/2)
3.0517578125e-5

Мы нашли решение, корень равен нулю. В этой записи Ne-5 означает $N \cdot 10^{-5}$

Краткое содержание

В этой главе мы узнали о том как происходят вычисления в Haskell. Мы узнали, что они ленивые. Всё вычисляется как можно позже и как можно меньше. Такие вычисления называются вычислениями по необходимости.

Также мы узнали о вычислениях по значению и вычислениях по имени.

Вычисление по необходимости является улучшением вычисления по имени. Мы не дублируем выражения во время применения. Мы сохраняем значения в памяти и подставляем в функцию ссылки на значения. После вычисления значения происходит его обновление в памяти. Так если в одном месте выражение уже было вычислено и мы обратимся к нему по ссылке из другого места, то мы не будем перевычислять его, а просто считаем готовое значение.

Мы познакомились с терминологией процесса вычислений. Выражение может находится в нормальной форме. Это значит что оно вычислено. Может находится в слабой заголовочной нормальной форме. Это значит, что мы знаем хотя бы один конструктор в корне выражения. Также возможно выражение ещё не вычислялось, тогда оно является отложенным (thunk).

Суть ленивых вычислений заключается в том, что они происходят синхронно. Если у нас есть композиция двух функций:

$$g\ (f\ x)$$

Внутренняя функция f не начнёт вычисления до тех пор пока значения не понадобятся внешней функции g. О последствиях этого мы остановимся подробнее в отдельной главе. Значения могут потребоваться только при сопоставлении с образцом. Когда мы хотим узнать какое из уравнений нам выбрать.

Иногда ленивые вычисления не эффективны по расходу памяти. Это происходит когда выражение состоит из большого числа подвыражений, которые будут вычислены в любом случае. В Haskell у нас есть способы борьбы с ленью. Это функция seq, энергичные образцы и энергичные типы данных.

Функция seq:

seq :: a -> b -> b

Сначала приводит к слабой заголовочной форме свой первый аргумент, а затем возвращает второй. Взрывные образцы выполняют те же функции, но они используются в декомпозиции аргументов или в объявлении типа.

Упражнения

Зарегистрировано под лицензией Creative commons Attribution-NonCommercial-NoDerivs 3.0 Generic (CC BY-NC-ND 3.0)