Лямбда-выражения в Java 8

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

Пример

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

public class SubscribeService {
    List<String> emails;

    public SubscribeService(List emails) {
        this.emails = emails;
    }

    public List getAllEmails() {
        return emails;
    }
    // другие методы, выдающие списки электронных адресов
}

Первый метод простой – выдача всех адресов. Но другие методы сложнее, например:

    //email из группы 1 
    public List getGroup1Emais() {
        List<String> list = new ArrayList<>();
        for (String email : emails) {
            if (email.contains(".group1")) {
                list.add(email);
            }
        }
        return list;
    }

    //email, начинающиеся с буквы "а"
    public List getAEmails() {
        List<String> list = new ArrayList<>();
        for (String email : emails) {
            if (email.startsWith("a")) {
                list.add(email);
            }
        }
        return list;
    }
    //email, начинающиеся с буквы "а" из группы1
    public List getGroup1AEmails() {
        List<String> list = new ArrayList<>();
        for (String email : emails) {
            if (email.startsWith("a") && email.contains("group1")) {
                list.add(email);
            }
        }
        return list;
    }

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

Заметьте, что во всех вышеприведенных примерах мы используем условие, другими словами функцию, которая возвращает boolean и принимает один аргумент типа String.

Именно здесь и можно применить синтаксический сахар, составляющий суть лямбда-выражений – это блок кода, описывающий фунцию интерфейса; функция эта выполняется при надобности (в нашем случае в тот момент, когда надо проверить условие см. функцию filter.test()), но передать ее можно заранее как аргумент.

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

    public List getEmails(Predicate<String> filter) {
        List<String> list = new ArrayList<>();
        for (String email : emails) {
            if (filter.test(email)) {
                list.add(email);
            }
        }
        return list;
    }

А уж при вызове метода мы будем передавать лямбда-выражение:

 service.getEmails((email) -> true);

Выше мы получили все электронные адреса.

Обратите внимание, что аргумент тут интерфейс Predicate из стандартной JDK 8.

Далее получим адреса, начинающиеся с буквы “a”:

 service.getEmails((email)->email.startsWith("a"));

Немного о синтаксисе

Пора сказать пару слов о синтаксисе лямбда-выражения. По сути лямбда-выражение – это метод интерфейса, причем его единственный метод. Слева от стрелки “->” передается список аргументов метода, а справа – сам текст метода.

В лямбда-выражениях можно опускать слово return и точку с запятой, если правая часть состоит из одной строки. Также, не нужно указывать типы параметров, обычно компялатор их определяет из кода.

В аргументе getEmail() мы передаем интерфейс Predicate. Но как компилятор определяет, какой именно метод  интерфейса передается?

Немного о функциональных интерфейсах

Predicate – это функциональный интерфейс, что означает, что он состоит только из одного метода (не считая статических и методов по умолчанию). Этот метод и вызывается для тестирования нашего условия. Мы можем передавать непосредственно содержимое этого метода, не указывания его имени. Компилятор и так догадается, что это за метод, поскольку в интерфейсе Predicate он всего один:

@FunctionalInterface
public interface Predicate<T> {
    boolean test(T t);

    //остальные методы статические либо default, они нам не мешают идентифицировать test
}

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

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

Таким образом, компилятор понимает, что  email->email.startsWith(“a”) – это реализация метода test() интерфейса Predicate.

Без лямбда-выражения реализация метода выглядела бы так:
 public boolean test(String email) {
      return email.startsWith("a");
 }

А вызов метода getEmails() без лямбда-выражения выглядел бы так:

 service.getEmails(new Predicate<String>() {
     @Override
     public boolean test(String email) {
          return email.startsWith("a");
     }
 });

Как видите, это по сути анонимный класс. Мы  передаем как аргумент экземпляр анонимного класса, реализующего единственный метод. И он когда надо вызывается.

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

Еще раз: поскольку метод единственный, и не надо уточнять его название, можно передать в параметр типа Predicate саму реализацию метода и аргумент. Idea сама предлагает заменить такой навороченный участок кода кратким лямбда-выражением.

Допишем остальные методы

Допишем вызовы с лямбда-выражениями оставшихся двух методов.

Этот получает электронные адреса из группы1:

service.getEmails((email) -> email.contains("group1"));

А этот  получает электронные адреса на букву “а” из группы1:

service.getEmails(email -> email.startsWith("a") && email.contains("group1"));

Подробнее о функциональных интерфейсах

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

Вот примеры, угадайте является ли следующий интерфейс функциональным:

public interface Movable {
}

Ответ нет, в интерфейсе Movable нет ни одного абстрактного метода, а надо один.

public interface Runnable extends Movable {
  public void run();
}

Ответ да, в интерфейсе Runnable ровно один метод (из Movable не наследуется ни одного).

public interface FastRunnable extends Runnable {
    default void fastrun(){
        System.out.println("run fast");
    }
}

Интерфейс FastRunnable функциональный, поскольку он наследует один метод из Runnable, а default-метод fastrun() не учитывается.

public interface Swimming extends Movable{
    public static void ss(){};
}

Интерфейс Swimming не является функциональным, поскольку из Movable  методы не наследуются, а метод ss() статический, а значит не учитывается.

Можно поупражняться в IDE в написании интерфейсов, проверяя ответ постановкой аннотации @FunctionalInterface вверху. Если интерфейс не фукциональный, компилятор выдаст ошибку.

Например, Runnable – функциональный, а значит можно поставить аннотацию @FunctionalInterface без проблем:

@FunctionalInterface
public interface Runnable extends Movable {
  public void run();
}

Подробнее о синтаксисе лямбда-выражения

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

email -> email.startsWith("a")

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

( String email ) -> { return email.startsWith("a"); }

Слева от стрелки параметры, а справа тело метода.

Параметр метода здесь один, он имеет тип String. Возвращаемое значение тоже имеет тип String.

Это значит, что данное лямбда-выражение подойдет к любому функциональному интерфейсу, имеющему метод с одним параметром типа String и возвращающим значение такого же типа. Например, вместо встроенного в JDK 8 интерфейса Predicate мы могли бы определить и использовать любой собственный интерфейс:

public interface MyInterface1<String> {

    boolean test(String myparam1);
}
//Или такой:
public interface MyInterface2<String> {

    boolean test(String myparam2);
}
//или такой:
public interface MyInterface3<T> {

    boolean test(T myparam3);
}

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

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

Когда можно опустить круглые скобки

Рассмотрим внимательнее левую часть двух вышеприведенных выражений:

Краткая форма
Краткая формаё

А это полная форма того же лямбда-выражения:

Полная форма

Во втором выражении присутствует тип аргумента и круглые скобки, а в первом нет.

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

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

Вот примеры корректно написанных лямбда-выпажений:

() -> new Duck()

d -> {return d.quack();}

(Duck d) -> d.quack()

(Animal a, Duck d) -> d.quack()

А вот примеры некорректных лямбда-выражений:

Duck d -> d.quack() // DOES NOT COMPILE
a, d -> d.quack() // DOES NOT COMPILE
Animal a, Duck d -> d.quack() // DOES NOT COMPILE
  1. В первом выражении задан тип, а значит нужны скобки.
  2. Во втором аргументов два, а не один, что означает, снова нужны скобки.
  3. В третьем снова два аргумента, а не один.

Фигурные скобки справа

Теперь сравним правую часть выражений, во втором выражении есть фигурные скобки {}.

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

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

Приведем еще примеры корректный лямбда-выражений:

() -> true // 0 parameters
a -> {return a.startsWith("test");} // 1 parameter
(String a) -> a.startsWith("test") // 1 parameter
(int x) -> {} // 1 parameter
(int y) -> {return;} // 1 parameter

(a, b) -> a.startsWith("test") // 2 parameters
(String a, String b) -> a.startsWith("test") // 2 parameters

Найдите ошибки в лямбда-выражениях

А теперь некорректные, угадайте, что с ними не так:

a, b -> a.startsWith("test") // DOES NOT COMPILE
c -> return 10; // DOES NOT COMPILE
a -> { return a.startsWith("test") } // DOES NOT COMPILE
  1. В первом нужны круглые скобки слева, поскольку аргументов два.
  2. Во втором – фигурные скобки справа.
  3. В третьем пропущена точка с запятой (справа).

Привильный вариант такой:

(a, b) -> a.startsWith("test")
c -> { return 10; }
a -> { return a.startsWith("test"); }

А почему некорректны вот эти выражения, еще не упоминалось:

(int y, z) -> {int x=1; return y+10; } // DOES NOT COMPILE
(String s, z) -> { return s.length()+z; } // DOES NOT COMPILE
(a, Animal b, c) -> a.getName() // DOES NOT COMPILE

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

Чтобы сделать выражения корректными, надо либо убрать все типы (компилятор обычно их может определить из кода, не переживайте), либо, наоборот, указать все типы:

(y, z) -> {int x=1; return y+10; }
(String s, int z) -> { return s.length()+z; }
(a, b, c) -> a.getName()

Вот еще некорретное выражение:

(a, b) -> { int a = 0; return 5;} // DOES NOT COMPILE

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

Вот так будет правильно:

(a, b) -> { int c = 0; return 5;}

Method references

Но это еще не все, синтаксический сахар простирается дальше, и существует такой механизм молчаливого проброса параметров, как method references.

Продемонстрируем его на примере всеми известного метода

System.out.println(argument);

Для этого возьмем еще интерфейс Consumer из jdk, поскольку его метод имеет ту же сигнатуру, что println():

@FunctionalInterface
public interface Consumer<T> {
   
    void accept(T t);
   
    //default method
}

Допустим, в теле метода accept() мы ничего сами не делаем, в просто передаем аргумент в другой метод System.out.println():

 Consumer<String> consumer= a->System.out.println(a);

Поскольку сигнатуры нашего consumer и println()  одинаковы, а дополнительных действий в теле метода нет, то аргумент a слева и справа упоминать избыточно, так как мы его просто передаем дальше. Для такого случая всегда наготове очередной синтаксический сахар, называемый method references:

 Consumer<String> consumer=System.out::println;

Последняя запись эквивалентна предыдущей.

Дж. Блох рекомендует использовать method references везде, где это возможно.

Стандартные функциональные интерфейсы и примеры их использования в JDK 8

Как уже говорилось, лучше использовать стандартные интерфейсы, чем писать свои. В пакете java.util.function их целых 43.

Но запомнить надо всего шесть, остальные интерфейсы производные от остальных. Например, мы выше использовали интерфейс Predicate, который возвращает boolean и принимает один аргумент. А есть BiPredicate – он такой же, но принимает два аргумента.

Вот эти шесть интерфейсов (в третьей колонке примеры с method reference, который мы расматривали выше):

Интерфейс Сигнатура Пример из JDK
UnaryOperator T apply(T t) String::toLowerCase
BinaryOperator  T apply(T t1, T t2) BigInteger::add
Predicate boolean test(T t) Collection::isEmpty
Function R apply(T t) Arrays::asList
Supplier T get() Instant::now
Consumer  void accept(T t) System.out::println
По сигнатуре методов из третьего столбца легко понять сигнатуру и предназначение методов из второго, даже не вникая в буквы T, R, поскольку эти сигнатуры одинаковы.

А название из первого поясняет суть той или иной сигнатуры.

Например,  BigInteger::add – бинарный оператор.

А вот (почти) все интерфейсы из java.util.function, которые поместились на скриншот:

java.utl.function
java.util.function

Проще зайти и посмотреть их сигнатуру, чем перечислять тут. Но они производятся по таким принципам:

    • Есть дополнительные три интерфейса BiFunction, BiPredicate, BiConsumer, принимающие два аргумента, а не один, в отличие от исходных Function, Predicate, Consumer. Пример:
    • Для всех интерфейсов, где это имеет смысл, есть производные с префиксом Double, Int, Long – они принимают конкретный примитивный тип, а не Generic, как исходные например:
      @FunctionalInterface
      public interface DoublePredicate {
      
          boolean test(double value);
         //...
      }
    • Для интерфейса Function помимо производных, уточняющих примитивный тип аргумента, есть производные с ..To.., уточняющие возвращаемый примитивный тип, например:
      public interface IntToDoubleFunction {
      
          double applyAsDouble(int value);
      }
      
    • Особняком стоит BooleanSupplier, возвращающий boolean:
      @FunctionalInterface
      public interface BooleanSupplier {
          boolean getAsBoolean();
      }
      

Итоги

Пока итогов нет, статья будет дополняться. Исходный код некоторых примеров доступен на GitHub.

 

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *