Лямбда выражения в Java 8
В новой версии Java 8 наконец-то появились долгожданные лямбда-выражения. Возможно, это самая важная новая возможность последней версии; они позволяют писать быстрее и делают код более ясным, а также открывают дверь в мир функционального программирования. В этой статье я расскажу, как это работает.Java задумывалась как объектно-ориентированный язык в 90-е годы, когда объектно-ориентированное программирование было главной парадигмой в разработке приложений. Задолго до этого было объектно-ориентированное программирование, были функциональные языки программирования, такие, как Lisp и Scheme, но их преимущества не были оценены за пределами академической среды. В последнее время функциональное программирование сильно выросло в значимости, потому что оно хорошо подходит для параллельного программирования и программирования, основанного на событиях («reactive»). Это не значит, что объектная ориентированность — плохо. Наоборот, вместо этого, выигрышная стратегия — смешивать объектно-ориентированное программирование и функциональное. Это имеет смысл, даже если вам не нужна параллельность. Например, библиотеки коллекций могут получить мощное API, если язык имеет удобный синтаксис для функциональных выражений.
Главным улучшением в Java 8 является добавление поддержки функциональных программных конструкций к его объектно-ориентированной основе. В этой статье я продемонстрирую основной синтаксис и как использовать его в нескольких важных контекстах. Ключевые моменты понятия лямбды:
Лямбда-выражение является блоком кода с параметрами. Используйте лямбда-выражение, когда хотите выполнить блок кода в более поздний момент времени. Лямбда-выражения могут быть преобразованы в функциональные интерфейсы. Лямбда-выражения имеют доступ к final переменным из охватывающей области видимости. Ссылки на метод и конструктор ссылаются на методы или конструкторы без их вызова. Теперь вы можете добавить методы по умолчанию и статические методы к интерфейсам, которые обеспечивают конкретные реализации. Вы должны разрешать любые конфликты между методами по умолчанию из нескольких интерфейсов. Зачем нужны лямбды? Лямбда-выражение представляет собой блок кода, который можно передать в другое место, поэтому он может быть выполнен позже, один или несколько раз. Прежде чем углубляться в синтаксис (и любопытное название), давайте сделаем шаг назад и увидим, где вы использовали аналогичные блоки кода в Java до этого.Если вы хотите выполнить действия в отдельном потоке, вы помещаете их в метод run из Runnable, вот так:
class MyRunner implements Runnable { public void run () { for (int i = 0; i < 1000; i++) doWork(); } ... } Затем, когда вы хотите выполнить этот код, вы создаете экземпляр класса MyRunner. Вы можете поместить экземпляр в пул потоков, или поступить проще и запустить новый поток: MyRunner r = new MyRunner(); new Thread(r).start(); Ключевым моментом является то, что метод run содержит код, который нужно выполнить в отдельном потоке.Рассмотрим сортировку с использованием пользовательского компаратора. Если вы хотите отсортировать строки по длине, а не по умолчанию, вы можете передать объект Comparator в метод sort:
class LengthStringComparator implements Comparator
button.setOnAction (new EventHandler
Во всех трех примерах вы видели один и тот же подход. Блок кода кому-то передавался — пулу потоков, методу сортировки или кнопке. Этот код вызывался некоторое время спустя.
До сих пор передача кода не была простой в Java. Вы не могли просто передать блоки кода куда угодно. Java является объектно-ориентированным языком, так что вы должны были создать объект, принадлежащий к классу, у которого есть метод с нужным кодом.В других языках можно работать с блоками кода непосредственно. Проектировщики Java сопротивлялись добавлению этой функции в течение длительного времени. В конце концов, большая сила Java в ее простоте и последовательности. Язык может стать крайне беспорядочным, если будет включать в себя все функции, которые дают чуть более краткий код. Тем не менее, в тех других языках, это не просто легче порождать поток или зарегистрировать обработчик кнопки щелчка; многие их API проще, более последовательны и мощные. В Java, можно было бы написать подобные интерфейсы, которые принимают объекты классов, реализующих определенную функцию, но такие API было бы неудобно использовать.
В последнее время вопрос был не в том, расширять Java для функционального программирования или нет, а как это сделать. Потребовалось несколько лет экспериментов, прежде чем выяснилось, что это хорошо подходит для Java. В следующем разделе вы увидите, как можно работать с блоками кода в Java 8.
Синтаксис лямбда-выражений Рассмотрим предыдущий пример сортировки еще раз. Мы передаем код, который проверяет, какая строка короче. Мы вычисляем Integer.compare (firstStr.length (), secondStr.length ()) Что такое firstStr и secondStr? Они оба строки! Java является строго типизированным языком, и мы должны указать типы: (String firstStr, String secondStr) → Integer.compare (firstStr.length (), secondStr.length ()) Вы только что видели ваше первое лямбда-выражение! Такое выражение является просто блоком кода вместе со спецификацией любых переменных, которые должны быть переданы в код.
Почему такое название? Много лет назад, когда еще не было никаких компьютеров, логик Алонзо Чёрч хотел формализовать, что значит для математической функции быть эффективно вычисляемой. (Любопытно, что есть функции, которые, как известно, существуют, но никто не знает, как вычислить их значения.) Он использовал греческую букву лямбда (λ), чтобы отметить параметры. Если бы он знал о Java API, он написал бы что-то не сильно похожее на то, что вы видели, скорее всего.
Почему буква λ? Разве Чёрч использовал все буквы алфавита? На самом деле, почтенный труд Principia Mathematica использует символ ˆ для обозначения свободных переменных, которые вдохновили Чёрча использовать заглавную лямбда (Λ) для параметров. Но, в конце концов, он переключился на строчной вариант буквы. С тех пор, выражение с переменными параметрами было названо «лямбда-выражение».
Вы только что видели одну форму лямбда-выражений в Java: параметры, стрелку → и выражение. Если код выполняет вычисление, которое не вписывается в одно выражение, запишите его так же, как вы бы написали метод: заключенный в {} и с явными выражениями return. Например,
(String firstStr, String secondStr) → {
if (firstStr.length () < secondStr.length()) return -1;
else if (firstStr.length() > secondStr.length ()) return 1;
else return 0;
}
Если лямбда-выражение не имеет параметров, вы все равно ставите пустые скобки, так же, как с методом без параметров:
() → { for (int i = 0; i < 1000; i++) doWork(); }
Если типы параметров лямбда-выражения можно вывести, можно опустить их. Например,
Comparator
EventHandler
(final String var) → … (@NonNull String var) → … Вы никогда не указываете тип результата лямбда-выражения. Это всегда выясняется из контекста. Например, выражение (String firstStr, String secondStr) → Integer.compare (firstStr.length (), secondStr.length ()) может быть использовано в контексте, где ожидается результат типа int.Обратите внимание, что лямбда-выражение не может возвращать значение в каких-то ветках, а в других не возвращать. Например, (int x) → { if (x <= 1) return -1; } является недопустимым.
Функциональные интерфейсы Как мы уже обсуждали, в Java есть много существующих интерфейсов, которые инкапсулируют блоки кода, такие, как Runnable или Comparator. Лямбда-выражения имеют обратную совместимость с этими интерфейсами.Вы можете поставить лямбда-выражение всякий раз, когда ожидается объект интерфейса с одним абстрактным методом. Такой интерфейс называется функциональным интерфейсом.
Вы можете удивиться, почему функциональный интерфейс должен иметь единственный абстрактный метод. Разве не все методы в интерфейсе абстрактные? На самом деле, всегда было возможно для интерфейса переопределить методы класса Object, например, toString или clone, и эти объявления не делают методы абстрактными. (Некоторые интерфейсы в Java API переопределяют методы Object, чтобы присоединить javadoc-комментарии. Посмотрите Comparator API для примера.) Что еще более важно, как вы вскоре увидите, в Java 8 интерфейсы могут объявлять неабстрактные методы.
Чтобы продемонстрировать преобразование в функциональный интерфейс, рассмотрим метод Arrays.sort. Его второй параметр требуется экземпляр Comparator, интерфейса с единственным методом. Просто предоставьте лямбду:
Arrays.sort (strs,
(firstStr, secondStr) → Integer.compare (firstStr.length (), secondStr.length ()));
За кулисами, метод Arrays.sort получает объект некоторого класса, реализующего Comparator
button.setOnAction (event → System.out.println («The button has been clicked!»)); Этот код очень легко читать.В самом деле, преобразование в функциональный интерфейс — это единственное, что вы можете сделать с лямбда-выражением в Java. В других языках программирования, которые поддерживают функциональные литералы, можно объявить типы функций, таких как (String, String) → int, объявлять переменные этих типов, и использовать переменные для сохранения функциональных выражений. В Java вы не можете даже присвоить лямбда-выражение переменной типа Object, потому Object не является функциональным интерфейсом. Проектировщики Java решили строго придерживаться знакомой концепции интерфейсов, а не добавлять типы функций в язык.
Java API определяет несколько универсальных функциональных интерфейсов в пакете java.util.function. Один из интерфейсов, BiFunction
BiFunction
Наконец, заметим, что checked исключения могут возникнуть при преобразовании лямбды в экземпляр функционального интерфейса. Если тело лямбда-выражения может бросить checked исключение, это исключение должно быть объявлено в абстрактном методе целевого интерфейса. Например, следующее было бы ошибкой:
Runnable sleepingRunner = () → { System.out.println (»…»); Thread.sleep (1000); };
// Error: Thread.sleep can throw a checkedInterruptedException
Поскольку Runnable.run не может бросить исключение, это присваивание является некорректным. Чтобы исправить ошибку, у вас есть два варианта. Вы можете поймать исключение в теле лямбда-выражения. Или вы можете присвоить лямбду интерфейсу, один абстрактный метод которого может бросить исключение. Например, метод call из интерфейса Callable может бросить любое исключение. Таким образом, вы можете присвоить лямбду Callable
Arrays.sort (strs, String: compareToIgnoreCase) Как вы можете видеть из этих примеров оператор :: отделяет имя метода от имени объекта или класса. Есть три основных варианта: object: instanceMethod Class: staticMethod Class: instanceMethod В первых двух случаях ссылка на метод эквивалентна лямбда-выражению, которое предоставляет параметры метода. Как уже упоминалось, System.out: println эквивалентно x → System.out.println (x). Точно так же, Math: pow эквивалентно (x, y) → Math.pow (x, y). В третьем случае первый параметр становится целевым объектом метода. Например, String: compareToIgnoreCase — это то же самое, что и (x, y) → x.compareToIgnoreCase (y).
При наличии нескольких перегруженных методов с тем же именем компилятор попытается найти из контекста, какой вы имеете в виду. Например, есть два варианта метода Math.max, один для int и один для double. Какой из них будет вызван, зависит от параметров метода функционального интерфейса, к которому Math.max преобразуется. Так же, как и лямбда-выражения, ссылки на методы не живут в изоляции. Они всегда преобразуются в экземпляры функциональных интерфейсов.
Вы можете захватить параметр this в ссылке на метод. Например, this: equals — это то же, что и x → this.equals (x). Можно также использовать super. Выражение super: instanceMethod использует this в качестве цели и вызывает версию данного метода суперкласса. Вот искусственный пример, который демонстрирует механизм:
class Speaker {
public void speak () {
System.out.println («Hello, world!»);
}
}
class Concurrent Speaker extends Speaker {
public void speak () {
Thread t = new Thread (super: speak);
t.start ();
}
}
При запуске потока вызывается его Runnable, и super: speak выполняется, вызывая speak суперкласса. (Обратите внимание, что во внутреннем классе вы можете захватить эту ссылку из класса приложения, как EnclosingClass.this: method или EnclosingClass.super: method.)Ссылки на конструктор
Ссылки на конструктор такие же, как ссылки на метод, за исключением того, что именем метода является new. Например, Button: new является ссылкой на конструктор класса Button. На какой именно конструктор? Это зависит от контекста. Предположим, у вас есть список строк. Затем, вы можете превратить его в массив кнопок, путем вызова конструктора для каждой из строк, с помощью следующего вызова:
List
Ссылки на конструкторы массива полезны для преодоления ограничений Java. Невозможно создать массив универсального типа T. Выражение new T[n] является ошибкой, так как оно будет заменено new Object[n]. Это является проблемой для авторов библиотек. Например, предположим, мы хотим иметь массив кнопок. Интерфейс Stream имеет метод toArray, возвращающее массив Object:
Object[] buttons = stream.toArray (); Но это неудовлетворительно. Пользователь хочет массив кнопок, а не объектов. Библиотека потоков решает эту проблему за счет ссылок на конструкторы. Передайте Button[]:: new методу toArray: Button[] buttons = stream.toArray (Button[]:: new); Метод toArray вызывает этот конструктор для получения массива нужного типа. Затем он заполняет и возвращает массив.Область действия переменной Часто вы хотели бы иметь возможность получить доступ к переменным из охватывающего метода или класса в лямбда-выражении. Рассмотрим следующий пример: public static void repeatText (String text, int count) { Runnable r = () → { for (int i = 0; i < count; i++) { System.out.println(text); Thread.yield(); } }; new Thread(r).start(); } Рассмотрим вызов: repeatText("Hi!", 2000); // Prints Hi 2000 times in a separate thread Теперь посмотрим на переменные count и text внутри лямбда-выражения. Обратите внимание, что эти переменные не определены в лямбда-выражении. Вместо этого, они являются параметрами метода repeatText.Если подумать хорошенько, то не очевидно, что здесь происходит. Код лямбда-выражения может выполниться гораздо позже вызова repeatText и переменные параметров уже будут потеряны. Как же переменные text и count остаются доступными?
Чтобы понять, что происходит, мы должны уточнить наши представления о лямбда-выражениях. Лямбда-выражение имеет три компонента:
Блок кода Параметры Значения для свободных переменных; то есть, переменных, которые не являются параметрами и не определены в коде В нашем примере лямбда-выражение имеет две свободных переменных, text и count. Структура данных, представляющая лямбда-выражение, должна сохранять значения для этих переменных, в нашем случае, «Hi!» и 2000. Будем говорить, что эти значения были захвачены лямбда-выражением. (Это деталь реализации, как это делается. Например, можно преобразовать лямбда-выражение в объект с одним методом, так что значения свободных переменных копируются в переменные экземпляра этого объекта.)Техническим термином для блока кода вместе со значениями свободных переменных является замыкание. Если кто-то злорадствует, что их язык поддерживает замыкания, будьте уверены, что Java также их поддерживает. В Java лямбда-выражения являются замыканиями. На самом деле, внутренние классы были замыканиями все это время. Java 8 предоставляет нам замыкания с привлекательным синтаксисом.
Как вы видели, лямбда-выражение может захватить значение переменной в охватывающей области. В Java, чтобы убедиться, что захватили значение корректно, есть важное ограничение. В лямбда-выражении можно ссылаться только на переменные, значения которых не меняются. Например, следующий код является неправильным:
public static void repeatText (String text, int count) { Runnable r = () → { while (count > 0) { count--; // Error: Can’t mutate captured variable System.out.println (text); Thread.yield (); } }; new Thread®.start (); } Существует причина для этого ограничения. Изменяющиеся переменные в лямбда-выражениях не потокобезопасны. Рассмотрим последовательность параллельных задач, каждая из которых обновляет общий счетчик. int matchCount = 0; for (Path p: files) new Thread (() → { if (p has some property) matchCount++; }).start (); // Illegal to mutate matchCount Если бы этот код был правомерным, это было бы не слишком хорошо. Приращение matchCount++ неатомарно, и нет никакого способа узнать, что произойдет, если несколько потоков выполнят этот код одновременно.Внутренние классы могут также захватывать значения из охватывающей области. До Java 8 внутренние классы могли иметь доступ только к локальным final переменным. Это правило теперь ослаблено для соответствия правилу для лямбда-выражений. Внутренний класс может получить доступ к любой эффективно final локальной переменной; то есть, к любой переменной, значение которой не изменяется.
Не рассчитывайте, что компилятор выявит все параллельные ошибки доступа. Запрет на модификацию имеет место только для локальных переменных. Если matchCount — переменная экземпляра или статическая переменная из охватывающего класса, то никакой ошибки не будет, хотя результат так же не определен.
Кроме того, совершенно законно изменять разделяемый объект, хоть это и не очень надежно. Например,
List
Как и с внутренними классами, есть обходное решение, которое позволяет лямбда-выражению обновить счетчик в локальной охватывающей области видимости. Используйте массив длиной 1, вроде этого:
int[] counts = new int[1]; button.setOnAction (event → counts[0]++); Конечно, такой код не потокобезопасный. Для обратного вызова кнопки это не имеет значения, но в целом, вы должны подумать дважды, прежде чем использовать этот трюк.Тело лямбда-выражения имеет ту же область видимости, что и вложенный блок. Здесь применяются те же самые правила для конфликтов имен. Нельзя объявить параметр или локальную переменную в лямбде, которые имеют то же имя, что и локальная переменная.
Path first = Paths.get (»/usr/local»);
Comparator
Рассмотрим такой интерфейс:
interface Person { long getId (); default String getFirstName () { return «Jack»; } } Интерфейс имеет два метода: getId, это абстрактный метод, и метод по умолчанию getFirstName. Конкретный класс, реализующий интерфейс Person, должен, конечно, предоставить реализацию getId, но он может выбрать, оставить реализацию getFirstName или переопределить ее.Методы по умолчанию кладут конец классическому паттерну предоставления интерфейса и абстрактного класса, который реализует все или почти все из его методов, такие, как Collection/AbstractCollection или WindowListener/WindowAdapter. Теперь вы можете просто реализовать методы в интерфейсе.Что произойдет, если точно такой же метод определен как метод по умолчанию в одном интерфейсе, а затем снова в качестве метода суперкласса или другого интерфейса? Языки типа Скала и C++ имеют сложные правила разрешения таких неоднозначностей. К счастью, правила в Java гораздо проще. Вот они:
Родительские классы выиграют. Если суперкласс предоставляет конкретный метод, методы по умолчанию с тем же именем и типами параметров просто игнорируются. Интерфейсы сталкиваются. Если супер интерфейс предоставляет метод по умолчанию, а другой интерфейс поставляет метод с тем же именем и типами параметров (по умолчанию или нет), то вы должны разрешить конфликт путем переопределения этого метода. Давайте посмотрим на второе правило. Рассмотрим еще один интерфейс с методом getFirstName: interface Naming { default String getFirstName () { return getClass ().getName () + »_» + hashCode (); } } Что произойдет, если вы создадите класс, реализующий оба? class Student implements Person, Naming { … } Класс наследует две противоречивые версии метода getFirstName, предоставляемые интерфейсами Person и Naming. Вместо выбора того или другого метода, компилятор Java сообщает об ошибке и оставляет программисту разрешение неоднозначности. Просто предоставьте метод getFirstName в классе Student. В этом методе вы можете выбрать один из двух конфликтующих методов, как показано ниже: class Student implements Person, Naming { public String getFirstName () { returnPerson.super.getFirstName (); } … } Теперь предположим, что Naming интерфейс не содержит реализацию по умолчанию для getFirstName: interface Naming { String getFirstName (); } Может ли класс Student унаследовать метод по умолчанию из интерфейса Person? Это могло бы быть разумным, но проектировщики Java приняли решение в пользу единообразия. Это не имеет значения, как два интерфейсы конфликтуют. Если хотя бы один интерфейс обеспечивает реализацию, компилятор сообщает об ошибке, и программист должен устранить неоднозначность.Если ни один интерфейс не обеспечивает реализацию по умолчанию для общего метода, то мы находимся в пре-Java 8 ситуации и нет никакого конфликта. У класса реализации есть две возможности: реализовать метод или оставить его нереализованным. В последнем случае класс сам является абстрактным.
Мы только что обсудили конфликты имен между двумя интерфейсами. Теперь рассмотрим класс, расширяющий суперкласс и реализующий интерфейс, наследуя тот же метод от обоих. Например, предположим, что Person является классом и Student определяется как:
class Student extends Person implements Naming { … } В этом случае только метод суперкласса имеет значение, и любой метод по умолчанию из интерфейса просто игнорируется. В нашем примере Student наследует метод getFirstName от Person, и нет никакой разницы, обеспечивает ли интерфейс Named реализацию по умолчанию для getFirstName или нет. Это правило «класс побеждает» в действии. Правило «класс побеждает» обеспечивает совместимость с Java 7. Если вы добавляете методы по умолчанию к интерфейсу, это не имеет никакого влияния на код, который работал до того, как появились методы по умолчанию. Но имейте в виду: вы не имеете права создавать метод по умолчанию, который переопределяет один из методов класса Object. Например, вы не можете определить метод по умолчанию для toString или equals, хотя это могло бы быть удобным для таких интерфейсов, как List. Как следствие правила о победе классов, такой метод никогда не может выиграть у Object.toString или Object.equals.Заключение В этой статье были описаны лямбда-выражения в качестве единого крупнейшего обновления в модели программирования, когда-либо бывшего в Java, — большего, чем даже дженерики. А почему нет? Это открывает программисту Java возможности, которых ему не хватало по сравнению с другими функциональными языками программирования. Наряду с другими фичами, такими, как методы по умолчанию, лямбды могут быть использованы для написания действительно хорошего кода. Надеюсь, эта статья дала представление о то, что для нас приготовили в Java 8.