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

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

Лямбда-вирази з'явилися в Java із функціонального програмування, а туди — з математики. У середині 20-го століття в Америці у Прінстонському університеті працював дехто Алонзо Чьорч, який дуже любив математику й різноманітні абстракції. Саме Алонзо Чьорч і вигадав лямбда-числення, яке спочатку було набором абстрактних ідей і жодним чином не стосувалося програмування. У той самий час у Прінстонському університеті працювали такі математики, як Алан Тюрінг та Джон фон Нейман. Усе склалось: Чьорч вигадав систему лямбда-числень, Тюрінг розробив свою абстрактну обчислювальну машину, нині відому під назвою «машина Тюрінга». А фон Нейман запропонував схему архітектури обчислювальних машин, яка стала основою сучасних комп'ютерів (і зараз називається «архітектура фон Неймана»). У той час ідеї Алонзо Чьорча не отримали такої гучної слави, як роботи його колег (за винятком сфери «чистої» математики). Проте, трохи пізніше дехто Джон МакКарті (також випускник Прінстонського університету, на момент розповіді — співробітник Массачусетського технологічного інституту) зацікавився ідеями Чьорча. На їх основі у 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 підсвітить його сірим, якщо він не потрібен.
Докладніше можна почитати в туторіалі Oracle, наприклад. Це зветься «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 не вказуємо, бо це не обов'язково. Варіанти з дужками і з 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() ми можемо ті ж операції виконати і з Собакою, абсолютно не змінюючи самі лямбда-вирази. Задача 3. Написати функціональний інтерфейс з методом, який приймає число і повертає булеве значення. Написати реалізацію такого інтерфейсу у вигляді лямбда-виразу, яке повертає true, якщо передане число ділиться без залишку на 13. Задача 4. Написати функціональний інтерфейс з методом, який приймає два рядки і повертає теж рядок. Написати реалізацію такого інтерфейсу у вигляді лямбди, яка повертає той рядок, який довший. Задача 5. Написати функціональний інтерфейс з методом, який приймає три дробові числа: a, b, c і повертає теж дробове число. Написати реалізацію такого інтерфейсу у вигляді лямбда-виразу, який повертає дискримінант. Хто забув, D = b^2 — 4ac. Задача 6. Використовуючи функціональний інтерфейс із задачі 5 написати лямбда-вираз, який повертає результат операції a * b^c.