В теории языков программирования ленивая оценка , или вызов по необходимости , [1] — это стратегия оценки , которая откладывает оценку выражения до тех пор, пока его значение не понадобится ( нестрогая оценка ), а также позволяет избежать повторных оценок (используя совместное использование ) . [2] [3]
Преимущества ленивой оценки включают в себя:
Ленивое вычисление часто сочетается с мемоизацией , как описано в книге Джона Бентли « Написание эффективных программ» . [4] После того, как значение функции вычислено для этого параметра или набора параметров, результат сохраняется в таблице поиска , которая индексируется значениями этих параметров; при следующем вызове функции таблица обращается к определению того, доступен ли уже результат для этой комбинации значений параметров. Если да, то просто возвращается сохраненный результат. Если нет, функция оценивается, и в таблицу поиска добавляется другая запись для повторного использования.
Ленивые вычисления трудно сочетать с императивными функциями, такими как обработка исключений и ввод/вывод , поскольку порядок операций становится неопределенным.
Противоположностью ленивой оценки является энергичная оценка , иногда называемая строгой оценкой. Жадная оценка — это стратегия оценки, используемая в большинстве [ квантифицировать ] языков программирования .
Ленивое вычисление было введено для лямбда-исчисления Кристофером Уодсвортом. [5] Для языков программирования оно было независимо введено Питером Хендерсоном и Джеймсом Х. Моррисом [6] , а также Дэниелом П. Фридманом и Дэвидом С. Уайзом. [7] [8]
Отложенная оценка используется, в частности, в функциональных языках программирования . При использовании отложенной оценки выражение оценивается не сразу, как только оно связывается с переменной, а когда оценщик вынужден выдать значение выражения. То есть, такой оператор, как x = expression;
(т. е. присвоение результата выражения переменной), явно требует, чтобы выражение было оценено, а результат помещен в x
, но то, что на самом деле находится в , x
не имеет значения, пока не возникнет необходимость в его значении через ссылку на x
в некотором более позднем выражении, оценка которого сама может быть отложена, хотя в конечном итоге быстро растущее дерево зависимостей будет обрезано, чтобы выдать некий символ, а не другой, который будет виден внешнему миру. [9]
Ленивая оценка позволяет определять структуры управления нормально, а не как примитивы или методы времени компиляции. Например, можно определить операторы if-then-else и short-circuit оценки : [10] [11]
если Тогда Иначе Истина b c = b если Тогда Иначе Ложь b c = c -- или Истина || b = Истина Ложь || b = b -- и Истина && b = b Ложь && b = Ложь
Они имеют обычную семантику, т. е. оценивает (a), тогда, если и только если (a) оценивается как true, он оценивает (b), в противном случае он оценивает (c). То есть, будет оценено ровно одно из (b) или (c). Аналогично, для , если легкая часть дает True , выражения с большим объемом работы можно было бы избежать. Наконец, при оценке , если SafeToTry ложно , не будет попытки оценить Expression .ifThenElse a b c
EasilyComputed || LotsOfWork
SafeToTry && Expression
Наоборот, в языке с жадностью указанное выше определение будет оценивать (a), (b) и (c) независимо от значения (a). Это нежелательное поведение, так как (b) или (c) могут иметь побочные эффекты , занимать много времени для вычисления или выдавать ошибки. Обычно в языках с жадностью можно вводить определяемые пользователем ленивые управляющие структуры как функции, хотя они могут отходить от синтаксиса языка для жадной оценки: Часто задействованные тела кода необходимо обернуть в значение функции, чтобы они выполнялись только при вызове.ifThenElse a b c
Отложенная оценка имеет преимущество в том, что она позволяет создавать вычисляемые бесконечные списки без бесконечных циклов или вопросов размера, вмешивающихся в вычисления. Фактические значения вычисляются только при необходимости. Например, можно создать функцию, которая создает бесконечный список (часто называемый потоком ) чисел Фибоначчи . Вычисление n -го числа Фибоначчи будет просто извлечением этого элемента из бесконечного списка, что приведет к оценке только первых n членов списка. [12] [13]
Возьмем для примера эту тривиальную программу на Haskell :
numberFromInfiniteList :: Int -> Int numberFromInfiniteList n = бесконечность !! n - 1 , где бесконечность = [ 1 .. ] main = print $ numberFromInfiniteList 4
В функции numberFromInfiniteList значение бесконечности представляет собой бесконечный диапазон, но пока не потребуется фактическое значение (или, точнее, конкретное значение по определенному индексу), список не оценивается, и даже тогда он оценивается только по мере необходимости (то есть до нужного индекса). При условии, что программист будет осторожен, программа завершится нормально. Однако некоторые вычисления могут привести к тому, что программа попытается оценить бесконечное количество элементов; например, запрос длины списка или попытка суммировать элементы списка с помощью операции свертывания приведет к тому, что программа либо не завершится, либо исчерпает память .
В качестве другого примера, список всех чисел Фибоначчи можно записать на языке программирования Haskell как: [13]
fibs = 0 : 1 : zipWith ( + ) fibs ( хвост fibs )
В синтаксисе Haskell « :
» добавляет элемент к списку, tail
возвращает список без его первого элемента и zipWith
использует указанную функцию (в данном случае сложение) для объединения соответствующих элементов двух списков для создания третьего. [12]
В компьютерных оконных системах отображение информации на экране управляется событиями раскрытия , которые запускают код отображения в последний возможный момент. Делая это, оконные системы избегают вычисления ненужных обновлений содержимого отображения. [14]
Другим примером лени в современных компьютерных системах является выделение страниц с копированием при записи или подкачка по требованию , когда память выделяется только тогда, когда изменяется значение, хранящееся в этой памяти. [14]
Ленивость может быть полезна для сценариев высокой производительности. Примером может служить функция Unix mmap , которая обеспечивает загрузку страниц с диска по требованию , так что в память загружаются только те страницы, которые были фактически затронуты, а ненужная память не выделяется.
MATLAB реализует функцию копирования при редактировании , при которой фактическое хранилище копируемых массивов реплицируется только при изменении их содержимого, что может привести к ошибке нехватки памяти при последующем обновлении элемента, а не во время операции копирования. [15]
Число бета-сокращений для сокращения лямбда-терма с вызовом по необходимости не больше числа, необходимого для сокращения вызова по значению или вызова по имени . [16] [17] А в некоторых программах число шагов может быть намного меньше, например, определенное семейство лямбда-термов, использующих числительные Чёрча, занимает бесконечное количество шагов с вызовом по значению (т. е. никогда не завершается), экспоненциальное количество шагов с вызовом по имени, но только полиномиальное число с вызовом по необходимости. Вызов по необходимости воплощает две оптимизации — никогда не повторять работу (аналогично вызову по значению) и никогда не выполнять ненужную работу (аналогично вызову по имени). [18] Ленивые вычисления также могут привести к сокращению объема памяти , поскольку значения создаются по мере необходимости. [19]
На практике ленивая оценка может вызывать значительные проблемы с производительностью по сравнению с энергичной оценкой. Например, на современных компьютерных архитектурах отсрочка вычисления и его выполнение позже медленнее, чем немедленное выполнение. Это можно облегчить с помощью анализа строгости . [18] Ленивая оценка также может приводить к утечкам памяти из-за неоцененных выражений. [20] [21]
Некоторые языки программирования задерживают оценку выражений по умолчанию, а некоторые другие предоставляют функции или специальный синтаксис для задержки оценки. В Miranda и Haskell оценка аргументов функции задерживается по умолчанию. Во многих других языках оценка может быть отложена путем явной приостановки вычисления с использованием специального синтаксиса (как в Scheme " delay
" и " force
" и в OCaml " lazy
" и " Lazy.force
") или, в более общем смысле, путем обертывания выражения в thunk . Объект, представляющий такую явно задержанную оценку, называется ленивым будущим . Raku использует ленивую оценку списков, поэтому можно назначать бесконечные списки переменным и использовать их в качестве аргументов функций, но в отличие от Haskell и Miranda, Raku не использует ленивую оценку арифметических операторов и функций по умолчанию. [9]
В ленивых языках программирования, таких как Haskell, хотя по умолчанию выражения оцениваются только тогда, когда они требуются, в некоторых случаях можно сделать код более жадным — или наоборот, снова сделать его более ленивым после того, как он был сделан более жадным. Это можно сделать, явно кодируя что-то, что заставляет оценивать (что может сделать код более жадным) или избегая такого кода (что может сделать код более ленивым). Строгая оценка обычно подразумевает жадность, но это технически разные концепции.
Однако в некоторых компиляторах реализована оптимизация, называемая анализом строгости , которая в некоторых случаях позволяет компилятору сделать вывод, что значение всегда будет использоваться. В таких случаях это может сделать выбор программиста о том, следует ли принудительно применять это конкретное значение или нет, несущественным, поскольку анализ строгости заставит применять строгую оценку .
В Haskell маркировка полей конструктора как строгих означает, что их значения всегда будут затребованы немедленно. seq
Функцию также можно использовать для немедленного запроса значения и последующей его передачи, что полезно, если поле конструктора должно быть в общем случае ленивым. Однако ни один из этих методов не реализует рекурсивную строгость — для этого была изобретена функция, называемая deepSeq
.
Кроме того, сопоставление с образцом в Haskell 98 по умолчанию является строгим, поэтому ~
для того, чтобы сделать его ленивым, необходимо использовать квалификатор. [22]
В Java ленивая оценка может быть выполнена с использованием объектов, имеющих метод для их оценки, когда требуется значение. Тело этого метода должно содержать код, необходимый для выполнения этой оценки. С момента введения лямбда-выражений в Java SE8, Java поддерживает компактную нотацию для этого. Следующий пример универсального интерфейса предоставляет структуру для ленивой оценки: [23] [24]
интерфейс Ленивый < T > { T eval (); }
Интерфейс Lazy
с его eval()
методом эквивалентен интерфейсу Supplier
с его get()
методом в java.util.function
библиотеке. [25] [26] : 200
Каждый класс, реализующий Lazy
интерфейс, должен предоставлять eval
метод, а экземпляры класса могут нести любые значения, необходимые методу для выполнения ленивой оценки. Например, рассмотрим следующий код для ленивого вычисления и печати 2 10 :
Ленивый < Целое число > a = () -> 1 ; for ( int i = 0 ; i < 10 ; i ++ ) { Ленивый < Целое число > b = a ; a = () -> b .eval ( ) + b .eval ( ); } System .out .println ( "a = " + a .eval ( ) ) ;
В приведенном выше примере переменная a изначально ссылается на ленивый целочисленный объект, созданный лямбда-выражением . Оценка этого лямбда-выражения похожа [a] на создание нового экземпляра анонимного класса , реализуемого с помощью метода eval , возвращающего 1 .() -> 1
Lazy<Integer>
Каждая итерация цикла связывает a с новым объектом, созданным путем вычисления лямбда-выражения внутри цикла. Каждый из этих объектов содержит ссылку на другой ленивый объект, b , и имеет метод eval , который вызывается дважды и возвращает сумму. Переменная b здесь необходима для соответствия требованию Java, чтобы переменные, на которые ссылаются из лямбда-выражения, были фактически final.b.eval()
Это неэффективная программа, поскольку эта реализация ленивых целых чисел не запоминает результат предыдущих вызовов eval . Она также включает значительную автоупаковку и распаковку . Что может быть неочевидно, так это то, что в конце цикла программа построила связанный список из 11 объектов и что все фактические добавления, участвующие в вычислении результата, выполняются в ответ на вызов в последней строке кода. Этот вызов рекурсивно обходит список для выполнения необходимых добавлений.a.eval()
Мы можем создать класс Java, который запоминает ленивый объект следующим образом: [23] [24]
class Memo < T > implements Lazy < T > { private Lazy < T > lazy ; // ленивое выражение, eval устанавливает его в null private T memo ; // меморандум предыдущего значения публичная заметка ( Ленивый < T > ленивый ) { этот . ленивый = ленивый ; } public T eval () { if ( lazy != null ) { memo = lazy . eval (); lazy = null ; } return memo ; } }
Это позволяет переписать предыдущий пример так, чтобы он был гораздо эффективнее. Если оригинал работал за время, экспоненциальное по числу итераций, то мемоизированная версия работает за линейное время :
Ленивый < Целое число > a = () -> 1 ; for ( int i = 0 ; i < 10 ; i ++ ) { Ленивый < Целое число > b = a ; a = new Memo < Целое число > (() -> b .eval ( ) + b .eval ( ) ) ; } System .out .println ( " a = " + a .eval () ) ;
Лямбда-выражения Java — это просто синтаксический сахар . Все, что можно написать с помощью лямбда-выражения, можно переписать как вызов для создания экземпляра анонимного внутреннего класса, реализующего интерфейс, [a] и любое использование анонимного внутреннего класса можно переписать с использованием именованного внутреннего класса, а любой именованный внутренний класс можно переместить на самый внешний уровень вложенности.
В JavaScript ленивые вычисления можно симулировать с помощью генератора . Например, поток всех чисел Фибоначчи можно записать, используя мемоизацию , как:
/** * Функции генератора возвращают объекты генератора, которые овеществляют ленивые вычисления. * @return {!Generator<bigint>} Ненулевой генератор целых чисел. */ function * fibonacciNumbers () { let memo = [ 1n , - 1n ]; // создать начальное состояние (например, вектор чисел "отрицательный Фибоначчи") while ( true ) { // повторять бесконечно memo = [ memo [ 0 ] + memo [ 1 ], memo [ 0 ]]; // обновить состояние при каждой оценке yield memo [ 0 ]; // выдать следующее значение и приостановить выполнение до возобновления } } let stream = fibonacciNumbers (); // создаем ленивый вычисляемый поток чисел let first10 = Array.from ( new Array ( 10 ), () => stream.next (). value ); // вычисляем только первые 10 чисел console.log ( first10 ); // вывод [0n, 1n, 1n, 2n, 3n, 5n, 8n, 13n, 21n , 34n ]
В Python 2.x range()
функция [27] вычисляет список целых чисел. Весь список сохраняется в памяти, когда вычисляется первый оператор присваивания, так что это пример нетерпеливой или немедленной оценки:
>>> r = диапазон ( 10 ) >>> печать r [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] >>> печать r [ 3 ] 3
В Python 3.x range()
функция [28] возвращает генератор , который вычисляет элементы списка по требованию. Элементы генерируются только тогда, когда они нужны (например, когда print(r[3])
вычисляется в следующем примере), поэтому это пример ленивой или отложенной оценки:
>>> r = диапазон ( 10 ) >>> печать ( r ) диапазон(0, 10) >>> печать ( r [ 3 ]) 3
В Python 2.x возможно использовать функцию xrange()
, которая возвращает объект, который генерирует числа в диапазоне по требованию. Преимущество в том, xrange
что сгенерированный объект всегда будет занимать один и тот же объем памяти.
>>> r = xrange ( 10 ) >>> print ( r ) xrange(10) >>> lst = [ x для x в r ] >>> print ( lst ) [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
Начиная с версии 2.2, Python реализует ленивую оценку, реализуя итераторы (ленивые последовательности) в отличие от последовательностей кортежей или списков. Например (Python 2):
>>> числа = диапазон ( 10 ) >>> итератор = итератор ( числа ) >>> печать чисел [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] >>> печать итератора <объект listiterator по адресу 0xf7e8dd4c> >>> печать итератора . next () 0
В .NET framework возможно делать ленивые вычисления с использованием класса . [29] Класс может быть легко использован в F# с использованием ключевого слова , в то время как метод будет принудительно выполнять вычисления. Существуют также специализированные коллекции, подобные тем, которые предоставляют встроенную поддержку ленивых вычислений. System.Lazy<T>
lazy
force
Microsoft.FSharp.Collections.Seq
пусть фибоначчи = Seq . unfold ( fun ( x , y ) -> Some ( x , ( y , x + y ))) ( 0I , 1I ) фибоначчи |> Seq . nth 1000
В C# и VB.NET класс используется напрямую. System.Lazy<T>
public int Sum () { int a = 0 ; int b = 0 ; Lazy < int > x = new Lazy < int > (() => a + b ); a = 3 ; b = 5 ; return x . Значение ; // возвращает 8 }
Или более практический пример:
// рекурсивное вычисление n-го числа Фибоначчи public int Fib ( int n ) { return ( n == 1 ) ? 1 : ( n == 2 ) ? 1 : Fib ( n - 1 ) + Fib ( n - 2 ); } public void Main () { Console . WriteLine ( "Какое число Фибоначчи вы хотите вычислить?" ); int n = Int32 . Parse ( Console . ReadLine ()); Lazy < int > fib = new Lazy < int > (() => Fib ( n )); // функция подготовлена, но не выполнена bool execute ; if ( n > 100 ) { Console . WriteLine ( "Это может занять некоторое время. Вы действительно хотите вычислить такое большое число? [y/n]" ); execute = ( Console . ReadLine () == "y" ); } else execute = true ; if ( execute ) Console . WriteLine ( fib . Value ); // число вычисляется только при необходимости }
Другой способ — использовать yield
ключевое слово:
// активное вычисление public IEnumerable < int > Fibonacci ( int x ) { IList < int > fibs = new List < int > (); int prev = - 1 ; int next = 1 ; for ( int i = 0 ; i < x ; i ++ ) { int sum = prev + next ; prev = next ; next = sum ; fibs.Add ( sum ) ; } return fibs ; } // ленивая оценка public IEnumerable < int > LazyFibonacci ( int x ) { int prev = - 1 ; int next = 1 ; for ( int i = 0 ; i < x ; i ++ ) { int sum = prev + next ; prev = next ; next = sum ; yield return sum ; } }