Вітання! У квесті Java Syntax Pro ми вивчали лямбда-вирази та говорабо, що це ніщо інше як реалізація функціонального методу з функціонального інтерфейсу. Інакше кажучи, це реалізація якогось анонімного (невідомого) класу, його нереалізованого методу. І якщо в лекціях курсу ми заглиблювалися в маніпуляції з лямбда-виразами, зараз розглянемо, так би мовити, зворотний бік: а саме ці інтерфейси. У восьмій версії Java з'явилося поняття « функціональні інтерфейси» . Що ж це таке? Функціональним вважається інтерфейс з одним не реалізованим (абстрактним) методом. Під це визначення потрапляють багато інтерфейсів з "коробки", такі як, наприклад, розглянутий раніше інтерфейс Predicate
Consumer
Supplier
Function
UnaryOperator
Comparator
. А ще — інтерфейси, які ми створюємо самі, як, наприклад:
@FunctionalInterface
public interface Converter<T, N> {
N convert(T t);
}
У нас вийшов інтерфейс, завдання якого — перетворювати об'єкти одного типу на об'єкти іншого (такий собі адаптер). Анотація @FunctionalInterface
не є чимось надскладним і важливим, тому що її призначення - повідомити компілятор, що даний інтерфейс функціональний і повинен містити не більше одного методу. Якщо ж в інтерфейсі з цією інструкцією більше одного не реалізованого (абстрактного) методу, компілятор не пропустить даний інтерфейс, оскільки сприйматиме його як помилковий код. Інтерфейси і без цієї інструкції можуть вважатися функціональними і працюватимуть, а @FunctionalInterface
є не більш ніж додатковою страховкою. Повернемося ж до класу Comparator
. Якщо заглянути до його коду (або документації)), можна побачити, що він має набагато більше одного методу. Тоді ви запитаєте: як же у такому разі він може вважатися функціональним інтерфейсом? Абстрактні інтерфейси можуть мати методи, які не входять до обмеження одного методу:
- статичні
@FunctionalInterface
public interface Converter<T, N> {
N convert(T t);
static <T> boolean isNotNull(T t){
return t != null;
}
}
Отримавши цей метод, компілятор не став лаятись, а значить наш інтерфейс все ще функціональний.
- default методи
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 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);
}
Як приклад візьмемо , що конвертує числа з формату рядків ( ) у формат чисел ( ): Function
String
Integer
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. Яким чином запитаєте ви? А таким, що багато методів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
, але різниця полягає в тому, що він кінцевий - термінальний.
Метод із 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
Тобто кожен наш елемент помножений на себе, і так для перших чотирьох чисел. На цьому все! Буде чудово, якщо після прочитання цієї статті ви станете на крок ближче до розуміння та освоєння Stream API Java!
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ