Вітання! У квесті Java Syntax Pro ми вивчали лямбда-вирази та говорабо, що це ніщо інше як реалізація функціонального методу з функціонального інтерфейсу. Інакше кажучи, це реалізація якогось анонімного (невідомого) класу, його нереалізованого методу. І якщо в лекціях курсу ми заглиблювалися в маніпуляції з лямбда-виразами, зараз розглянемо, так би мовити, зворотний бік: а саме ці інтерфейси. Функціональні інтерфейси в Java - 1У восьмій версії Java з'явилося поняття « функціональні інтерфейси» . Що ж це таке? Функціональним вважається інтерфейс з одним не реалізованим (абстрактним) методом. Під це визначення потрапляють багато інтерфейсів з "коробки", такі як, наприклад, розглянутий раніше інтерфейс Comparator. А ще — інтерфейси, які ми створюємо самі, як, наприклад:
@FunctionalInterface
public interface Converter<T, N> {
   N convert(T t);
}
У нас вийшов інтерфейс, завдання якого — перетворювати об'єкти одного типу на об'єкти іншого (такий собі адаптер). Анотація @FunctionalInterfaceне є чимось надскладним і важливим, тому що її призначення - повідомити компілятор, що даний інтерфейс функціональний і повинен містити не більше одного методу. Якщо ж в інтерфейсі з цією інструкцією більше одного не реалізованого (абстрактного) методу, компілятор не пропустить даний інтерфейс, оскільки сприйматиме його як помилковий код. Інтерфейси і без цієї інструкції можуть вважатися функціональними і працюватимуть, а @FunctionalInterfaceє не більш ніж додатковою страховкою. Повернемося ж до класу Comparator. Якщо заглянути до його коду (або документації)), можна побачити, що він має набагато більше одного методу. Тоді ви запитаєте: як же у такому разі він може вважатися функціональним інтерфейсом? Абстрактні інтерфейси можуть мати методи, які не входять до обмеження одного методу:
  • статичні
Концепція інтерфейсів передбачає, що ця одиниця коду може мати реалізованих методів. Але з Java 8 з'явилася можливість використовувати статичні та дефолтні методи в інтерфейсах. Статичні методи прив'язані безпосередньо до класу, і виклику такого методу не потрібен конкретний об'єкт даного класу. Тобто ці методи гармонійно вписуються в уявлення про інтерфейси. Як приклад додамо до попереднього класу статичний метод перевірки об'єкта на null:
@FunctionalInterface
public interface Converter<T, N> {

   N convert(T t);

   static <T> boolean isNotNull(T t){
       return t != null;
   }
}
Отримавши цей метод, компілятор не став лаятись, а значить наш інтерфейс все ще функціональний.
  • 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 - Bobbbie, age - 5, weight - 3
І це означає, що наш метод відпрацював коректно.Функціональні інтерфейси в Java - 2

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

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

Predicate

Predicate— функціональний інтерфейс для перевірки певної умови. Якщо умова дотримується, повертає 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

Consumer(з англ. - "споживач") - функціональний інтерфейс, який приймає як вхідний аргумент об'єкт типу T, здійснює деякі дії, але при цьому нічого не повертає:
@FunctionalInterface
public interface Consumer<T> {
   void accept(T t);
}
Як приклад розглянемо завдання якого — виводити в консоль вітання з переданим рядковим аргументом: Consumer
public static void main(String[] args) {
   Consumer<String> greetings = x -> System.out.println("Hello " + x + " !!!");
   greetings.accept("Elena");
}
Виведення в консоль:

Hello Elena !!!

Supplier

Supplier(з англ. - Постачальник) - функціональний інтерфейс, який не приймає жодних аргументів, але повертає деякий об'єкт типу 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

Function- цей функціональний інтерфейс приймає аргумент T і приводить його до об'єкта типу R, який і повертається як результат:
@FunctionalInterface
public interface Function<T, R> {
   R apply(T t);
}
Як приклад візьмемо , що конвертує числа з формату рядків ( ) у формат чисел ( ): FunctionStringInteger
public static void main(String[] args) {
   Function<String, Integer> valueConverter = x -> Integer.valueOf(x);
   System.out.println(valueConverter.apply("678"));
}
Запустивши, отримаємо виведення в консоль:

678
PS: якщо в рядок ми передамо не лише цифри, а й інші символи, вилетить exception - NumberFormatException.

UnaryOperator

UnaryOperator- функціональний інтерфейс, що приймає як параметр об'єкт типу T, виконує над ним деякі операції і повертає результат операцій у вигляді об'єкта того ж типу T:
@FunctionalInterface
public interface UnaryOperator<T> {
   T apply(T t);
}
UnaryOperator, Який своїм способом applyзводить число в квадрат:
public static void main(String[] args) {
   UnaryOperator<Integer> squareValue = x -> x * x;
   System.out.println(squareValue.apply(9));
}
Виведення в консоль:

81
Ми розглянули п'ять функціональних інтерфейсів. Це не все, що доступне нам, починаючи з Java 8, — це основні інтерфейси. Інші з доступних – це їх ускладнені аналоги. Повний список можна переглянути в офіційній документації Oracle .

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

Як говорилося вище, ці функціональні інтерфейси щільно пов'язані зі Stream API. Яким чином запитаєте ви? Функціональні інтерфейси в Java - 3А таким, що багато методів Streamпрацюють саме з цими функціональними інтерфейсами. Давайте розглянемо, як можна використовувати функціональні інтерфейси в способах Stream.

Метод з Predicate

Наприклад візьмемо метод класу Streamfilter, який як аргумент приймає 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, але різниця полягає в тому, що він кінцевий - термінальний.

Метод із 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аргументом 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
Тобто кожен наш елемент помножений на себе, і так для перших чотирьох чисел. Функціональні інтерфейси в Java - 4На цьому все! Буде чудово, якщо після прочитання цієї статті ви станете на крок ближче до розуміння та освоєння Stream API Java!