Я давно задавался вопросом, чем полезна ленивая оценка. Мне еще предстоит, чтобы кто-нибудь объяснил мне разумным образом; в основном все сводится к «поверь мне».
Примечание: я не имею в виду мемоизацию.
Я давно задавался вопросом, чем полезна ленивая оценка. Мне еще предстоит, чтобы кто-нибудь объяснил мне разумным образом; в основном все сводится к «поверь мне».
Примечание: я не имею в виду мемоизацию.
Ответы:
В основном потому, что это может быть более эффективным - значения не нужно вычислять, если они не будут использоваться. Например, я могу передать в функцию три значения, но в зависимости от последовательности условных выражений фактически может использоваться только подмножество. В таком языке, как C, все три значения будут вычисляться в любом случае; но в Haskell вычисляются только необходимые значения.
Он также позволяет создавать такие интересные вещи, как бесконечные списки. У меня не может быть бесконечного списка на таком языке, как C, но в Haskell это не проблема. Бесконечные списки довольно часто используются в определенных областях математики, поэтому может быть полезно иметь возможность манипулировать ими.
Полезным примером ленивого вычисления является использование quickSort
:
quickSort [] = []
quickSort (x:xs) = quickSort (filter (< x) xs) ++ [x] ++ quickSort (filter (>= x) xs)
Если теперь мы хотим найти минимум списка, мы можем определить
minimum ls = head (quickSort ls)
Которая сначала сортирует список, а затем берет первый элемент списка. Однако из-за ленивой оценки вычисляется только голова. Например, если мы возьмем минимум списка, [2, 1, 3,]
quickSort сначала отфильтрует все элементы, которые меньше двух. Затем он выполняет quickSort (возвращая список синглтонов [1]), чего уже достаточно. Из-за ленивого вычисления остальное никогда не сортируется, что позволяет сэкономить много вычислительного времени.
Это, конечно, очень простой пример, но лень работает таким же образом для очень больших программ.
Однако у всего этого есть обратная сторона: становится труднее предсказать скорость выполнения и использование памяти вашей программой. Это не означает, что ленивые программы работают медленнее или занимают больше памяти, но это полезно знать.
take k $ quicksort list
требуется только время O (n + k log k), где n = length list
. При сортировке неленивым сравнением это всегда будет занимать время O (n log n).
Я считаю, что ленивое вычисление полезно для многих вещей.
Во-первых, все существующие ленивые языки чисты, потому что на ленивом языке очень сложно рассуждать о побочных эффектах.
Чистые языки позволяют вам рассуждать об определениях функций, используя уравнительные рассуждения.
foo x = x + 3
К сожалению, в неленивом режиме больше операторов не удается вернуть, чем в ленивом, поэтому это менее полезно в таких языках, как ML. Но ленивым языком можно смело рассуждать о равенстве.
Во-вторых, многие вещи вроде «ограничения значений» в ML не нужны в ленивых языках вроде Haskell. Это приводит к значительному упрощению синтаксиса. ML-подобные языки должны использовать такие ключевые слова, как var или fun. В Haskell все это сводится к одному понятию.
В-третьих, лень позволяет писать очень функциональный код, который можно понимать по частям. В Haskell обычно пишут тело функции, например:
foo x y = if condition1
then some (complicated set of combinators) (involving bigscaryexpression)
else if condition2
then bigscaryexpression
else Nothing
where some x y = ...
bigscaryexpression = ...
condition1 = ...
condition2 = ...
Это позволяет вам работать «сверху вниз», понимая основную часть функции. ML-подобные языки заставляют вас использовать let
строго оцененный. Следовательно, вы не осмеливаетесь «поднять» предложение let до основного тела функции, потому что, если оно дорого (или имеет побочные эффекты), вы не хотите, чтобы оно всегда оценивалось. Haskell может явно «отодвинуть» детали в предложение where, поскольку знает, что содержимое этого предложения будет оцениваться только по мере необходимости.
На практике мы, как правило, используем охранники и обрушиваем их, чтобы:
foo x y
| condition1 = some (complicated set of combinators) (involving bigscaryexpression)
| condition2 = bigscaryexpression
| otherwise = Nothing
where some x y = ...
bigscaryexpression = ...
condition1 = ...
condition2 = ...
В-четвертых, иногда лень предлагает гораздо более элегантное выражение определенных алгоритмов. Ленивая «быстрая сортировка» в Haskell является однострочным и имеет то преимущество, что если вы смотрите только на несколько первых элементов, вы платите только те затраты, которые пропорциональны стоимости выбора только этих элементов. Ничто не мешает вам делать это строго, но вам, вероятно, придется каждый раз перекодировать алгоритм для достижения той же асимптотической производительности.
В-пятых, лень позволяет вам определять в языке новые управляющие структуры. Вы не можете написать новую конструкцию типа «если ... то ... еще ...» на строгом языке. Если вы попытаетесь определить такую функцию, как:
if' True x y = x
if' False x y = y
на строгом языке обе ветви будут оцениваться независимо от значения условия. Когда вы рассматриваете петли, становится еще хуже. Все строгие решения требуют, чтобы язык предоставлял вам некую цитату или явную лямбда-конструкцию.
Наконец, в том же духе некоторые из лучших механизмов для работы с побочными эффектами в системе типов, такие как монады, действительно могут быть эффективно выражены только в ленивой настройке. В этом можно убедиться, сравнив сложность рабочих процессов F # с монадами Haskell. (Вы можете определить монаду на строгом языке, но, к сожалению, вы часто нарушаете один или два закона монад из-за отсутствия лени, а рабочие процессы по сравнению с этим собирают тонну строгого багажа.)
let
говоря, рекурсивный - опасный зверь, в схеме R6RS он позволяет случайным символам #f
появляться в вашем термине везде, где завязывание узла строго ведет к циклу! Никакой каламбур, но строго более рекурсивные let
привязки разумны на ленивом языке. Строгость также усугубляет тот факт, что where
нет никакого способа упорядочить относительные эффекты вообще, за исключением SCC, это конструкция уровня оператора, ее эффекты могут происходить строго в любом порядке, и даже если у вас чистый язык, вы в конечном итоге #f
вопрос. Строгие where
загадки вашего кода нелокальными проблемами.
ifFunc(True, x, y)
что буду оценивать и то, x
и другое, а y
не просто x
.
Есть разница между вычислением нормального порядка и ленивым вычислением (как в Haskell).
square x = x * x
Вычисляя следующее выражение ...
square (square (square 2))
... с нетерпеливой оценкой:
> square (square (2 * 2))
> square (square 4)
> square (4 * 4)
> square 16
> 16 * 16
> 256
... при нормальной оценке порядка:
> (square (square 2)) * (square (square 2))
> ((square 2) * (square 2)) * (square (square 2))
> ((2 * 2) * (square 2)) * (square (square 2))
> (4 * (square 2)) * (square (square 2))
> (4 * (2 * 2)) * (square (square 2))
> (4 * 4) * (square (square 2))
> 16 * (square (square 2))
> ...
> 256
... с ленивой оценкой:
> (square (square 2)) * (square (square 2))
> ((square 2) * (square 2)) * ((square 2) * (square 2))
> ((2 * 2) * (2 * 2)) * ((2 * 2) * (2 * 2))
> (4 * 4) * (4 * 4)
> 16 * 16
> 256
Это потому, что ленивое вычисление смотрит на дерево синтаксиса и выполняет преобразования дерева ...
square (square (square 2))
||
\/
*
/ \
\ /
square (square 2)
||
\/
*
/ \
\ /
*
/ \
\ /
square 2
||
\/
*
/ \
\ /
*
/ \
\ /
*
/ \
\ /
2
... тогда как обычная оценка порядка выполняет только текстовые расширения.
Вот почему мы, используя ленивое вычисление, получаем больше возможностей (оценка завершается чаще, чем другие стратегии), в то время как производительность эквивалентна активной оценке (по крайней мере, в O-нотации).
Ленивая оценка связана с процессором так же, как сборка мусора с оперативной памятью. GC позволяет вам делать вид, что у вас неограниченный объем памяти, и, таким образом, запрашивать столько объектов в памяти, сколько вам нужно. Среда выполнения автоматически восстановит неиспользуемые объекты. LE позволяет вам притвориться, что у вас неограниченные вычислительные ресурсы - вы можете выполнять столько вычислений, сколько вам нужно. Время выполнения просто не будет выполнять ненужные (в данном случае) вычисления.
В чем практическая польза от этих «притворных» моделей? Он освобождает разработчика (до некоторой степени) от управления ресурсами и удаляет некоторый шаблонный код из ваших источников. Но более важно то, что вы можете эффективно повторно использовать свое решение в более широком наборе контекстов.
Представьте, что у вас есть список чисел S и число N. Вам нужно найти ближайшее к числу N число M из списка S. У вас может быть два контекста: одиночный N и некоторый список L из N (ei для каждого N в L вы посмотрите ближайший M в S). Если вы используете ленивую оценку, вы можете отсортировать S и применить двоичный поиск, чтобы найти ближайший M к N. Для хорошей ленивой сортировки потребуется O (размер (S)) шагов для одного N и O (ln (size (S)) * (размер (S) + размер (L))) шагов для равномерно распределенного L.Если у вас нет ленивой оценки для достижения оптимальной эффективности, вы должны реализовать алгоритм для каждого контекста.
Если верить Саймону Пейтону Джонсу, ленивое оценивание не важно само по себе, а только как «рубашка для волос», заставляющая дизайнеров сохранять язык чистым. Я сочувствую этой точке зрения.
Ричард Берд, Джон Хьюз и, в меньшей степени, Ральф Хинце способны делать удивительные вещи с ленивой оценкой. Прочтение их работ поможет вам оценить это. Хорошей отправной точкой являются великолепный решатель судоку Берда и статья Хьюза « Почему так важно функциональное программирование» .
IO
монады) подпись main
была бы String -> String
и вы уже могли бы правильно писать интерактивные программы.
IO
монаде?
Рассмотрим программу игры в крестики-нолики. У него четыре функции:
Это создает четкое разделение проблем. В частности, функция генерации ходов и функции оценки доски - единственные, которые должны понимать правила игры: дерево ходов и функции минимакса полностью повторно используются.
Теперь попробуем реализовать шахматы вместо крестиков-ноликов. На «нетерпеливом» (то есть традиционном) языке это не сработает, потому что дерево перемещений не помещается в памяти. Итак, теперь функции оценки платы и генерации ходов должны быть смешаны с деревом ходов и минимаксной логикой, потому что минимаксная логика должна использоваться, чтобы решить, какие ходы генерировать. Наша красивая чистая модульная структура исчезает.
Однако в ленивом языке элементы дерева перемещений генерируются только в ответ на требования функции минимакса: нет необходимости генерировать все дерево перемещений, прежде чем мы позволим минимаксу ослабить верхний элемент. Так что наша чистая модульная структура по-прежнему работает в реальной игре.
Вот еще два момента, которые, как мне кажется, еще не были затронуты в ходе обсуждения.
Лень - это механизм синхронизации в параллельной среде. Это легкий и легкий способ создать ссылку на какое-либо вычисление и поделиться его результатами между многими потоками. Если несколько потоков попытаются получить доступ к неоцененному значению, только один из них выполнит его, а остальные заблокируются соответственно, получив значение, как только оно станет доступным.
Лень - основа амортизации структур данных в чистом виде. Это подробно описано Окасаки в « Чисто функциональных структурах данных» , но основная идея состоит в том, что ленивое вычисление является контролируемой формой мутации, критически важной для того, чтобы мы могли эффективно реализовывать определенные типы структур данных. Хотя мы часто говорим о лени, вынуждающей нас носить чистую рубашку для волос, применим и другой способ: это пара синергетических языковых особенностей.
Когда вы включаете компьютер, и Windows воздерживается от открытия каждого каталога на вашем жестком диске в проводнике Windows и воздерживается от запуска каждой отдельной программы, установленной на вашем компьютере, до тех пор, пока вы не укажете, что требуется определенный каталог или определенная программа, что это «ленивая» оценка.
«Ленивая» оценка - это выполнение операций, когда и по мере необходимости. Это полезно, когда это функция языка программирования или библиотеки, потому что обычно сложнее реализовать ленивую оценку самостоятельно, чем просто предварительно вычислить все заранее.
Учти это:
if (conditionOne && conditionTwo) {
doSomething();
}
Метод doSomething () будет выполняться, только если conditionOne истинно, а conditionTwo истинно. В случае, когда conditionOne ложно, зачем вам вычислять результат conditionTwo? В этом случае оценка conditionTwo будет пустой тратой времени, особенно если ваше условие является результатом некоторого процесса метода.
Это один из примеров ленивого интереса к оценке ...
Это может повысить эффективность. Это кажется очевидным, но на самом деле не самым важным. (Обратите внимание, что лень тоже может убить эффективность - этот факт не сразу очевиден. Однако, сохраняя много временных результатов, а не вычисляя их немедленно, вы можете использовать огромное количество оперативной памяти.)
Он позволяет определять конструкции управления потоком в обычном коде пользовательского уровня, а не жестко закодировать его в языке. (Например, в Java есть for
циклы; в Haskell есть for
функция. В Java есть обработка исключений; в Haskell есть различные типы монад исключений. В C # есть goto
; в Haskell есть монада продолжения ...)
Это позволяет вам отделить алгоритм генерации данных от алгоритма для принятия решения о том, сколько данных генерировать. Вы можете написать одну функцию, которая генерирует условно бесконечный список результатов, и другую функцию, которая обрабатывает столько из этого списка, сколько сочтет нужным. Более того, у вас может быть пять функций-генераторов и пять функций-потребителей, и вы можете эффективно создавать любую комбинацию - вместо того, чтобы вручную кодировать 5 x 5 = 25 функций, которые объединяют оба действия одновременно. (!) Все мы знаем, что развязка - это хорошо.
Это более или менее заставляет вас разрабатывать чистый функциональный язык. Всегда есть соблазн сократить путь , но, говоря ленивым языком, малейшая примесь делает ваш код совершенно непредсказуемым, что сильно препятствует использованию сокращений.
Одно из огромных преимуществ лени - это возможность писать неизменяемые структуры данных с разумными амортизируемыми границами. Простой пример - неизменяемый стек (с использованием F #):
type 'a stack =
| EmptyStack
| StackNode of 'a * 'a stack
let rec append x y =
match x with
| EmptyStack -> y
| StackNode(hd, tl) -> StackNode(hd, append tl y)
Код разумный, но добавление двух стеков x и y занимает время O (длина x) в лучшем, худшем и среднем случаях. Добавление двух стеков - это монолитная операция, она затрагивает все узлы в стеке x.
Мы можем переписать структуру данных в виде ленивого стека:
type 'a lazyStack =
| StackNode of Lazy<'a * 'a lazyStack>
| EmptyStack
let rec append x y =
match x with
| StackNode(item) -> Node(lazy(let hd, tl = item.Force(); hd, append tl y))
| Empty -> y
lazy
работает, приостанавливая оценку кода в своем конструкторе. После оценки с использованием.Force()
возвращаемое значение кэшируется и повторно используется в каждом последующем .Force()
.
В ленивой версии добавления - это операция O (1): она возвращает 1 узел и приостанавливает фактическое перестроение списка. Когда вы получаете заголовок этого списка, он оценивает содержимое узла, заставляя его вернуть заголовок и создать одну приостановку с оставшимися элементами, поэтому взятие заголовка списка является операцией O (1).
Итак, наш ленивый список постоянно перестраивается, вы не платите за перестройку этого списка, пока не пройдете через все его элементы. Используя лень, этот список поддерживает объединение и добавление O (1). Интересно, что поскольку мы не оцениваем узлы до тех пор, пока к ним не обращаемся, вполне возможно создать список с потенциально бесконечными элементами.
Приведенная выше структура данных не требует повторного вычисления узлов при каждом обходе, поэтому они сильно отличаются от обычных IEnumerables в .NET.
Этот фрагмент показывает разницу между ленивым и неленивым вычислением. Конечно, эту функцию Фибоначчи можно оптимизировать и использовать ленивую оценку вместо рекурсии, но это испортит пример.
Предположим, мы МОЖЕМ использовать для чего-то 20 первых чисел, без ленивой оценки все 20 чисел должны быть сгенерированы заранее, но при ленивом вычислении они будут генерироваться только по мере необходимости. Таким образом, при необходимости вы будете платить только расчетную цену.
Пример вывода
Не ленивое поколение: 0,023373 Ленивое поколение: 0,000009 Не ленивый вывод: 0,000921 Ленивый вывод: 0,024205
import time
def now(): return time.time()
def fibonacci(n): #Recursion for fibonacci (not-lazy)
if n < 2:
return n
else:
return fibonacci(n-1)+fibonacci(n-2)
before1 = now()
notlazy = [fibonacci(x) for x in range(20)]
after1 = now()
before2 = now()
lazy = (fibonacci(x) for x in range(20))
after2 = now()
before3 = now()
for i in notlazy:
print i
after3 = now()
before4 = now()
for i in lazy:
print i
after4 = now()
print "Not lazy generation: %f" % (after1-before1)
print "Lazy generation: %f" % (after2-before2)
print "Not lazy output: %f" % (after3-before3)
print "Lazy output: %f" % (after4-before4)
Ленивое вычисление наиболее полезно для структур данных. Вы можете определить массив или вектор, индуктивно определяя только определенные точки в структуре и выражая все остальные через весь массив. Это позволяет создавать структуры данных очень кратко и с высокой производительностью во время выполнения.
Чтобы увидеть это в действии, вы можете взглянуть на мою библиотеку нейронных сетей под названием instinct . Он интенсивно использует ленивую оценку для элегантности и высокой производительности. Например, я полностью избавился от традиционно императивного расчета активации. Простое ленивое выражение делает все за меня.
Это используется, например, в функции активации, а также в алгоритме обучения обратному распространению (я могу опубликовать только две ссылки, поэтому вам нужно будет самостоятельно найти learnPat
функцию в AI.Instinct.Train.Delta
модуле). Традиционно оба требуют гораздо более сложных итерационных алгоритмов.
Другие люди уже привели все веские причины, но я думаю, что полезным упражнением, которое поможет понять, почему имеет значение лень, является попытка написать функцию с фиксированной точкой на строгом языке.
В Haskell функция с фиксированной точкой очень проста:
fix f = f (fix f)
это расширяется до
f (f (f ....
но поскольку Haskell ленив, эта бесконечная цепочка вычислений не проблема; оценка выполняется «снаружи внутрь», и все работает прекрасно:
fact = fix $ \f n -> if n == 0 then 1 else n * f (n-1)
Важно то, что не fix
ленишься, а что f
ленишься. Как только вам уже назначили строгий f
режим, вы можете либо поднять руки вверх и сдаться, либо расширить его и засорять. (Это очень похоже на то, что Ной говорил о строгой / ленивой библиотеке , а не о языке).
А теперь представьте, что вы пишете ту же функцию на строгом Scala:
def fix[A](f: A => A): A = f(fix(f))
val fact = fix[Int=>Int] { f => n =>
if (n == 0) 1
else n*f(n-1)
}
Вы, конечно, получите переполнение стека. Если вы хотите, чтобы это работало, вам нужно указать f
аргумент по мере необходимости:
def fix[A](f: (=>A) => A): A = f(fix(f))
def fact1(f: =>Int=>Int) = (n: Int) =>
if (n == 0) 1
else n*f(n-1)
val fact = fix(fact1)
Я не знаю, как вы в настоящее время думаете о вещах, но я считаю полезным рассматривать ленивую оценку как проблему библиотеки, а не как функцию языка.
Я имею в виду, что в строгих языках я могу реализовать ленивую оценку, создав несколько структур данных, а в ленивых языках (по крайней мере, Haskell) я могу попросить строгости, когда я этого хочу. Следовательно, выбор языка на самом деле не делает ваши программы ленивыми или неленивыми, а просто влияет на то, какой язык вы получаете по умолчанию.
После того, как вы подумаете об этом так, подумайте обо всех местах, где вы пишете структуру данных, которую позже можете использовать для генерации данных (не слишком много глядя на нее до этого), и вы увидите множество применений для ленивого оценка.
Самым полезным применением ленивых вычислений, которое я использовал, была функция, которая вызывала серию подфункций в определенном порядке. Если одна из этих подфункций завершилась неудачно (вернула ложь), вызывающая функция должна была немедленно вернуться. Так что я мог сделать это так:
bool Function(void) {
if (!SubFunction1())
return false;
if (!SubFunction2())
return false;
if (!SubFunction3())
return false;
(etc)
return true;
}
или более элегантное решение:
bool Function(void) {
if (!SubFunction1() || !SubFunction2() || !SubFunction3() || (etc) )
return false;
return true;
}
Как только вы начнете его использовать, вы увидите возможности использовать его все чаще и чаще.
Без ленивой оценки вам не разрешат написать что-то вроде этого:
if( obj != null && obj.Value == correctValue )
{
// do smth
}
Среди прочего, ленивые языки допускают многомерные бесконечные структуры данных.
Хотя схема, Python и т. Д. Допускают одномерные бесконечные структуры данных с потоками, вы можете перемещаться только по одному измерению.
Лень полезна для той же проблемы , но стоит отметить соединение сопрограмм, упомянутое в этой ссылке.
Ленивое вычисление - это рассуждение бедняков по уравнениям (в идеале можно ожидать, что свойства кода будут выводиться из свойств задействованных типов и операций).
Пример , где она работает достаточно хорошо: sum . take 10 $ [1..10000000000]
. Что мы не против того, чтобы его сократили до суммы 10 чисел вместо одного прямого и простого числового вычисления. Без ленивого вычисления, конечно, это создало бы гигантский список в памяти только для использования его первых 10 элементов. Конечно, это будет очень медленно и может вызвать ошибку нехватки памяти.
Пример , где это не так велика , как хотелось бы: sum . take 1000000 . drop 500 $ cycle [1..20]
. Которая фактически суммирует 1 000 000 чисел, даже если в цикле, а не в списке; тем не менее, его следует свести к одному прямому числовому вычислению с несколькими условными выражениями и несколькими формулами. Что было бы намного лучше, чем суммировать 1 000 000 чисел. Даже если в цикле, а не в списке (т.е. после оптимизации вырубки).
Другое дело, что это позволяет кодировать в стиле хвостовой рекурсии по модулю cons , и это просто работает .
ср связанный ответ .
Если под "ленивым вычислением" вы подразумеваете как в комбинированных логических значениях, как в
if (ConditionA && ConditionB) ...
тогда ответ прост: чем меньше циклов процессора потребляет программа, тем быстрее она будет работать ... и если фрагмент инструкций обработки не повлияет на результат программы, тогда в этом нет необходимости (и, следовательно, времени), чтобы выполнить их в любом случае ...
если otoh, вы имеете в виду то, что я называю "ленивыми инициализаторами", например:
class Employee
{
private int supervisorId;
private Employee supervisor;
public Employee(int employeeId)
{
// code to call database and fetch employee record, and
// populate all private data fields, EXCEPT supervisor
}
public Employee Supervisor
{
get
{
return supervisor?? (supervisor = new Employee(supervisorId));
}
}
}
Что ж, этот метод позволяет клиентскому коду, использующему класс, избежать необходимости вызывать базу данных для записи данных супервизора, за исключением случаев, когда клиент, использующий объект Employee, требует доступа к данным супервизора ... это ускоряет процесс создания экземпляра Employee, и все же, когда вам понадобится Supervisor, первый вызов свойства Supervisor вызовет вызов базы данных, и данные будут извлечены и доступны ...
Выдержка из функций высшего порядка
Давайте найдем наибольшее число меньше 100 000, которое делится на 3829. Для этого мы просто отфильтруем набор возможностей, в которых, как мы знаем, лежит решение.
largestDivisible :: (Integral a) => a
largestDivisible = head (filter p [100000,99999..])
where p x = x `mod` 3829 == 0
Сначала мы составляем список всех чисел меньше 100000 по убыванию. Затем мы фильтруем его по нашему предикату, и поскольку числа отсортированы по убыванию, наибольшее число, удовлетворяющее нашему предикату, является первым элементом отфильтрованного списка. Нам даже не нужно было использовать конечный список для нашего начального набора. Это снова лень в действии. Поскольку в конечном итоге мы используем только заголовок отфильтрованного списка, не имеет значения, является ли отфильтрованный список конечным или бесконечным. Оценка останавливается, когда найдено первое адекватное решение.