1. Обзор
Теперь, когда Java 8 получила широкое распространение, начали появляться шаблоны и лучшие практики для некоторых из ее ключевых функций. В этом руководстве мы более подробно рассмотрим функциональные интерфейсы и лямбда-выражения.
2. Отдавайте предпочтение стандартным функциональным интерфейсам
Функциональные интерфейсы, собранные в пакете java.util.function , удовлетворяют потребности большинства разработчиков в предоставлении целевых типов для лямбда-выражений и ссылок на методы. Каждый из этих интерфейсов является общим и абстрактным, что позволяет легко адаптировать их практически к любому лямбда-выражению. Разработчики должны изучить этот пакет перед созданием новых функциональных интерфейсов.
Рассмотрим интерфейс Foo
:
@FunctionalInterface
public interface Foo {
String method(String string);
}
Кроме того, у нас есть метод add()
в некотором классе UseFoo
, который принимает этот интерфейс в качестве параметра:
public String add(String string, Foo foo) {
return foo.method(string);
}
Чтобы выполнить его, мы должны написать:
Foo foo = parameter -> parameter + " from lambda";
String result = useFoo.add("Message ", foo);
Если мы посмотрим внимательнее, то увидим, что Foo
— не более чем функция, которая принимает один аргумент и выдает результат. Java 8 уже предоставляет такой интерфейс в Function<T,R>
из пакета java.util.function .
Теперь мы можем полностью удалить интерфейс Foo
и изменить наш код на:
public String add(String string, Function<String, String> fn) {
return fn.apply(string);
}
Чтобы выполнить это, мы можем написать:
Function<String, String> fn =
parameter -> parameter + " from lambda";
String result = useFoo.add("Message ", fn);
3. Используйте аннотацию @FunctionalInterface
Теперь давайте аннотируем наши функциональные интерфейсы с помощью @FunctionalInterface .
Поначалу эта аннотация кажется бесполезной. Даже без него наш интерфейс будет считаться функциональным, если он имеет только один абстрактный метод.
Однако давайте представим большой проект с несколькими интерфейсами; трудно все контролировать вручную. Интерфейс, который был разработан как функциональный, может быть случайно изменен путем добавления другого абстрактного метода/методов, что сделает его непригодным для использования в качестве функционального интерфейса.
Используя аннотацию @FunctionalInterface
, компилятор вызовет ошибку в ответ на любую попытку нарушить предопределенную структуру функционального интерфейса. Это также очень удобный инструмент, облегчающий понимание архитектуры нашего приложения другими разработчиками.
Итак, мы можем использовать это:
@FunctionalInterface
public interface Foo {
String method();
}
Вместо просто:
public interface Foo {
String method();
}
4. Не злоупотребляйте стандартными методами в функциональных интерфейсах
Мы можем легко добавить методы по умолчанию в функциональный интерфейс. Это приемлемо для контракта функционального интерфейса, если есть только одно объявление абстрактного метода:
@FunctionalInterface
public interface Foo {
String method(String string);
default void defaultMethod() {}
}
Функциональные интерфейсы могут быть расширены другими функциональными интерфейсами, если их абстрактные методы имеют одинаковую сигнатуру:
@FunctionalInterface
public interface FooExtended extends Baz, Bar {}
@FunctionalInterface
public interface Baz {
String method(String string);
default String defaultBaz() {}
}
@FunctionalInterface
public interface Bar {
String method(String string);
default String defaultBar() {}
}
Как и в случае с обычными интерфейсами, расширение различных функциональных интерфейсов одним и тем же методом по умолчанию может быть проблематичным .
Например, добавим метод defaultCommon()
в интерфейсы Bar
и Baz
:
@FunctionalInterface
public interface Baz {
String method(String string);
default String defaultBaz() {}
default String defaultCommon(){}
}
@FunctionalInterface
public interface Bar {
String method(String string);
default String defaultBar() {}
default String defaultCommon() {}
}
В этом случае мы получим ошибку времени компиляции:
interface FooExtended inherits unrelated defaults for defaultCommon() from types Baz and Bar...
Чтобы исправить это, метод defaultCommon()
должен быть переопределен в интерфейсе FooExtended
. Мы можем предоставить пользовательскую реализацию этого метода; однако мы также можем повторно использовать реализацию из родительского интерфейса :
@FunctionalInterface
public interface FooExtended extends Baz, Bar {
@Override
default String defaultCommon() {
return Bar.super.defaultCommon();
}
}
Важно отметить, что мы должны быть осторожны. Добавление в интерфейс слишком большого количества методов по умолчанию — не очень хорошее архитектурное решение. Это следует рассматривать как компромисс, который следует использовать только в случае необходимости для обновления существующих интерфейсов без нарушения обратной совместимости.
5. Создание функциональных интерфейсов с помощью лямбда-выражений
Компилятор позволит нам использовать внутренний класс для создания экземпляра функционального интерфейса; однако это может привести к очень многословному коду. Мы должны предпочесть использовать лямбда-выражения:
Foo foo = parameter -> parameter + " from Foo";
Над внутренним классом:
Foo fooByIC = new Foo() {
@Override
public String method(String string) {
return string + " from Foo";
}
};
Подход с лямбда-выражением можно использовать для любого подходящего интерфейса из старых библиотек. Его можно использовать для таких интерфейсов, как Runnable
, Comparator
и т. д.; однако это не означает, что мы должны пересмотреть всю нашу старую кодовую базу и все изменить.
6. Избегайте перегрузки методов функциональными интерфейсами в качестве параметров
Мы должны использовать методы с разными именами, чтобы избежать коллизий:
public interface Processor {
String process(Callable<String> c) throws Exception;
String process(Supplier<String> s);
}
public class ProcessorImpl implements Processor {
@Override
public String process(Callable<String> c) throws Exception {
// implementation details
}
@Override
public String process(Supplier<String> s) {
// implementation details
}
}
На первый взгляд это кажется разумным, но любая попытка выполнить любой из методов ProcessorImpl
:
String result = processor.process(() -> "abc");
Заканчивается ошибкой со следующим сообщением:
reference to process is ambiguous
both method process(java.util.concurrent.Callable<java.lang.String>)
in com.foreach.java8.lambda.tips.ProcessorImpl
and method process(java.util.function.Supplier<java.lang.String>)
in com.foreach.java8.lambda.tips.ProcessorImpl match
Для решения этой проблемы у нас есть два варианта. Первый вариант — использовать методы с разными именами:
String processWithCallable(Callable<String> c) throws Exception;
String processWithSupplier(Supplier<String> s);
Второй вариант — выполнить приведение вручную, что не является предпочтительным:
String result = processor.process((Supplier<String>) () -> "abc");
7. Не рассматривайте лямбда-выражения как внутренние классы
Несмотря на наш предыдущий пример, где мы по существу заменили внутренний класс лямбда-выражением, эти две концепции существенно различаются: область действия.
Когда мы используем внутренний класс, он создает новую область видимости. Мы можем скрыть локальные переменные от объемлющей области видимости, создав новые локальные переменные с теми же именами. Мы также можем использовать ключевое слово this
внутри нашего внутреннего класса в качестве ссылки на его экземпляр.
Однако лямбда-выражения работают с охватывающей областью. Мы не можем скрыть переменные из объемлющей области внутри тела лямбды. В этом случае ключевое слово this
является ссылкой на объемлющий экземпляр.
Например, в классе UseFoo
у нас есть значение переменной экземпляра :
private String value = "Enclosing scope value";
Затем в какой-нибудь метод этого класса поместите следующий код и выполните этот метод:
public String scopeExperiment() {
Foo fooIC = new Foo() {
String value = "Inner class value";
@Override
public String method(String string) {
return this.value;
}
};
String resultIC = fooIC.method("");
Foo fooLambda = parameter -> {
String value = "Lambda value";
return this.value;
};
String resultLambda = fooLambda.method("");
return "Results: resultIC = " + resultIC +
", resultLambda = " + resultLambda;
}
Если мы выполним метод scopeExperiment()
, мы получим следующий результат: Результаты: resultIC = значение внутреннего класса, resultLambda = значение объемлющей области
Как мы видим, вызывая this.value
в IC, мы можем получить доступ к локальной переменной из ее экземпляра. В случае лямбды вызов this.value
дает нам доступ к значению переменной ,
которое определено в классе UseFoo , но не к
значению
переменной , определенному внутри тела лямбды.
8. Держите лямбда-выражения короткими и не требующими пояснений
Если возможно, мы должны использовать однострочные конструкции вместо большого блока кода. Помните, лямбды должны быть выражением, а не повествованием. Несмотря на лаконичный синтаксис, лямбда-выражения должны конкретно выражать предоставляемую ими функциональность.
Это, в основном, стилистический совет, так как производительность кардинально не изменится. Однако в целом с таким кодом гораздо проще разбираться и работать.
Этого можно достичь разными способами; давайте посмотрим поближе.
8.1. Избегайте блоков кода в теле Lambda
В идеальной ситуации лямбда-выражения должны быть записаны в одну строку кода. При таком подходе лямбда представляет собой не требующую пояснений конструкцию, которая объявляет, какое действие должно быть выполнено с какими данными (в случае лямбда с параметрами).
Если у нас есть большой блок кода, функциональность лямбды не сразу понятна.
Имея это в виду, сделайте следующее:
Foo foo = parameter -> buildString(parameter);
private String buildString(String parameter) {
String result = "Something " + parameter;
//many lines of code
return result;
}
Вместо:
Foo foo = parameter -> { String result = "Something " + parameter;
//many lines of code
return result;
};
Важно отметить, что мы не должны использовать это правило «однострочной лямбды» как догму . Если у нас есть две или три строки в определении лямбды, может быть нецелесообразно извлекать этот код в другой метод.
8.2. Избегайте указания типов параметров
Компилятор в большинстве случаев способен разрешить тип лямбда-параметров с помощью вывода типа . Следовательно, добавление типа к параметрам необязательно и может быть опущено.
Мы можем сделать это:
(a, b) -> a.toLowerCase() + b.toLowerCase();
Вместо этого:
(String a, String b) -> a.toLowerCase() + b.toLowerCase();
8.3. Избегайте скобок вокруг одного параметра
Синтаксис лямбда требует круглых скобок только вокруг более чем одного параметра или когда параметр вообще отсутствует. Вот почему безопасно сделать наш код немного короче и исключить круглые скобки, когда есть только один параметр.
Итак, мы можем сделать это:
a -> a.toLowerCase();
Вместо этого:
(a) -> a.toLowerCase();
8.4. Избегайте оператора возврата и фигурных скобок
Фигурные скобки и операторы return
являются необязательными в однострочных лямбда-телах. Это означает, что их можно опустить для ясности и краткости.
Мы можем сделать это:
a -> a.toLowerCase();
Вместо этого:
a -> {return a.toLowerCase()};
8.5. Используйте ссылки на методы
Очень часто, даже в наших предыдущих примерах, лямбда-выражения просто вызывают методы, которые уже реализованы в другом месте. В этой ситуации очень полезно использовать другую функцию Java 8 — ссылки на методы .
Лямбда-выражение будет таким:
a -> a.toLowerCase();
Мы могли бы заменить его на:
String::toLowerCase;
Это не всегда короче, но делает код более читаемым.
9. Используйте «эффективно окончательные» переменные
Доступ к неконечной переменной внутри лямбда-выражений вызовет ошибку времени компиляции, но это не означает, что мы должны помечать каждую целевую переменную как конечную.
В соответствии с концепцией « эффективно финальной » компилятор рассматривает каждую переменную как финальную
, если ей присвоено значение только один раз.
Такие переменные безопасно использовать внутри лямбда-выражений, потому что компилятор будет контролировать их состояние и вызывать ошибку времени компиляции сразу после любой попытки их изменить.
Например, следующий код не скомпилируется:
public void method() {
String localVariable = "Local";
Foo foo = parameter -> {
String localVariable = parameter;
return localVariable;
};
}
Компилятор сообщит нам, что:
Variable 'localVariable' is already defined in the scope.
Такой подход должен упростить процесс обеспечения потокобезопасности выполнения лямбда-выражений.
10. Защитите переменные объекта от мутации
Одна из основных целей лямбда-выражений — использование в параллельных вычислениях, что означает, что они действительно полезны, когда речь идет о безопасности потоков.
Здесь очень помогает парадигма «эффективно окончательный», но не во всех случаях. Lambdas не может изменить значение объекта из охватывающей области. Но в случае изменяемых объектных переменных состояние может быть изменено внутри лямбда-выражений.
Рассмотрим следующий код:
int[] total = new int[1];
Runnable r = () -> total[0]++;
r.run();
Этот код допустим, так как переменная total
остается «фактически окончательной», но будет ли объект, на который она ссылается, иметь такое же состояние после выполнения лямбда-выражения? Нет!
Сохраните этот пример как напоминание, чтобы избежать кода, который может вызвать неожиданные мутации.
11. Заключение
В этой статье мы рассмотрели некоторые из лучших практик и подводных камней в лямбда-выражениях и функциональных интерфейсах Java 8. Несмотря на полезность и мощь этих новых функций, они всего лишь инструменты. Каждый разработчик должен обращать внимание при их использовании.
Полный исходный код примера доступен в этом проекте GitHub . Это проект Maven и Eclipse, поэтому его можно импортировать и использовать как есть.