ФУНКЦІЇ ВИЩИХ ПОРЯДКІВ
В результаті вивчення матеріалу глави 3 студент повинен:
- • поняття функцій вищого порядку, область їх застосування в функціональних програмах;
- • поняття лямбда-виразів і методи їх використання у функціях вищих порядків;
- • то, в яких випадках можна використовувати природний паралелізм у визначенні функцій;
- • користуватися стандартними функціями вищих порядків для масової обробки даних;
- • застосовувати стандартні функції обробки списків для отримання коротких і зрозумілих програм;
- • навичками застосування функцій вищих порядків в програмуванні на мові Haskell;
- • навичками позбавлення від рекурсії при обробці складних структур даних.
Відображення і згортка. Лямбда-вирази
У цьому параграфі ми розглянемо способи опису і приклади застосування функцій, аргументами або результатами яких також є функції. У традиційних мовах програмування такі функції часто буває важко описати і не завжди зручно використовувати. У функціональному програмуванні, однак, функції є значеннями «першого класу», так що подібні описи не тільки можливі, але і є одним з основних засобів побудови програм. У мові є велика кількість стандартних функцій, які мають функціональні аргументи і результати. З добре відомих прикладів назвемо функцію, яка, отримуючи два функціональних аргументу, виробляє їх суперпозицію.
Ми, однак, почнемо ні з опису функції суперпозиції, а з інших прикладів, в яких функції використовуються як аргументи інших функцій. В першу чергу розглянемо стандартну функцію обробки списків тар, яка, отримавши в якості аргументів список і функцію перетворення елементів цього списку, видає як результат список з перетворених елементів, отриманих застосуванням функціонального параметра до кожного елементу списку. Якщо, наприклад, функція sqr зводить свій аргумент в квадрат, то за допомогою функції тар можна, маючи список цілих чисел, отримати список їх квадратів: map sqr [1, 2, 5, -2] – »[1, 4, 25, 4]
Функція тар – це стандартна функція, певна в ядрі мови. Однак за вже сформованою традицією приведемо її опис заново. Перш за все, визначимо її тип. Першим аргументом нашої функції є функція, яка тут має один цілий аргумент і повертає цілий результат. Тип цього першого аргументу буде (Integer -> Integer). Другий аргумент і результат функції – це списки цілих типу [Integer]. Таким чином, тип функції тар буде виглядати як map :: (Integer -> Integer) -> [Integer] -> [Integer]
Насправді не так важливо, що вихідний і результуючий списки містять саме елементи типу Integer. Фактично вихідні елементи можуть мати довільний тип а. Тоді, якщо функція, яка є першим аргументом функції тар, має тип (а -> Ь), то результуючий список буде містити елементи типу Ь. Тепер ми готові описати повністю функцію тар, включаючи і її тип (лістинг 3.1).
Лістинг 3.1. Визначення функції відображення списків – куля
– результат застосування тар до порожнього списку – порожній список тар _ [] = []
тар func (x: s) = (func х): (тар func s)
Перше рівняння визначає результат роботи відображення в разі порожнього списку. Зрозуміло, що в цьому випадку перший аргумент функції не застосовується зовсім, так як результатом буде порожній список незалежно від того, яка функція застосовується для відображення його елементів. Друге рівняння використовує рекурсивне звернення до функції тар для того, щоб побудувати відображення хвоста списку, а потім приєднує до результату головного елемент, який виходить за допомогою застосування до першого елементу вихідного списку відображає функції.
Функція, подібна тар, яка в якості аргументу отримує іншу функцію або видає функцію в якості результату, називається функцією вищого порядку , або функціоналом.
З нашого опису очевидно, що обчислення результату застосування функції до першого елементу списку не залежить від того, як обчислюється відображення іншої частини списку. Фактично, якщо в системі є достатня кількість обчислювальних ресурсів, то обчислення результатів застосування функції до елементів списку можна виробляти паралельно. Хоча загальний час роботи функції тар пропорційно довжині списку аргументу (вважаючи, що час обробки кожного елемента списку одне і те ж), в обчисленнях на багатопроцесорних машинах або в розподіленому кластері функція тар працює надзвичайно ефективно.
Функцію тар зручно застосовувати в ситуаціях, коли перед початком послідовної обробки елементів списку потрібно виконати будь-які дії з кожним елементом списку. Розглянемо наступну задачу.
Нехай потрібно скласти список всіх простих співмножників заданого великого списку натуральних чисел. Можна для вирішення скласти рекурсивную функцію, що має один додатковий накопичує аргумент – впорядкований список вже знайдених простих співмножників. Крім того, напишемо просту функцію, яка обчислює найменший простий множник натурального числа, більшого одиниці. Для простоти Не будемо сильно піклуватися про ефективність цієї останньої функції. Наприклад, функція може бути написана в такий спосіб. minFactor n = minFactor ‘п 2
where minFactor ‘nd I n’ mod 4 d == 0 = d
I otherwise = minFactor ‘n (d + 1)
Тепер функція обробки списку натуральних чисел може виглядати так (перший аргумент – вихідний список, другий – що генерується список простих співмножників): factorList [] factors = factors
factorList (l: xs) factors = factorList xs factors factorList (x: xs) factors =
factorList ((x ‘div 4 d): xs) (insert d factors) where d = minFactor x
| d == f = factors | otherwise = f: insert d fs
На простих прикладах можна переконатися в тому, що функція працює правильно. Наприклад, написавши вираз factorList [12, 24, 36, 25] [], ми отримаємо правильну відповідь [2,3,5]. Наша функція послідовно обробляє елементи вихідного списку, отримуючи але черги прості множники кожного числа. Однак можна значно прискорити роботу, якщо обчислювати прості множники кожного елемента списку паралельно з іншими елементами. Цього можна досягти за допомогою функції тар.
Переглянемо проект програми. Напишемо функцію, яка по заданому натуральному числу отримує впорядкований список його простих співмножників, і застосуємо її паралельно до всіх елементів списку. Після цього всі списки можна буде зібрати в один за допомогою процедури злиття впорядкованих списків.
Повний текст програми з деякими коментарями міститься в лістингу 3.2.
Лістинг 3.2. Програма обчислення списку простих співмножників
- – Функція factors видає упорядкований список простих
- – співмножників заданого натурального числа
factors :: Integer -> [Integer]
factors n = reverse $ factors ‘n 2 [] where
– Допоміжна функція factors ‘має два накопичують – аргументу – черговий перевіряється дільник d і – вже обчислений список простих співмножників, менших d.
factors ‘1 _ list = factors’ nd list
| n ‘mod’ d == 0 = factors ‘(n’ div ‘d) d (add d list)
| otherwise = factors ‘n (d + 1) list – Функція add додає черговий співмножник в початок – списку, так що остаточний список виявляється – перевернутим, add d [] = [d]
add d listQ (xixs) | d == x = list
merge listl® (xl: xsl) list20 (x2: xs2)
– Функція mergeLists зливає без повторень список – упорядкованих списків в один список. mergeLists:: Ord а => [[а]] -> [а]
mergeLists (list: lists) = merge list $ mergeLists lists
– Основна функція для вирішення завдання отримання списку всіх – простих дільників заданого списку натуральних чисел. factorList :: [Integer] -> [Integer] factorList list = mergeLists $ map factors list
Якщо деяка функція призначена тільки для того, щоб передати її в якості аргументу іншої функції, то не має сенсу давати їй постійне ім’я і визначати її тип явно. В цьому випадку можна використовувати так зване лямбда-вираз, яке задає зразки для аргументів функції і праві частини її рівнянь, але не пов’язує з цією функцією ніякого імені, а тип такої функції визначається з контексту. Таким чином, лямбда-вираз являє собою функціональне значення, що зображує безіменну функцію.
У найпростішому випадку, коли функція може бути визначена за допомогою тільки одного рівняння, лямбда-вираз має такий же вигляд, як і це єдине рівняння. Різниця полягає в тому, що в лівій його частині замість імені функції варто символ зворотної косої межі (зображає грецьку букву «лямбда» – А,), а замість знака рівності для відділення зразків для аргументів від правої частини рівняння використовується послідовність символів ‘-> 1 . Наприклад, якщо функція sqr для зведення числа в квадрат не визначена, то ми можемо написати лямбда-вираз для її визначення безпосередньо там, де ця функція використовується.
Тоді виклик функції куля для отримання списку квадратів, згаданий на початку глави, буде виглядати наступним чином: тар (х -> х * х) [1, 2, 5, -2]
Якщо рівнянь кілька, то їх можна об’єднати в один вираз за допомогою умовного виразу if або спеціальної конструкції для вибору за зразками – case. Наприклад, для того щоб отримати список з знаків заданого списку цілих чисел за допомогою функції тар (знак числа – це функція, що видає нуль, одиницю або мінус одиницю в залежності від того, чи є число нульовим, позитивним або негативним), можна поступити наступним чином . По-перше, визначити окремо функцію для обчислення знака числа, скажімо, таким чином:
Тоді виклик для знаходження списку знаків заданого списку чисел виглядав би як map signum [2,5,0, -3, 2, -2]
(В результаті вийде список [1, 1, 0, -1, 1, -1]).
Другий спосіб виклику, при якому функція обчислення знака числа визначається прямо в точці виклику, буде виглядати так (замість трьох рівнянь використано умовний вираз):
map ( -> if п == 0 then 0 else if n 0 then -1 else 1)
Нарешті, визначення функції лямбда-виразом з використанням конструкції case буде виглядати наступним чином: map ( -> case n of
Конструкція case дозволяє зробити вибір серед декількох зразків точно так же, як це відбувається при виконанні функції з деякими аргументом. В даному випадку роль такого аргументу грає вираз, що стоїть безпосередньо після ключового слова case. Ми не будемо заглиблюватися в тонкощі синтаксису для всебічного вивчення конструкції case, тим більше що вона нам в подальшому майже не знадобиться. Однак зауважимо, що звичне визначення іменованої функції завжди може бути переформулювати за допомогою лямбда-вирази, якщо тільки в правих частинах рівнянь цієї функції не використовується ім’я самої функції. Так, наведене вище визначення функції signum насправді є лише інший синтаксичної формою для визначення за допомогою лямбда-вирази: signum = -> case n of
Наведемо ще одну форму записи, в якій іменована функція визначається в вираженні локально за допомогою лямбда-вирази і блоку where. У наступній сходинці обчислюються факторіали перших 10
map factorial [1..10] where
factorial = -> if n == 0 then 1 else n * factorial (n-1)
Зауважимо, що тут в лямбда-виразі використаний рекурсивний виклик. В даному випадку це можливо, оскільки в реченні where з ним зв’язується ім’я.
Блок where може містити і кілька визначень функцій, ми вже використовували такий запис в лістингу 3.2, причому кожна з цих функцій може мати і кілька рівнянь, і навіть визначення типу. Таким чином, локальне визначення функцій може бути зроблено в тій же формі, що і глобальне: map factorial [1..10] where
factorial:: Integer -> Integer factorial 0 = 1
factorial n | n> 0 = n * factorial (n-1)
Повернемося до функцій вищих порядків. Функція тар отримує в якості аргументу функцію одного аргументу і застосовує її до елементів списку, в результаті чого виходить новий список. Можна розглянути ще одну функцію вищого порядку, яка буде «згортати» список, виробляючи на базі всіх елементів списку одне значення. Така функція для з’єднання окремих елементів списку в одне значення має отримувати бінарну операцію – функцію двох аргументів. Ця бінарна операція буде послідовно застосовуватися до елементів списку так, щоб в результаті обробки усього списку вийшло одне значення. Наприклад, якщо бінарну операцію складання застосовувати послідовно до всіх елементів списку (в якості початкового значення можна взяти нуль), то в результаті вийде сума всіх елементів списку. Якщо ж в якості бінарної операції взяти операцію множення, то в результаті перемноження всіх елементів списку вийде твір цих елементів (тепер в якості початкового значення потрібно буде взяти одиницю).
У стандартній бібліотеці мови Haskell є дві функції вищого порядку, одна з яких застосовує задану бінарну операцію до елементів списку від початку списку до його кінця, а друга починає обробку елементів з кінця списку, застосовуючи задану як аргумент операцію послідовно, рухаючись у напрямку до початку списку. Перша з цих операцій називається foldl, друга – foldr. Загальна назва для цих операцій – операції згортки списку. Звичайно, якщо застосовується до елементів списку бінарна операція асоціативна (як додавання множення), то порядок обробки елементів – з початку або кінця списку – не важливий.
Наведемо визначення функцій згортки списку. У цих визначеннях (лістинг 3.3) f – вживана бінарна операція (або функція з двома аргументами), seed – початкове значення (воно буде результатом роботи функції згортки, якщо оброблюваний список не містить жодного елемента).
Лістинг 3.3. Визначення функцій згортки списків – f oldl і f oldr
foldl f seed (x: s) = foldl f (f seed x) s
foldr f seed (x: s) = fx (foldr f seed s)
Розглянемо рівняння, що визначають ці функції докладніше (обмежимося тільки функцією foldr, рівняння для функції foldl аналогічні). Як і в випадку функції тар, перше рівняння визначає поведінку функції згортки для випадку, коли вихідний список порожній. Як ми вже говорили, в цьому випадку результатом має бути початкове значення, яке визначається аргументом seed. Якщо ж вихідний списку не порожній, то, перш за все, побудуємо згортку хвоста списку за допомогою рекурсивного звернення до функції foldr, а потім застосуємо нашу бінарну операцію f до першого елементу списку і результату згортки хвоста. В результаті вийде шуканий результат.
Іноді функцію згортки називають також операцією вставки, оскільки результат її роботи аналогічний вставці бінарної операції, яка задається в якості першого аргументу, між елементами списку.
Суму всіх елементів заданого списку list тепер можна отримати простим викликом будь-якої з функцій foldl або foldr за умови, що в якості початкового значення вибирається нуль, а в якості бінарної операції береться операція додавання: foldl (+) 0 list
однак функція згортки – це досить потужна функція, яка може застосовуватися в самих різних, часом досить несподіваних ситуаціях. Наприклад, якщо описати операцію приєднання елемента до початку списку у вигляді операції addElem addElem list elem = elemrlist
(Зауважимо, що ця операція відрізняється від стандартного конструктора списків (:) тільки порядком аргументів), то функція «перевертання» списку, яку ми описували в гл. 1 у вигляді функції reverse, тепер може бути виражена за допомогою застосування функції foldl: reverse list = foldl addElem [] list
У цій версії функції звернення списку немає явної рекурсії. Рекурсивні виклики «заховані» всередині функції foldl. Функції вищих порядків часто дозволяють писати короткі і виразні програми, що не містять в явному вигляді рекурсії. Нижче наведена версія функції обчислення факторіала, в якій для обчислення спочатку будується список цілих чисел від одиниці до заданого значення аргументу, а потім все елементи отриманого списку перемножуються для отримання результату:
На відміну від функції тар згортка списку проводиться послідовно, так як бінарної операції необхідні значення обох аргументів для того, щоб обчислити результат. Втім, якщо в якості початкового значення береться один з елементів списку, то в разі асоціативної операції можна було б виробляти згортку окремих ділянок списку незалежно від інших і потім з’єднувати результати операцій. У бібліотеці Haskell поряд з функціями foldl і f oldr існують їх варіанти foldll і foldrl, які не вимагають початкового значення, а в якості такого початкового значення беруть перший або, відповідно, останній елементи самого списку. Зрозуміло, ці функції не застосовуються до порожнього списку. Якщо бінарна операція, що застосовується для згортки, асоціативна, то ці функції могли б обробляти різні ділянки списку паралельно, підвищуючи тим самим продуктивність в разі багатопроцесорних систем.
На жаль, асоціативність – це властивість, яке важко розпізнати аналітично. Наприклад, підсумовування елементів непорожньої списку [3, 7, 2, 4, 10] можна провести за допомогою виразу foldll (+) [3, 7, 2, 4, 10]
в результаті чого вийде значення 26, а за допомогою схожого вираження
вийде значення 3 – 7 – 2 – 4 – 10 = -20. Але якщо перше значення можна отримати, застосовуючи складання в будь-якому порядку, наприклад, (3 + 7) + (2 + 4) + 10, і, значить, обробку списку можна було б провести паралельно, то в другому випадку аналогічне побудова (3 7) – (2-4) – 10 дасть зовсім не той результат.
Спробуємо написати функцію, яка для асоціативної бінарної операції f дасть той же результат, що і згортки foldll і foldrl, але намагається запустити бінарну операцію паралельно на двох ділянках списку. Подібна функція дасть нам перевагу перед звичайною сверткой в разі дуже «важкою» бінарної операції при роботі в багатопроцесорних системах:
fold2 f (xl: x2: xs) = f (f xl x2) (fold2 f xs)
Спробуємо застосувати нашу нову згортку до списку [3, 7, 2, 4,
10], використовуючи операцію складання як бінарної операції: fold2 (+) [3, 7, 2, 4, 10]:
Спроба вдала, але подивимося на послідовність редукцій, яка виходить в разі застосування звичайної згортки foldll і нашої згортки fold2. Звичайна згортка дає наступну послідовність виразів:
- 3 + foldll (+) [7, 2, 4, 10]
- 3 + (7 + foldll (+) [2, 4, 10])
- 3 + (7 + (2 + foldll (+) [4, 10]))
- 3 + (7 + (2 + (4 + foldll (+) [10])))
- 3 + (7 + (2 + (4 + 10)))
- 3 + (7 + (2 + 14))
- 3 + (7 + 16)
- 3 + 23 26
Всі операції виконуються послідовно, распараллелить обчислення немає ніякої можливості. Тепер подивимося, як буде працювати fold2:
- (3 + 7) + f old2 (+) [2 , 4 , 10]
- (3 + 7) + ((2 + 4) + fold2 (+) [10])
- (3 + 7) + ((2 + 4) + 10)
- 10 + (6 + 10)
- 10 + 16 26
Деяка економія досягнута – ми змогли обчислити «паралельно» суми (3 + 7) і (2 + 4). Однак на цьому наш паралелізм закінчується. Функція fold2 дозволяє виконати паралельно операції над парами елементів, але в подальшому обробка все одно відбувається строго послідовно. Чи можна «розповсюдити» паралелізм далі?
Розглянемо ще одну версію згортки – функцію f old3:
fold3 f list = f (fold3 f first) (fold3 f second) where (first, second) = splitAt (length list ‘div’ 2) list
Щоб побачити ефект від паралелізму, продемонструємо роботу цієї функції па трохи більше довгому списку з восьми елементів. При цьому будемо «паралельно» обчислювати ті значення, які на кожному етапі вже готові для обчислення: fold3 (+) [1, 2, 3, 4, 5, 6, 7, 8]
fold3 (+) [1, 2, 3, 4] + fold3 (+) [5, 6, 7, 8]
- (Fold3 (+) [1, 2] + fold3 (+) [3, 4]) +
- (F old3 (+) [5, 6] + f old3 (+) [7, 8])
- ((1 + 2) + (3 + 4)) + ((5 + 6) + (7 + 8))
- (3 + 7) + (11 + 15)
- 10 + 26 36
Тепер з паралелізмом все в порядку, проблема тільки в тому, що функція splitAt для дуже довгих списків буде працювати неефективно. Замість лінійної по довжині списку функції ми отримали квадратичную. Крім того, що splitAt працює не за константне час, вона ще й породжує нові списки під час своєї роботи, так що ефективність за обсягом необхідної пам’яті теж не дуже висока. Втім, всі ці недоліки можуть бути переважені вигодою від паралелізму, якщо дійсно наша операція «складання» елементів істотно складніша, так що має сенс виконувати її в максимально можливому паралелізм.
Пізніше, в гл. 8 ми повернемося до питання про паралелізм при виконанні згортки за допомогою асоціативної бінарної операції.
Функції вищих порядків, що видають функції в якості результату, можна визначати, щоб будувати нові функції на основі інших. Напевно, однією з найпоширеніших операцій над функціями є їх суперпозиція (або композиція). Така операція дозволяє за двома функціями fug отримати нову функцію fg таку, що результат її застосування до аргументу fg х буде тим же, що і результат послідовного застосування функцій f і g: f (д х). На відміну від традиційних мов програмування, в яких описати подібну функцію, як правило, неможливо, в мові Haskell такий опис не складає ніяких труднощів:
comp:: (b -> а) -> (с -> Ь) -> (с -> а) comp fg = х -> f (gx)
Така функція є в стандартній бібліотеці мови Haskell у вигляді бінарної операції (.) • Це означає, що якщо є функція зведення числового значення в квадрат sqr, то функція зведення числа в четверту ступінь power4 може бути отримана за допомогою виклику
Надалі ми будемо використовувати операцію композиції досить часто.
Вище, при описі варіанта визначення функції звернення списку, ми використовували визначення функції addElem, яка відрізняється від стандартного конструктора списків (:) тільки порядком аргументів. Можна визначити функцію, яка за заданою функції двох аргументів буде отримувати нову функцію, що відрізняється від вихідної тільки порядком аргументів. Така функція також є в стандартній бібліотеці мови Haskell, називається flip і може бути описана наступним чином:
flip:: (а -> b -> c) -> (Ь -> а -> с) flip func = х у -> func у х
Тепер функцію звернення списку можна визначити, не використовуючи явного опису допоміжної функції addElem: reverse list = foldl (flip (:)) [] list
Зауважимо, що описаний алгоритм звернення списку працює дуже ефективно і звертає список за лінійний час. Раніше такого результату ми домагалися, використовуючи перетворення функцій за допомогою введення додаткових аргументів. Зверніть увагу ще й на те, що функції flip передається аргументом конструктор списків (:), який в даному випадку використаний як бінарної операції. Це як раз той випадок, коли конструктор використовується в ролі звичайної функції.
Згадаймо функцію сортування списку методом простих вставок, наведену в лістингу 2.16. У цій програмі ми визначили дві функції – simpleSort і insert. Перша рекурсивна функція здійснювала сортування списку за допомогою вставки елементів в упорядкований список, і цю вставку якраз і забезпечувала функція insert. У тому прикладі ми обговорювали, як змусити рекурсивную функцію бути більш ефективною за допомогою кінцевий рекурсії і накопичують аргументів. Тепер напишемо функцію simpleSort зовсім без рекурсії: simpleSort list = foldl insert [] list
У функції insert позбутися рекурсії складніше, але пізніше ми зможемо і її висловити за допомогою комбінації простих вбудованих функцій.
Популярно про лямбда-вираження в Java. З прикладами та завданнями. Частина 1
Лямбда-вирази дійшли 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 = "мир", "праця", "тдорівнюєь">; ListString[]> arrays = new ArrayList > (); arrays.add(array1); arrays.add(array2); arrays.add(array3); ComparatorString[]> sortByLength = new ComparatorString[]>() @Override public int compare(String[] o1, String[] o2) return o1.length - o2.length; > >; ComparatorString[]> sortByWordsLength = new ComparatorString[]>() @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);- мама мила раму
- мир праця тдорівнюєь
- я дуже люблю java
- мир праця тдорівнюєь
- мама мила раму
- я дуже люблю java
String[] array1 = "Мама", "мила", "раму">; String[] array2 = "я", "дуже", "кохаю", "java">; String[] array3 = "мир", "праця", "тдорівнюєь">; ListString[]> arrays = new ArrayList > (); arrays.add(array1); arrays.add(array2); arrays.add(array3); arrays.sort(new ComparatorString[]>() @Override public int compare(String[] o1, String[] o2) return o1.length - o2.length; > >);Результат буде такий самий, як і в першому випадку. Завдання 1 . Переписати цей приклад так, щоб він сортував масиви не за зростанням кількості слів у масиві, а за спаданням. Це ми вже всі знаємо. Ми вміємо передавати об’єкти в методи, ми можемо передати той чи інший об’єкт у метод залежно від того, що нам зараз треба, і всередині того методу, куди ми передаємо такий об’єкт, буде викликаний той метод, для якого ми написали реалізацію. Виникає питання: до чого тут взагалі лямбда-вирази? При тому, що лямбда це і є такий об’єкт, який містить рівно один метод. Такий собі об’єкт-метод. Метод, запакований в об’єкт. Просто у них трохи незвичний синтаксис (але про це трохи згодом). Давайте ще раз поглянемо на цей запис
arrays.sort(new ComparatorString[]>() @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[] можна не писати.
Докладніше можна почитати в туторіалі оракла , наприклад. Це називається “target typing” . Імена змінним можна дати будь-які, не обов’язково саме ті, які вказані в інтерфейсі. Якщо параметрів немає, тоді просто круглі дужки. Якщо параметр лише один — просто ім’я змінної без круглих дужок. З параметрами розібралися тепер про тіло самого лямбда-вираження. Усередині фігурних дужок пишете код як для звичайного методу. Якщо у вас весь код складається тільки з одного рядка, можете взагалі фігурних дужок не писати (як і з if-ами, і циклами). Якщо ваша лямбда щось повертає, але її тіло складається з одного рядка, писати return зовсім не обов’язково. А от якщо у вас фігурні дужки, тоді, як і у звичайному методі, потрібно писати return .
Приклади
Теж цікавий варіант. Нічого не приймає і повертає порожній рядок ( return опущений через непотрібність). Те ж, але з return :
() -> 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. Таке лямбда-вираз можна помістити в змінну типу Callable , тому що в цьому інтерфейсі визначено лише один метод, який виглядає приблизно так:
де V – це тип значення, що повертається (у нашому випадку int ). Відповідно, ми можемо зберегти такий лямбда-вираз таким чином:
Callable Integer> c = () -> 42;() -> String[] helloWorld = "Hello", "world!">; System.out.println(helloWorld[0]); System.out.println(helloWorld[1]); >Знову, це лямбда-вираз без параметрів і тип значення, що повертається у нього void (бо відсутній return ). Приклад 6
Тут ми приймаємо щось у змінну х , і її ж і повертаємо. Зверніть увагу, що якщо приймається лише один параметр, то дужки навколо нього можна не писати. Те саме, але з дужками:
В обох випадках дужки навколо параметра, тіла методу та слово return не вказуємо, оскільки це не обов’язково. Варіанти з дужками та ретурном описані в прикладі 6. Приклад 8
Приймаємо якісь х і у , повертаємо залишок від поділу x на y . Дужки навколо параметрів тут уже обов’язкові. Необов’язкові вони лише коли параметр лише один. Ось так із явною вказівкою типів:
(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 token operator">+ age + '>'; > >Результат: Cat Cat Як видно, спочатку об’єкт Кіт мав один стан, а після використання лямбда-вираження стан змінився. Лямбда-вираження відмінно поєднуються з дженериками. І якщо нам знадобиться створити клас Dog , наприклад, який теж імплементуватиме WithNameAndAge , то в методі main() ми можемо ті ж операції зробити і з Cобакою, абсолютно не змінюючи самі лямбда-вираз. Завдання 3 . Написати функціональний інтерфейс з методом, який приймає число та повертає булеве значення. Написати реалізацію такого інтерфейсу у вигляді лямбда-вираження, яке повертає, true якщо передане число ділиться без залишку на 13. Задача 4. Написати функціональний інтерфейс з методом, який приймає два рядки та повертає теж рядок. Написати реалізацію такого інтерфейсу у вигляді лямбди, яка повертає той рядок, який довший. Завдання 5 . Написати функціональний інтерфейс з методом, який приймає три дробові числа: a , b , c і повертає теж дробове число. Написати реалізацію такого інтерфейсу як лямбда-выражения, яке повертає дискримінант. Хто забув, D = b^2 – 4ac . Завдання 6 . Використовуючи функціональний інтерфейс із завдання 5 написати лямбда-вираз, який повертає результат операції a * b^c . Популярно про лямбда-вираження в Java. З прикладами та завданнями. Частина 2.