Функциональный интерфейс это интерфейс ровно с одним абстрактным методом. Именно поэтому его можно реализовать лямбдой: компилятору не нужно гадать, какой из методов вы имели в виду, он там один. Статические методы, методы по умолчанию и переопределения публичных методов Object в этот счет не идут. В самой Java таких интерфейсов много: Comparator, Runnable, а с восьмой версии еще и целое семейство в пакете java.util.function, на котором построен Stream API.

Кратко

  • Функциональный интерфейс это интерфейс ровно с одним абстрактным методом. Только такой можно реализовать лямбда-выражением.
  • Аннотация @FunctionalInterface ничего не добавляет, она просто просит компилятор проверить это правило. Без нее интерфейс с одним абстрактным методом все равно останется функциональным.
  • В счет одного метода не идут: статические методы, методы по умолчанию (default) и абстрактные методы, повторяющие публичные методы класса Object, например equals.
  • Пять базовых интерфейсов из java.util.function: Predicate (проверка), Consumer (принял и ничего не вернул), Supplier (ничего не принял и выдал), Function (принял одно, вернул другое), UnaryOperator (принял и вернул то же самое).
  • На них построен Stream API: filter берет Predicate, peek и forEach берут Consumer, generate берет Supplier, map берет Function, iterate берет UnaryOperator.
  • Кроме базовых есть их двухаргументные родственники BiPredicate, BiConsumer, BiFunction и специализации под примитивы вроде IntPredicate.
Привет! В квесте Java Syntax Pro мы изучали лямбда-выражения и говорили, что это ничто иное как реализация функционального метода из функционального интерфейса. Иными словами, это реализация некоего анонимного (неизвестного) класса, его нереализованного метода. И если в лекциях курса мы углублялись в манипуляции с лямбда-выражениями, сейчас рассмотрим, так сказать, обратную сторону: а именно эти самые интерфейсы.Акварельный набросок трех макетов интерфейса на бумаге: синий, фиолетовый и зеленый экраны с блоками и строками текста

Что такое функциональные интерфейсы

Функциональные интерфейсы появились в Java 8 и остаются важной частью языка в современных версиях (Java 17-21) Что же это? Функциональным считается интерфейс с одним не реализованным (абстрактным) методом. Под это определение попадают многие интерфейсы с “коробки”, такие как, например, рассмотренный ранее интерфейс Comparator. А еще интерфейсы, которые мы создаем сами, как, например:

@FunctionalInterface
public interface Converter<T, N> {
   N convert(T t);
}
У нас получился интерфейс, задача которого преобразовывать объекты одного типа в объекты другого (такой себе адаптер). Аннотация @FunctionalInterface не является чем-то сверхсложным и важным, так как ее предназначение это сообщить компилятору, что данный интерфейс функциональный и должен содержать ровно один абстрактный метод. Именно так это сформулировано в документации самой аннотации: «a functional interface has exactly one abstract method». Ноль абстрактных методов компилятор тоже не пропустит. Если же в интерфейсе с данной аннотацией более одного не реализованного (абстрактного) метода, компилятор не пропустит данный интерфейс, так как будет воспринимать его как ошибочный код. Интерфейсы и без данной аннотации могут считаться функциональными и будут работать, а @FunctionalInterface является не более чем дополнительной страховкой. Вернемся же к интерфейсу Comparator. Если заглянуть в его код (или документацию), можно увидеть, что у него есть гораздо больше одного метода. Тогда вы спросите: как же в таком случае он может считаться функциональным интерфейсом? Абстрактные интерфейсы могут иметь методы, которые не входят в ограничения одного метода:

Статические методы

Концепция интерфейсов подразумевает, что данная единица кода не может иметь реализованных методов. Но начиная с Java 8 появилась возможность использовать статические и дефолтные методы в интерфейсах. Статические методы привязаны непосредственно к классу, и для вызова такого метода не нужен конкретный объект данного класса. То есть данные методы гармонично вписываются в представления об интерфейсах. В качестве примера добавим в предыдущий класс статический метод проверки объекта на null:

@FunctionalInterface
public interface Converter<T, N> {
 
   N convert(T t);
 
   static <T> boolean isNotNull(T t){
       return t != null;
   }
}
Получив этот метод, компилятор не стал ругаться, а значит наш интерфейс все еще функциональный. Кстати, с Java 9 в интерфейсах разрешены еще и приватные методы: в них выносят общий код нескольких default-методов. На счет абстрактных методов они, разумеется, тоже не влияют.

Методы по умолчанию (default)

До появления Java 8, если бы нам было нужно создать в интерфейсе какой-то метод, который наследуется другими классами, мы могли бы создать только абстрактный метод, реализуемый в каждом конкретном классе. Но а что если этот метод будет одинаковым у всех классов? В таком случае чаще всего использовали абстрактные классы. Но начиная с Java 8 есть опция использовать интерфейсы с реализованными методами, то есть методами по умолчанию. При наследовании интерфейса можно переопределить эти методы или же оставить все как есть (оставить логику по умолчанию). При создании метода по умолчанию мы должны добавить ключевое слово default:

@FunctionalInterface
public interface Converter<T, N> {
 
   N convert(T t);
 
   static <T> boolean isNotNull(T t){
       return t != null;
   }
 
   default void writeToConsole(T t) {
       System.out.println("Текущий объект - " + t.toString());
   }
}
Опять же мы видим, что компилятор не начал ругаться, и мы не вышли за ограничения функционального интерфейса.

Методы класса Object

В лекции Сравнение объектов мы рассказывали о том, что все классы наследуются от класса Object. Это не касается интерфейсов. Но если у нас в интерфейсе будет абстрактный метод, совпадающий сигнатурой с каким-то методом класса Object, такой метод (или методы) не поломает наше ограничение функционального интерфейса:

@FunctionalInterface
public interface Converter<T, N> {
 
   N convert(T t);
 
   static <T> boolean isNotNull(T t){
       return t != null;
   }
 
   default void writeToConsole(T t) {
       System.out.println("Текущий объект - " + t.toString());
   }
 
   boolean equals(Object obj);
}
И снова компилятор у нас не ругается, поэтому интерфейс Converter все еще считается функциональным. Теперь вопрос: а зачем же нам ограничение одним не реализованным методом в функциональном интерфейсе? А затем, чтобы мы могли его реализовать с помощью лямбд. Давайте рассмотрим это на примере Converter. Для этого создадим класс Dog:

public class Dog {
  String name;
  int age;
  int weight;
 
  public Dog(final String name, final int age, final int weight) {
     this.name = name;
     this.age = age;
     this.weight = weight;
  }
}
И аналогичный ему Raccoon (енот):

public class Raccoon {
  String name;
  int age;
  int weight;
 
  public Raccoon(final String name, final int age, final int weight) {
     this.name = name;
     this.age = age;
     this.weight = weight;
  }
}
Предположим, у нас есть объект Dog, и нам нужно на основе его полей создать объект Raccoon. То есть Converter конвертирует объект одного типа в другой. Как это будет выглядеть:

public static void main(String[] args) {
  Dog dog = new Dog("Bobbie", 5, 3);

  Converter<Dog, Raccoon> converter = x -> new Raccoon(x.name, x.age, x.weight);

  Raccoon raccoon = converter.convert(dog);

  System.out.println("Raccoon has parameters: name - " + raccoon.name + ", age - " + raccoon.age + ", weight - " + raccoon.weight);
}
Запустив, мы получим вывод в консоль:

Raccoon has parameters: name - Bobbie, age - 5, weight - 3
И это значит, что наш метод отработал корректно.Енот и собака сидят рядом на кремовом кресле

Базовые функциональные интерфейсы Java 8

Ну а теперь рассмотрим несколько функциональных интерфейсов, которые появились в Java 8 и активно используются в современной разработке, особенно в связке со Stream API.

Predicate<T>

Predicate<T> это функциональный интерфейс для проверки соблюдения некоторого условия. Если условие соблюдается, возвращает true, иначе false:

@FunctionalInterface
public interface Predicate<T> {
   boolean test(T t);
}
В качестве примера рассмотрим создание Predicate, который будет проверять на четность числа типа Integer:

public static void main(String[] args) {
   Predicate<Integer> isEvenNumber = x -> x % 2 == 0;

   System.out.println(isEvenNumber.test(4));
   System.out.println(isEvenNumber.test(3));
}
Вывод в консоль:

true
false

Consumer<T>

Consumer<T> (с англ. “потребитель”) это функциональный интерфейс, который принимает в качестве входного аргумента объект типа T, совершает некоторые действия, но при этом ничего не возвращает:

@FunctionalInterface
public interface Consumer<T> {
   void accept(T t);
}
В качестве примера рассмотрим Consumer<String>, задача которого выводить в консоль приветствие с переданным строковым аргументом:

public static void main(String[] args) {
   Consumer<String> greetings = x -> System.out.println("Hello " + x + " !!!");
   greetings.accept("Elena");
}
Вывод в консоль:

Hello Elena !!!

Supplier<T>

Supplier<T> (с англ. поставщик) это функциональный интерфейс, который не принимает никаких аргументов, но возвращает некоторый объект типа T:

@FunctionalInterface
public interface Supplier<T> {
   T get();
}
В качестве примера рассмотрим Supplier, который будет выдавать рандомные имена из списка:

public static void main(String[] args) {
   ArrayList<String> nameList = new ArrayList<>();
   nameList.add("Elena");
   nameList.add("John");
   nameList.add("Alex");
   nameList.add("Jim");
   nameList.add("Sara");

   Supplier<String> randomName = () -> {
       int value = (int)(Math.random() * nameList.size());
       return nameList.get(value);
   };

   System.out.println(randomName.get());
}
И если мы это запустим, то увидим в консоли случайные результаты из списка имен.

Function<T,R>

Function<T,R> это функциональный интерфейс, который принимает аргумент T и приводит его к объекту типа R, который и возвращается как результат:

@FunctionalInterface
public interface Function<T, R> {
   R apply(T t);
}
В качестве примера возьмем Function<String, Integer>, который конвертирует числа из формата строк (String) в формат чисел (Integer):

public static void main(String[] args) {
   Function<String, Integer> valueConverter = x -> Integer.valueOf(x);
   System.out.println(valueConverter.apply("678"));
}
Запустив, получим вывод в консоль:

678
P.S.: если в строку мы передадим не только числа, но и другие символы, вылетит exception, а именно NumberFormatException.

UnaryOperator<T>

UnaryOperator<T> это функциональный интерфейс, который принимает в качестве параметра объект типа T, выполняет над ним некоторые операции и возвращает результат операций в виде объекта того же типа T:

@FunctionalInterface
public interface UnaryOperator<T> {
   T apply(T t);
}
UnaryOperator<T>, который своим методом apply возводит число в квадрат:

public static void main(String[] args) {
   UnaryOperator<Integer> squareValue = x -> x * x;
   System.out.println(squareValue.apply(9));
}
Вывод в консоль:

81
Все пять удобно держать в одной таблице, тем более что каждому из них соответствует свой метод Stream:
Интерфейс Абстрактный метод Что делает Метод Stream, который его принимает
Predicate<T> boolean test(T t) Проверяет условие и возвращает true или false filter
Consumer<T> void accept(T t) Что-то делает с объектом и ничего не возвращает peek, forEach
Supplier<T> T get() Ничего не принимает и выдает объект generate
Function<T,R> R apply(T t) Принимает объект одного типа, возвращает другого map
UnaryOperator<T> T apply(T t), унаследован от Function Принимает и возвращает объект одного и того же типа iterate
Мы рассмотрели пять основных функциональных интерфейсов. В Java доступно гораздо больше: BiFunction, BiPredicate, BiConsumer и другие усложненные аналоги базовых интерфейсов. Полный список можно посмотреть в официальной документации Oracle.

Функциональные интерфейсы в Stream

Как говорилось выше, эти функциональные интерфейсы плотно связаны со Stream API. Каким же образом, спросите вы?Серая белка с широко открытым ртом на темном фонеА таким, что многие методы Stream работают именно с данными функциональными интерфейсами. Давайте рассмотрим, как можно применять функциональные интерфейсы в методах Stream.

Метод с Predicate

Для примера возьмем метод класса Stream по имени filter, который в качестве аргумента принимает Predicate и возвращает Stream только с теми элементами, которые удовлетворяют условию Predicate. В контексте Stream-а это означает, что он пропускает только те элементы, которые возвращают true при использовании их в методе test интерфейса Predicate. Вот как будет выглядеть наш пример для Predicate, но уже для фильтра элементов в Stream:

public static void main(String[] args) {
   List<Integer> evenNumbers = Stream.of(1, 2, 3, 4, 5, 6, 7, 8)
           .filter(x -> x % 2==0)
           .collect(Collectors.toList());
}
В итоге список evenNumbers будет состоять из элементов {2, 4, 6, 8}. И, как мы помним, collect будет собирать все элементы в некоторую коллекцию, в нашем случае в List.

Метод с Consumer

Одним из методов в Stream, который использует функциональный интерфейс Consumer, является метод peek. Так будет выглядеть наш пример для Consumer в Stream:

public static void main(String[] args) {
   List<String> peopleGreetings = Stream.of("Elena", "John", "Alex", "Jim", "Sara")
           .peek(x -> System.out.println("Hello " + x + " !!!"))
           .collect(Collectors.toList());
}
Вывод в консоль:

Hello Elena !!!
Hello John !!!
Hello Alex !!!
Hello Jim !!!
Hello Sara !!!
Но так как метод peek работает с Consumer, модификации строк в Stream не произойдет, а сам peek вернет Stream с изначальными элементами: такими, какими они ему пришли. Поэтому список peopleGreetings будет состоять из элементов "Elena", "John", "Alex", "Jim", "Sara". Также есть часто используемый метод forEach, который аналогичен методу peek, но разница состоит в том, что он конечный, терминальный. И одно предупреждение про peek: в документации прямо сказано, что метод существует главным образом для отладки. В боевом коде на него лучше не завязываться: в некоторых конвейерах виртуальная машина имеет право его вообще не вызвать. Для настоящего действия над каждым элементом берут forEach.

Метод с Supplier

Примером метода в Stream, использующего функциональный интерфейс Supplier, является generate, который генерирует бесконечную последовательность на основе переданного ему функционального интерфейса. Воспользуемся нашим примером Supplier для вывода в консоль пяти случайных имен:

public static void main(String[] args) {
   ArrayList<String> nameList = new ArrayList<>();
   nameList.add("Elena");
   nameList.add("John");
   nameList.add("Alex");
   nameList.add("Jim");
   nameList.add("Sara");

   Stream.generate(() -> {
       int value = (int) (Math.random() * nameList.size());
       return nameList.get(value);
   }).limit(5).forEach(System.out::println);
}
И вот какой мы получим вывод в консоль:

John
Elena
Elena
Elena
Jim
Здесь мы использовали метод limit(5), чтобы задать ограничение методу generate, иначе программа выводила бы рандомные имена в консоль бесконечно.

Метод с Function

Типичный пример метода в Stream c аргументом Function это метод map, который принимает элементы одного типа, что-то с ними делает и передает дальше, но это уже могут быть элементы другого типа. Как может выглядеть пример с Function в Stream:

public static void main(String[] args) {
   List<Integer> values = Stream.of("32", "43", "74", "54", "3")
           .map(x -> Integer.valueOf(x)).collect(Collectors.toList());
}
В итоге мы получаем список чисел, но уже в формате Integer.

Метод с UnaryOperator

В качестве метода, использующего UnaryOperator как аргумент, возьмем метод класса Stream по имени iterate. Данный метод схож с методом generate: он также генерирует бесконечную последовательность, но имеет два аргумента:
  • первый это элемент, с которого начинается генерация последовательности;
  • второй это UnaryOperator, который указывает принцип генерации новых элементов с первого элемента.
Как будет выглядеть наш пример UnaryOperator, но в методе iterate:

public static void main(String[] args) {
   Stream.iterate(9, x -> x * x)
           .limit(4)
           .forEach(System.out::println);
}
Запустив, мы получим вывод в консоль:

9
81
6561
43046721
То есть каждый наш элемент умножен на самого себя, и так для первых четырех чисел.Енот стоит на траве на задних лапах и поднял обе передние лапы вверхНа этом все! Будет отлично, если после прочтения данной статьи вы станете на шаг ближе к пониманию и освоению Stream API в Java!

Вопросы и ответы

Чем функциональный интерфейс отличается от обычного?

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

Обязательна ли аннотация FunctionalInterface?

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

Почему Comparator считается функциональным, если у него много методов?

Потому что абстрактный там ровно один, это compare. Все остальное это статические методы, методы по умолчанию и переопределение equals из класса Object. Ни одна из этих трех категорий в счет абстрактных методов не идет, так прямо и написано в документации аннотации FunctionalInterface.

Чем Function отличается от UnaryOperator?

Типами. Function принимает объект типа T и возвращает объект типа R, то есть типы могут быть разными. UnaryOperator это частный случай, когда тип входа и выхода совпадает. В документации он объявлен как наследник Function с обоими параметрами типа T.

Что выбрать, Consumer или Function?

Смотрите на возвращаемое значение. Если метод должен что-то отдать, берите Function. Если он только совершает действие, печатает, пишет в базу, складывает в коллекцию, берите Consumer: он ничего не возвращает. В Stream это хорошо видно на методе peek, который получает Consumer и поэтому элементы не меняет.

Можно ли написать свой функциональный интерфейс?

Можно и нужно, если ни один готовый не подходит по смыслу. Достаточно объявить интерфейс с одним абстрактным методом и по желанию пометить его аннотацией FunctionalInterface. Интерфейс Converter из этой статьи ровно такой пример.

Читайте также