JavaRush /Java блог /Random UA /Популярно про лямбда-вираження в Java. З прикладами та за...
Стас Пасинков
26 рівень
Киев

Популярно про лямбда-вираження в Java. З прикладами та завданнями. Частина 1

Стаття з групи Random UA
Для кого призначено цю статтю?
  • для тих, хто вважає, що вже непогано знає Java Core, але гадки не має про лямбда-вирази в Java. Або, може, щось уже чув про лямбди, але без подробиць.
  • для тих, хто має якесь розуміння лямбда-виразів, але використовувати їх досі боязко і незвично.
Якщо ви не входите в одну з цих категорій, вам ця стаття може здатися нудною, некоректною і взагалі «не айс». У такому випадку або сміливо проходьте повз, або, якщо ви добре знаєтеся на темі, запропонуйте в коментарях, як я міг би поліпшити або доповнити статтю. Матеріал не претендує на якусь академічну цінність, і вже тим більше на новизну. Швидше навпаки: в ній я спробую описати складні (для когось) речі якомога простіше. На написання мене спонукало прохання пояснити stream api. Я подумав, і вирішив, що без розуміння лямбда-виразів частина моїх прикладів про «стрим» буде незрозуміла. Тож для початку — лямбди. Популярно про лямбда-вираження в Java.  З прикладами та завданнями.  Частина 1 - 1Які знання потрібні для розуміння цієї статті:
  1. Розуміння об'єктно-орієнтованого програмування (далі ООП), а саме:
    • знання що таке класи, об'єкти, яка між ними різниця;
    • знання що таке інтерфейси, чим вони відрізняються від класів, яка між ними (інтерфейсами та класами) зв'язок;
    • знання що таке метод, як його викликати, що таке абстрактний метод (або метод без реалізації), що таке параметри/аргументи методу, як їх передавати туди;
    • модифікатори доступу, статичні методи/змінні, фінальні методи/змінні;
    • успадкування (класів, інтерфейсів, множинне успадкування інтерфейсів).
  2. Знання Java Core: узагальнені типи (generics), колекції (списки), потоки (threads).
Ну що ж, почнемо.

Трохи історії

Лямбда-вирази дійшли Java з функціонального програмування, а туди — з математики. У середині 20-го століття в Америці в університеті Прінстона працював якийсь Алонзо Черч, який дуже любив математику і всілякі абстракції. Саме Алонзо Черч і придумав лямбда-числення, яке спочатку було набором абстрактних ідей і ніяк не стосувалося програмування. У той же час у тому ж Прінстонському університеті працювали такі математики, як Алан Тьюрінг та Джон фон Нейман. Все склалося воєдино: Черч вигадав систему лямбда-обчислень, Тьюрінг розробив свою абстрактну обчислювальну машину, нині відому під назвою «машина Тьюринга». Ну, а фон Нейман запропонував схему архітектури обчислювальних машин, яка лягла в основу сучасних комп'ютерів (і зараз називається «архітектура фон Неймана»). На той час ідеї Алонзо Черча не здобули такої гучної популярності, як роботи його колег (за винятком сфери «чистої» математики). Тим не менш, трохи пізніше Джон МакКарті (також випускник Прінстонського університету, на момент оповідання — співробітник Массачусетського технологічного інституту) зацікавився ідеями Черча. На їх основі, в 1958 він створив першу функціональну мову програмування Lisp. А через 58 років ідеї функціонального програмування проникли в Java під номером 8. Не минуло й 70 років... Насправді — не найдовший термін застосування математичної ідеї на практиці. на момент оповідання – співробітник Массачусетського технологічного інституту) зацікавився ідеями Чорча. На їх основі, в 1958 він створив першу функціональну мову програмування Lisp. А через 58 років ідеї функціонального програмування проникли в Java під номером 8. Не минуло й 70 років... Насправді — не найдовший термін застосування математичної ідеї на практиці. на момент оповідання – співробітник Массачусетського технологічного інституту) зацікавився ідеями Чорча. На їх основі, в 1958 він створив першу функціональну мову програмування Lisp. А через 58 років ідеї функціонального програмування проникли в Java під номером 8. Не минуло й 70 років... Насправді — не найдовший термін застосування математичної ідеї на практиці.

Суть

Лямбда-вираз це така функція. Можете вважати, що це звичайний метод Java, тільки його особливість у тому, що його можна передавати в інші методи як аргумент. Так, стало можливим передавати в методи не лише числа, рядки та котиків, а й інші методи! Коли це може знадобитися? Наприклад, якщо ми хочемо передати якийсь callback. Нам потрібно, щоб той метод, який ми викликаємо, мав можливість викликати якийсь інший метод, який ми передамо йому. Тобто щоб у нас була можливість у якихось випадках передавати один callback, а в інших — інший. І наш метод, який би приймав наші callback-і, викликав би їх. Простий приклад – сортування. Припустимо, ми пишемо якесь хитре сортування, яке виглядає приблизно ось так:
public void mySuperSort() {
    // ... тут щось робимо
    if(compare(obj1, obj2) > 0)
    // ... і тут щось робимо
}
Там, де ifми викликаємо метод compare(), передаємо туди два об'єкти, які ми порівнюємо, і хочемо дізнатися який з цих об'єктів «більше». Той, що «більше» ми поставимо перед тим, що «менше». Я написав «більше» у лапках, тому що ми пишемо універсальний метод, який вмітиме сортувати не тільки за зростанням, а й за спаданням (у такому разі «більше» буде той об'єкт, який по суті менший, і навпаки). Щоб задати правило, як саме ми хочемо відсортувати, нам потрібно якимось чином передати його до нашого методу mySuperSort(). У такому разі ми зможемо якось керувати нашим методом під час його виклику. Зрозуміло, можна написати два окремих методи mySuperSortAsc()і mySuperSortDesc()для сортування за зростанням та зменшенням. Або передавати якийсь параметр усередину методу (припустимоbooleanі якщо trueсортувати за зростанням, а якщо false- за спаданням). А що, якщо ми хочемо відсортувати не якусь просту структуру, а, наприклад, список масивів рядків? Як наш метод mySuperSort()знатиме, за яким принципом сортувати ці масиви рядків? По розміру? За загальною довжиною слів? Може, за алфавітом, залежно від першого рядка в масиві? А що, якщо нам у якихось випадках треба відсортувати список масивів за розміром масиву, а в іншому випадку за сумарною довжиною слів у масиві? Я думаю, ви вже чули про компараторів і про те, що в таких випадках ми просто передаємо в наш метод сортування об'єкт компаратора, в якому ми описуємо правила, за якими хочемо сортувати. Оскільки стандартний метод sort()реалізований за тим самим принципом, що йmySuperSort(), у прикладах я використовуватиму саме стандартний sort().
String[] array1 = {"Мама", "мила", "раму"};
String[] array2 = {"я", "дуже", "кохаю", "java"};
String[] array3 = {"мир", "праця", "тдорівнюєь"};

List<String[]> arrays = new ArrayList<>();
arrays.add(array1);
arrays.add(array2);
arrays.add(array3);

Comparator<String[]> sortByLength = new Comparator<String[]>() {
    @Override
    public int compare(String[] o1, String[] o2) {
        return o1.length - o2.length;
    }
};

Comparator<String[]> sortByWordsLength = new Comparator<String[]>() {
    @Override
    public int compare(String[] o1, String[] o2) {
        int length1 = 0;
        int length2 = 0;
        for (String s : o1) {
            length1 += s.length();
        }
        for (String s : o2) {
            length2 += s.length();
        }
        return length1 - length2;
    }
};

arrays.sort(sortByLength);
Результат:
  1. мама мила раму
  2. мир праця тдорівнюєь
  3. я дуже люблю java
Тут масиви відсортовані за кількістю слів у кожному масиві. Масив, де менше слів - той і вважається "меншим". Ось чому він стоїть на початку. Той, де більше слів вважається «більше», і виявляється в кінці. Якщо в метод sort()ми передамо інший компаратор (sortByWordsLength), то і результат буде інший:
  1. мир праця тдорівнюєь
  2. мама мила раму
  3. я дуже люблю java
Наразі масиви відсортовані за загальною кількістю букв у словах такого масиву. У першому випадку 10 літер, у другому 12, і в третьому 15. Якщо у нас використовується лише один компаратор, то ми можемо не заводити під нього окрему змінну, а просто створити об'єкт анонімного класу прямо в момент виклику методу sort(). Приблизно так:
String[] array1 = {"Мама", "мила", "раму"};
String[] array2 = {"я", "дуже", "кохаю", "java"};
String[] array3 = {"мир", "праця", "тдорівнюєь"};

List<String[]> arrays = new ArrayList<>();
arrays.add(array1);
arrays.add(array2);
arrays.add(array3);

arrays.sort(new Comparator<String[]>() {
    @Override
    public int compare(String[] o1, String[] o2) {
        return o1.length - o2.length;
    }
});
Результат буде такий самий, як і в першому випадку. Завдання 1 . Переписати цей приклад так, щоб він сортував масиви не за зростанням кількості слів у масиві, а за спаданням. Це ми вже всі знаємо. Ми вміємо передавати об'єкти в методи, ми можемо передати той чи інший об'єкт у метод залежно від того, що нам зараз треба, і всередині того методу, куди ми передаємо такий об'єкт, буде викликаний той метод, для якого ми написали реалізацію. Виникає питання: до чого тут взагалі лямбда-вирази? При тому, що лямбда це і є такий об'єкт, який містить рівно один метод. Такий собі об'єкт-метод. Метод, запакований в об'єкт. Просто у них трохи незвичний синтаксис (але про це трохи згодом). Давайте ще раз поглянемо на цей запис
arrays.sort(new Comparator<String[]>() {
    @Override
    public int compare(String[] o1, String[] o2) {
        return o1.length - o2.length;
    }
});
Тут ми беремо наш список arraysі викликаємо в нього метод sort(), куди передаємо об'єкт компаратора з одним єдиним методом compare()(нам не важливо, як він називається, адже він єдиний у цьому об'єкті, тут не промахнемося). Цей метод приймає два параметри, з якими ми далі працюємо. Якщо ви працюєте в IntelliJ IDEA , то напевно бачабо, як вона вам пропонує цей код значно скоротити:
arrays.sort((o1, o2) -> o1.length - o2.length);
Ось так шість рядків перетворабося на один короткий. 6 рядків переписали в один короткий. Щось зникло, але я гарантую, що не зникло нічого важливого, і такий код працюватиме так само, як і при анонімному класі. Завдання 2 . Здогадатися, як переписати рішення задачі 1 через лямбду (у крайньому випадку, попросіть IntelliJ IDEA перетворити ваш анонімний клас на лямбду).

Поговоримо про інтерфейси

В принципі, інтерфейс це просто список абстрактних методів. Коли ми створюємо клас і кажемо, що він імплементуватиме якийсь інтерфейс — ми повинні в нашому класі написати реалізацію тих методів, які перераховані в інтерфейсі (або, на крайній випадок, не писати, але зробити клас абстрактним). Бувають інтерфейси з безліччю різних методів (наприклад, List), бувають інтерфейси тільки з одним методом (наприклад, той же Comparator або Runnable). Бувають інтерфейси зовсім без єдиного методу (так звані інтерфейси-маркери, наприклад Serializable). Ті інтерфейси, які мають лише один метод, також називають функціональними інтерфейсами . У Java 8 вони навіть позначені спеціальною інструкцією @FunctionalInterface. Саме інтерфейси з одним єдиним методом та підходять для використання лямбда-виразами. Як я вже говорив вище, лямбда-вираз – це метод, загорнутий в об'єкт. І коли ми передаємо кудись такий об'єкт — ми по суті передаємо цей один єдиний метод. Виходить нам не важливо, як цей метод називається. Все, що нам важливо, — це параметри, які цей метод приймає, і, власне, сам код методу. Лямбда-вираз – це, по суті. реалізація функціонального інтерфейсу Де бачимо інтерфейс з одним методом – значить такий анонімний клас можемо переписати через лямбду. Якщо в інтерфейсі більше/менше одного методу, тоді нам лямбда-вираз не підійде, і будемо використовувати анонімний клас, або навіть звичайний. Настав час поколупати лямбди. :)

Синтаксис

Загальний синтаксис приблизно такий:
(параметры) -> {тело метода}
Тобто круглі дужки, всередині їх параметри методу, «стрілочка» (це два символи поспіль: мінус і більше), після якої тіло методу у фігурних дужках, як і завжди. Параметри відповідають тим, що вказані в інтерфейсі під час опису методу. Якщо тип змінних може бути чітко визначений компілятором (у нашому випадку точно відомо, що ми працюємо з масивами рядків, тому що — Listтипізований саме масивами рядків), то і тип змінних String[]можна не писати.
Якщо не впевнені, вказуйте тип, а IDEA підсвічує його сірим, якщо він не потрібний.
Докладніше можна почитати в туторіалі оракла , наприклад. Це називається "target typing" . Імена змінним можна дати будь-які, не обов'язково саме ті, які вказані в інтерфейсі. Якщо параметрів немає, тоді просто круглі дужки. Якщо параметр лише один — просто ім'я змінної без круглих дужок. З параметрами розібралися тепер про тіло самого лямбда-вираження. Усередині фігурних дужок пишете код як для звичайного методу. Якщо у вас весь код складається тільки з одного рядка, можете взагалі фігурних дужок не писати (як і з if-ами, і циклами). Якщо ваша лямбда щось повертає, але її тіло складається з одного рядка, писати returnзовсім не обов'язково. А от якщо у вас фігурні дужки, тоді, як і у звичайному методі, потрібно писати return.

Приклади

приклад 1.
() -> {}
Найпростіший варіант. І найбезглуздіший:). Оскільки нічого не робить. приклад 2.
() -> ""
Теж цікавий варіант. Нічого не приймає і повертає порожній рядок ( returnопущений через непотрібність). Те ж, але з return:
() -> {
    return "";
}
Приклад 3. Hello world на лямбдах
() -> System.out.println("Hello world!")
Нічого не приймає, нічого не повертає (ми не можемо поставити returnперед викликом System.out.println(), так як тип значення, що повертається в методі println() — void), просто виводить на екран напис. Ідеально підходить для реалізації інтерфейсу Runnable. Цей же приклад більш повний:
public class Main {
    public static void main(String[] args) {
        new Thread(() -> System.out.println("Hello world!")).start();
    }
}
Ну, чи так:
public class Main {
    public static void main(String[] args) {
        Thread t = new Thread(() -> System.out.println("Hello world!"));
        t.start();
    }
}
Або навіть можемо зберегти лямбда-вираз як об'єкт типу Runnable, а потім його вже передати в конструктор thread’а:
public class Main {
    public static void main(String[] args) {
        Runnable runnable = () -> System.out.println("Hello world!");
        Thread t = new Thread(runnable);
        t.start();
    }
}
Розглянемо докладніше момент збереження лямбда-вираження змінну. Інтерфейс Runnableнам каже, що його об'єкти повинні мати метод public void run(). Відповідно до інтерфейсу, метод run нічого не приймає як параметри. І нічого не повертає (void). Тому за такого запису буде створено об'єкт із якимось методом, який нічого не приймає та не повертає. Що цілком відповідає методу run()в інтерфейсі Runnable. Ось чому ми і змогли помістити це лямбда-вираз у змінну типу Runnable. Приклад 4
() -> 42
Знову, нічого не приймає, а повертає число 42. Таке лямбда-вираз можна помістити в змінну типу Callable, тому що в цьому інтерфейсі визначено лише один метод, який виглядає приблизно так:
V call(),
де V- це тип значення, що повертається (у нашому випадку int). Відповідно, ми можемо зберегти такий лямбда-вираз таким чином:
Callable<Integer> c = () -> 42;
Приклад 5. Лямбда кілька рядків
() -> {
    String[] helloWorld = {"Hello", "world!"};
    System.out.println(helloWorld[0]);
    System.out.println(helloWorld[1]);
}
Знову, це лямбда-вираз без параметрів і тип значення, що повертається у нього void(бо відсутній return). Приклад 6
x -> x
Тут ми приймаємо щось у змінну х, і її ж і повертаємо. Зверніть увагу, що якщо приймається лише один параметр, то дужки навколо нього можна не писати. Те саме, але з дужками:
(x) -> x
А ось варіант з явним return:
x -> {
    return x;
}
Або так, з дужками і return:
(x) -> {
    return x;
}
Або з явною вказівкою типу (і, відповідно, з дужками):
(int x) -> x
Приклад 7
x -> ++x
Приймаємо х, повертаємо його ж, але 1більше. Можна переписати і так:
x -> x + 1
В обох випадках дужки навколо параметра, тіла методу та слово returnне вказуємо, оскільки це не обов'язково. Варіанти з дужками та ретурном описані в прикладі 6. Приклад 8
(x, y) -> x % y
Приймаємо якісь хі у, повертаємо залишок від поділу xна y. Дужки навколо параметрів тут уже обов'язкові. Необов'язкові вони лише коли параметр лише один. Ось так із явною вказівкою типів:
(double x, int y) -> x % y
Приклад 9
(Cat cat, String name, int age) -> {
    cat.setName(name);
    cat.setAge(age);
}
Приймаємо об'єкт Кіт, рядок з ім'ям та ціле число вік. У самому методі встановлюємо Коту передані ім'я та вік. Оскільки змінна catу нас посилального типу, то й об'єкт Кіт поза лямбдою-виразом зміниться (отримає передані всередину ім'я та вік). Трохи ускладнений варіант, де використовується подібна лямбда:
public class Main {
    public static void main(String[] args) {
        // Створюємо кота і виводимо на екран щоб переконатися, що він "порожній"
        Cat myCat = new Cat();
        System.out.println(myCat);

        // створюємо лямбду
        Settable<Cat> s = (obj, name, age) -> {
            obj.setName(name);
            obj.setAge(age);
        };

        // Викликаємо метод, в який передаємо кота та лямбду
        changeEntity(myCat, s);
        // Виводимо на екран і бачимо, що стан кота змінилося (має ім'я та вік)
        System.out.println(myCat);
    }

    private static <T extends WithNameAndAge>  void changeEntity(T entity, Settable<T> s) {
        s.set(entity, "Мурзик", 3);
    }
}

interface WithNameAndAge {
    void setName(String name);
    void setAge(int age);
}

interface Settable<C extends WithNameAndAge> {
    void set(C entity, String name, int age);
}

class Cat implements WithNameAndAge {
    private String name;
    private int age;

    @Override
    public void setName(String name) {
        this.name = name;
    }

    @Override
    public void setAge(int age) {
        this.age = age;
    }

    @Override
    public String toString() {
        return "Cat{" +
                "name='" + name + '\'' +
                ", age=" + age +
                '}';
    }
}
Результат: Cat{name='null', age=0} Cat{name='Мурзик', age=3} Як видно, спочатку об'єкт Кіт мав один стан, а після використання лямбда-вираження стан змінився. Лямбда-вираження відмінно поєднуються з дженериками. І якщо нам знадобиться створити клас Dog, наприклад, який теж імплементуватиме WithNameAndAge, то в методі main()ми можемо ті ж операції зробити і з Cобакою, абсолютно не змінюючи самі лямбда-вираз. Завдання 3 . Написати функціональний інтерфейс з методом, який приймає число та повертає булеве значення. Написати реалізацію такого інтерфейсу у вигляді лямбда-вираження, яке повертає, trueякщо передане число ділиться без залишку на 13. Задача 4. Написати функціональний інтерфейс з методом, який приймає два рядки та повертає теж рядок. Написати реалізацію такого інтерфейсу у вигляді лямбди, яка повертає той рядок, який довший. Завдання 5 . Написати функціональний інтерфейс з методом, який приймає три дробові числа: a, b, cі повертає теж дробове число. Написати реалізацію такого інтерфейсу як лямбда-выражения, яке повертає дискримінант. Хто забув, D = b^2 - 4ac . Завдання 6 . Використовуючи функціональний інтерфейс із завдання 5 написати лямбда-вираз, який повертає результат операції a * b^c. Популярно про лямбда-вираження в Java. З прикладами та завданнями. Частина 2.
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ