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

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

1. обзор

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

Дальнейшее чтение:

Почему локальные переменные, используемые в лямбдах, должны быть окончательными или эффективными?

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

Read more

Java 8 - мощное сравнение с лямбдами

Элегантная сортировка в Java 8 - Лямбда-выражения идут мимо синтаксического сахара и привносят мощную функциональную семантику в Java.

Read more

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 fn) {
    return fn.apply(string);
}

Чтобы выполнить это, мы можем написать:

Function 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();
    default void defaultMethod() {}
}

Функциональные интерфейсы могут быть расширены другими функциональными интерфейсами, если их абстрактные методы имеют такую ​​же сигнатуру. Например:

@FunctionalInterface
public interface FooExtended extends Baz, Bar {}

@FunctionalInterface
public interface Baz {
    String method();
    default void defaultBaz() {}
}

@FunctionalInterface
public interface Bar {
    String method();
    default void defaultBar() {}
}

Как и в случае с обычными интерфейсами, расширение различных функциональных интерфейсов одним и тем же методом по умолчанию может быть проблематичным. Например, предположим, что интерфейсыBar иBaz имеют метод по умолчаниюdefaultCommon().. В этом случае вы получите ошибку времени компиляции:

interface Foo inherits unrelated defaults for defaultCommon() from types Baz and Bar...

Чтобы исправить это, методdefaultCommon() должен быть переопределен в интерфейсеFoo. Конечно, вы можете предоставить индивидуальную реализацию этого метода. Но если вы хотите использовать одну из реализаций родительских интерфейсов (например, из интерфейсаBaz), добавьте следующую строку кода в тело методаdefaultCommon():

Baz.super.defaultCommon();

Но будь осторожен. Adding too many default methods to the interface is not a very good architectural decision. Это следует рассматривать как компромисс, который будет использоваться только при необходимости, для обновления существующих интерфейсов без нарушения обратной совместимости.

5. Создание функциональных интерфейсов с помощью лямбда-выражений

Компилятор позволит вам использовать внутренний класс для создания экземпляра функционального интерфейса. Однако это может привести к очень подробному коду. Вы должны предпочесть лямбда-выражения:

Foo foo = parameter -> parameter + " from Foo";

по внутреннему классу:

Foo fooByIC = new Foo() {
    @Override
    public String method(String string) {
        return string + " from Foo";
    }
};

The lambda expression approach can be used for any suitable interface from old libraries. Его можно использовать для таких интерфейсов, какRunnable,Comparator и т. д. However, thisdoesn’t mean that you should review your whole older codebase and change everything.

6. Избегайте методов перегрузки с функциональными интерфейсами в качестве параметров

Используйте методы с разными именами, чтобы избежать коллизий; давайте посмотрим на пример:

public interface Processor {
    String process(Callable c) throws Exception;
    String process(Supplier s);
}

public class ProcessorImpl implements Processor {
    @Override
    public String process(Callable c) throws Exception {
        // implementation details
    }

    @Override
    public String process(Supplier s) {
        // implementation details
    }
}

На первый взгляд это кажется разумным. Но любая попытка выполнить любой из методовProcessorImpl:

String result = processor.process(() -> "abc");

заканчивается ошибкой со следующим сообщением:

reference to process is ambiguous
both method process(java.util.concurrent.Callable)
in com.example.java8.lambda.tips.ProcessorImpl
and method process(java.util.function.Supplier)
in com.example.java8.lambda.tips.ProcessorImpl match

Чтобы решить эту проблему, у нас есть два варианта. The first is to use methods with different names:с

String processWithCallable(Callable c) throws Exception;

String processWithSupplier(Supplier s);

The second is to perform casting manually. Это нежелательно.

String result = processor.process((Supplier) () -> "abc");

7. Не относитесь к лямбда-выражениям как к внутренним классам

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

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

Однако лямбда-выражения работают с закрывающей областью видимости. Вы не можете скрыть переменные из области видимости внутри тела лямбды. В этом случае ключевое словоthis является ссылкой на включающий экземпляр.

Например, в классеUseFoo у вас есть переменная экземпляраvalue:

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(), вы получите следующий результат:Results: resultIC = Inner class value, resultLambda = Enclosing scope value

Как видите, вызываяthis.value в IC, вы можете получить доступ к локальной переменной из ее экземпляра. Но в случае лямбда-выражения вызовthis.value дает вам доступ к переменнойvalue, которая определена в классеUseFoo, но не к переменнойvalue, определенной внутри тело лямбды.

8. Делайте лямбда-выражения короткими и понятными

Если возможно, используйте однострочные конструкции вместо большого блока кода. Помнитеlambdas should be anexpression, not a narrative. Несмотря на лаконичный синтаксис,lambdas should precisely express the functionality they provide.

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

Этого можно добиться разными способами - давайте рассмотрим подробнее.

8.1. Избегайте блоков кода в теле лямбды

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

Если у вас большой блок кода, функциональность лямбда не сразу становится понятной.

Имея это в виду, сделайте следующее:

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;
};

However, please don’t use this “one-line lambda” rule as dogma. Если у вас есть две или три строки в определении лямбды, извлечение этого кода в другой метод может оказаться бесполезным.

8.2. Избегайте указания типов параметров

В большинстве случаев компилятор может определять тип лямбда-параметров с помощьюtype inference. Поэтому добавление типа к параметрам является необязательным и может быть опущено.

Сделай это:

(a, b) -> a.toLowerCase() + b.toLowerCase();

вместо этого:

(String a, String b) -> a.toLowerCase() + b.toLowerCase();

8.3. Избегайте круглых скобок вокруг одного параметра

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

Итак, сделайте это:

a -> a.toLowerCase();

вместо этого:

(a) -> a.toLowerCase();

8.4. Избегайте возврата и скобок

ОператорыBraces иreturn необязательны в однострочных лямбда-телах. Это означает, что они могут быть опущены для ясности и краткости.

Сделай это:

a -> a.toLowerCase();

вместо этого:

a -> {return a.toLowerCase()};

8.5. Ссылки на методы

Очень часто, даже в наших предыдущих примерах, лямбда-выражения просто вызывают методы, которые уже реализованы в других местах. В этой ситуации очень полезно использовать другую функцию Java 8:method references.

Итак, лямбда-выражение:

a -> a.toLowerCase();

может быть заменен на:

String::toLowerCase;

Это не всегда короче, но делает код более читабельным.

9. Используйте переменные «Фактически окончательные»

Доступ к неконечной переменной внутри лямбда-выражений вызовет ошибку во время компиляции. But it doesn’t mean that you should mark every target variable as final.с

Согласно концепции «effectively final» компилятор обрабатывает каждую переменную какfinal,, если она назначается только один раз.

Безопасно использовать такие переменные внутри лямбды, потому что компилятор будет контролировать их состояние и вызывать ошибку во время компиляции сразу после любой попытки их изменения.

Например, следующий код не будет компилироваться:

public void method() {
    String localVariable = "Local";
    Foo foo = parameter -> {
        String localVariable = parameter;
        return localVariable;
    };
}

Компилятор сообщит вам, что:

Variable 'localVariable' is already defined in the scope.

Этот подход должен упростить процесс обеспечения лямбда-исполнения потокобезопасным.

10. Защита переменных объекта от мутации

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

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

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

int[] total = new int[1];
Runnable r = () -> total[0]++;
r.run();

Этот код является допустимым, поскольку переменнаяtotal остается «фактически окончательной». Но будет ли объект, на который он ссылается, иметь такое же состояние после выполнения лямбды? No!

Сохраните этот пример как напоминание, чтобы избежать кода, который может вызвать неожиданные мутации.

11. Заключение

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

Полныйsource code для примера доступен вthis GitHub project - это проект Maven и Eclipse, поэтому его можно импортировать и использовать как есть.