Перейти к основному содержимому

Лямбда-выражения и функциональные интерфейсы: советы и рекомендации

· 10 мин. чтения

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, поэтому его можно импортировать и использовать как есть.