Статьи

Во славу лени

Мне лень. Но это ленивые люди, которые изобрели колесо и велосипед, потому что им не нравилось ходить или нести вещи. — Леха Валенса

Лень — мать всех вредных привычек. Но в конечном итоге она мать, и мы должны уважать ее. — Шикамару Нара

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

Что такое лень?

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

Некоторые языки ленивы, другие строги. Некоторые строгие по умолчанию и, возможно, ленивые, а другие … ну, наоборот. Говорят, что Java — строгий язык. Но что это значит?

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

Давайте возьмем пример. Если вы пишете int x = 5 это приводит к тому, что буквальное значение 5 будет упоминаться именем x . Иногда говорят, что значение 5 хранится в переменной x , по аналогии с тем, как это делается компьютерами, которые будут хранить некоторые данные, представляющие 5 в некотором месте, представленном x . Однако обратите внимание, что запись строки выше не сохраняет 5 в x если программа не выполняется. Подробнее об этом позже.

Таким образом, кажется очевидным, что на данные справа от знака равенства ссылается имя слева от этого знака. Однако, если мы напишем int x = 2 + 3 это не приведет к тому, что переменная x ссылается на 2 + 3 , которая является операцией. Вместо этого x будет ссылаться на результат операции.

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

Рассмотрим следующую Java-программу:

 public static void main(String... args) { int x = 2 + 3; int y = 18 / 2; System.out.println(y); } 

Вычислить значение x совершенно бесполезно, так как это значение нигде не используется. Если бы Java был ленивым языком, он бы вычислял только y , и это произошло бы в результате выполнения метода println .

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

 public static void main(String... args) { displayMessage(getLocalizedMessage(), getDefaultMessage()); } private static void displayMessage( String localizedMessage, String defaultMessage) { if (localizedMessage != null && localizedMessage.length() != 0) { System.out.println(localizedMessage); } else { System.out.println(defaultMessage); } } private static String getDefaultMessage() { System.out.println("Evaluating default message"); return "Bye!"; } private static String getLocalizedMessage() { System.out.println("Evaluating localized message"); return "Ciao!"; } 

Запуск этой программы отображает:

 Evaluating localized message Evaluating default message Ciao! 

Это показывает, что хотя параметр defaultMessage не используется, он был оценен, что привело к вызову getDefaultMessage() . Так что Java — строгий язык.

Или это?

Разные виды лени

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

Это, конечно, случай для чего-то типа int x = 2 + 3 но как насчет int x = getValue(y) ? Метод getValue() может получить доступ к сети, чтобы получить значение. Сетевое соединение может быть доступно в какое-то время, а не в другие. Или он может прочитать результат некоторых других вычислений, которые могут быть изменены другим потоком.

Следствием этого является то, что сама ленивая оценка является ссылочно-прозрачной, только если выражение для оценки является ссылочно-прозрачным. В этом примере это означает, что значение, возвращаемое методом getValue() должно зависеть только от его аргумента y . (Это упрощение, поскольку оно также может зависеть от некоторых неизменных внешних данных.)

Но кроме ленивости языка, вы также можете встретить ленивость типов , как в следующем примере Java:

 Supplier x = () -> getValue(y);  Supplier x = () -> getValue(y); 

Здесь x охотно оценивается для объекта типа Supplier<Integer> , но метод getValue не вызывается до тех пор, пока нам не понадобится его результат и мы получим его, вызвав Supplier.get .

Итак, ясно, что помимо того, что язык может быть ленивым или строгим, мы все равно можем извлечь выгоду из лени, используя ленивые типы. Основным отличием будет то, что с ленивым языком ленивый A будет A , тогда как на строгом языке у ленивого A будет другой тип, не связанный с A (в том смысле, что мы не сможем назначить ленивое значение для ссылки типа A ).

Почему все языки ленивы

Итак, ясно, что Java — строгий язык, хотя мы можем реализовать ленивость в типах. Но действительно ли Java строг? Давайте посмотрим на displayMessage метода displayMessage :

 if (localizedMessage != null && localizedMessage.length() != 0) { System.out.println(localizedMessage); } else { System.out.println(defaultMessage); } 

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

Так что Java не является «строгим» языком. Это язык, который строг в одних областях и ленив в других. И все языки в некоторых областях ленивы, потому что в этом суть программирования: программа — это ленивая конструкция, которая оценивается при запуске. Без лени не было бы возможного программирования.

Это также верно в повседневной жизни. Без лени мы ничего не могли спланировать. Когда вы записываете список покупок, хотя это список товаров, он не оценивается. Представьте себе, если вы не можете добавить статью в список, не обратившись к магазину («охотно»), чтобы купить ее. Это было бы довольно неэффективно.

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

В конце концов, у Java есть лень.

Когда Java ленива?

У Java есть несколько ленивых конструкций и операторов. Мы уже видели, что структура if ... else ленива. На самом деле это и строго, и лениво. Рассмотрим общую форму:

 if (<condition>) { <branch 1> } else { <branch 2> } 

Вы увидите, что условие всегда оценивается, но только одна ветвь. Таким образом, мы можем сказать, что if ... else строго в отношении его состояния и ленив в отношении ветвей.

Другими ленивыми Java-конструкциями являются циклы for и while , switch ... case и try ... catch ... finally . Как if ... else , эти структуры строги в отношении одних элементов и ленивы в отношении других.

У Java также есть несколько ленивых операторов. Это так называемые «короткозамкнутые» логические операторы && и || и троичный оператор ?: Рассмотрим следующий пример:

 String string = getString(); boolean condition = string != null && string.length() > 0; 

Здесь метод length не будет вызываться, если string равна null потому что, хотя оператор && строг в отношении левого аргумента, он ленив в отношении правого.

Можете ли вы придумать способ реализовать то же самое с помощью метода, например:

 String string = getString(); boolean condition = and(string != null, string.length() > 0); boolean and(boolean condition1, boolean condition2) { // ? } 

Независимо от реализации метода and , он не будет работать, поскольку Java строго относится к параметрам метода, поэтому оба условия будут оцениваться, даже если condition1 ложно, что приведет к возможному исключению NullPointerException если string равна null . Единственный способ реализовать такой метод — это изменить типы:

 String string = getString(); boolean condition = and(() -> string != null, () -> string.length() > 0); boolean and(BooleanSupplier condition1, BooleanSupplier condition2) { if (condition1.getAsBoolean()) { if (condition2.getAsBoolean()) { return true; } } return false; } 

Преимущества лени

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

Другой вариант использования лени — сэкономить время выполнения и ресурсы обработки. Как правило, мы хотим выполнить только соответствующие части программы, в зависимости от некоторых условий. Это то, для чего большинство контрольных структур. Если в конечном итоге нам не нужно значение, зачем нам тратить время и ресурсы процессора на его оценку?

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

Затраты на лень

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

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

Рассмотрим следующий пример:

 public static void main(String... args) { display(createMessage("Bob"), true); display(createMessage("Mark"), false); } private static void display(String message, boolean condition) { if (condition) { System.out.println(message); } else { System.out.println("Hi!"); } } private static String createMessage(String name) { System.out.println("Creating message for " + name); return "Hello, " + name; } 

Этот пример напечатает:

 Creating message for Bob Hello, Bob Creating message for Mark Hi! 

Мы наблюдаем, что из-за нетерпеливой оценки сообщение для Боба вычисляется, хотя значение не используется. Поэтому следующая программа, использующая тип laziness, более эффективна:

 public static void main(String... args) { display(() -> createMessage("Bob"), true); display(() -> createMessage("Mark"), false); } private static void display(Supplier<String> message, boolean condition) { if (condition) { System.out.println(message.get()); } else { System.out.println("Hi!"); } } private static String createMessage(String name) { System.out.println("Creating message for " + name); return "Hello, " + name; } 

Запустив программу, мы можем убедиться, что она не создает сообщение, если оно не используется. С другой стороны, мы можем получить обратный результат (с точки зрения эффективности), если значение используется несколько раз:

 public static void main(String... args) { display(() -> createMessage("Bob"), true); display(() -> createMessage("Mark"), false); } private static void display(Supplier<String> message, boolean condition) { if (condition) { System.out.println(message.get()); System.out.println(message.get()); System.out.println(message.get()); } else { System.out.println("Hi!"); System.out.println("Hi!"); System.out.println("Hi!"); } } private static String createMessage(String name) { System.out.println("Creating message for " + name); return "Hello, " + name; } 

Теперь результат выглядит следующим образом:

 Creating message for Bob Hello, Bob Creating message for Bob Hello, Bob Creating message for Bob Hello, Bob Hi! Hi! Hi! 

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

Конечно, поскольку мы используем ленивость типов и фактически сами реализуем лень, эту проблему легко решить, сохранив вычисленное значение, чтобы оно оценивалось только один раз:

 private static void display(Supplier<String> message, boolean condition) { if (condition) { String evaluatedMessage = message.get(); System.out.println(evaluatedMessage); System.out.println(evaluatedMessage); System.out.println(evaluatedMessage); } else { // [...] } } 

Но мы могли бы предпочесть, чтобы этот процесс был абстрагирован в ленивый тип. Это означает, что нам придется создавать собственный тип, а не использовать стандартный интерфейс Supplier Java 8. Предположим, мы бы назвали этот тип Lazy<T> , мы бы использовали его примерно так:

 display(new Lazy<>(() -> createMessage("Bob")), true); 

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

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

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

Однако нас это не касается, если мы вовлечены в функциональное программирование, поскольку в функциональном программировании функции и эффекты никогда не смешиваются. Фактически, чисто функциональные программы организованы таким образом, что эффекты возникают вне программы.

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

Но основная проблема с вызовом по необходимости — это когда значение должно меняться при каждом вызове. Это, очевидно, не относится к значению выражения, назначенному для ссылки, но это может быть для рекурсивного вызова метода, где значение может изменяться для каждого шага. При ленивом вызове по необходимости оценка аргумента будет оцениваться на первом шаге и не будет переоцениваться на каждом последующем рекурсивном шаге. Если условие остановки рекурсии основывалось на значении этого аргумента, рекурсия никогда не остановилась бы чисто.

Сделать невозможное возможным

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

Рассмотрим, например, следующую программу:

  • взять список натуральных чисел
  • фильтровать простые числа
  • возьмите первые сто элементов и выведите результат на консоль.

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

Очевидно, что нам понадобится не только мощная реализация ленивых скалярных типов (для ленивой работы с одиночными значениями), но и ленивые векторные типы (или ленивые коллекции; для работы с множеством значений). Это будет предметом будущей статьи.

Резюме

Мы исследовали, как лень относится к языкам программирования:

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

Затем мы раскрыли некоторые более глубокие истины относительно лени:

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