Функціональне програмування в JavaScript: зрозуміла альтернатива ООП
Привіт! Я — Дар’я Чернявська, JavaScript Developer в NIX.
Існує думка, що функціональне програмування надто академічне і використовує багато «зайвої» математики. Однак код, написаний у такому стилі, більш лаконічний, декларативний і, зрештою, має більш зрозумілу математику, оскільки повністю спирається на її постулати.
У цій статті я розповім про основи функціонального програмування, його методи та деякі бібліотеки, які допоможуть вам суттєво спростити розробку на JS.
Імперативний і декларативний підходи у програмуванні
Функціональне програмування — це підхід у програмуванні, який спирається на обчислення виразів (декларативний підхід), а не на послідовне виконання команд (імперативний підхід). Функціональне програмування відрізняється від об’єктно-орієнтованого, тому деякі методики, які успішно працювали в першому, мало пристосовані до другого і навпаки.
Що це означає на практиці?
Розглянемо приклади, які демонструють відмінність імперативного підходу від декларативного у написанні коду:
Приклад 1: Імперативний підхід
Припустимо, існує масив чисел. Наше завдання — отримати суму його елементів, зведену у квадрат. Чому цей код можна вважати імперативним? По-перше — це цикли, набір дій, поступальне виконання яких веде до вирішення задачі. Цикли дуже характерні для імперативного підходу у програмуванні. Ще одна відмінність — це мутації, які передбачають зміни вхідних аргументів у коді. Наведений відрізок коду орієнтується на чітке та послідовне виконання команд для отримання підсумкового результату.
Розглянемо приклад декларативного підходу написання коду:
Приклад 2: Декларативний підхід
Задача та сама, але тепер ми не використовуємо цикли та мутації. Натомість ми отримуємо результат за допомогою функції reduce. У декларативному підході ми орієнтуємося на кінцевий результат, оминаючи прописування конкретних кроків, необхідних для його досягнення.
Чисті функції, або «Згинь нечистий код»
Чисті функції — це основні блоки функціонального програмування.
- Першочергова концепція чистих функцій: Same input = Same output. Це означає, що при одних і тих же вхідних параметрах функція завжди буде повертати той самий результат. Саме тому чисті функції передбачувані та стабільні.
- Друге — у чистих функцій немає побічних ефектів. У чистої функції має бути лише одна відповідальність. Якщо ваша функція повертає суму чисел, вона повинна робити тільки це і не відповідати за виконання сторонніх задач.
- Третє — чисті функції не залежать від зовнішніх даних. У цих функціях ми ніколи не використовуємо глобальних змінних.
Наведу кілька прикладів, щоб зрозуміти відмінність чистих функцій від нечистих.
Приклад 3: Нечиста функція
Ця функція нечиста, оскільки результат виконання залежить від зовнішніх даних. Натомість викликається нова дата (Date), після чого відбувається форматування. Ця функція завжди буде видавати різний результат залежно від дати в момент виконання. Її результат відрізнятиметься сьогодні, завтра та післязавтра.
Для порівняння подивимося, як виглядає код чистої функції для цього завдання:
Приклад 4: Чиста функція
Для того, щоб перетворити функцію з прикладу 3 в чисту, нам лише потрібно передавати дату (date) як вхідний аргумент. Тепер ми впевнені: якщо передаємо певну дату, нам повернеться певний результат.
Розглянемо ще один приклад:
Приклад 5: Код нечистої функції
У нас є глобальне значення initialValue, оголошене в рядку 1. Крім цього, у нас є функція add, яка приймає за вхідний аргумент деяке число (а) і, як результат, здійснює підсумовування цього числа з нашою глобальною змінною (5 рядок коду). Після цього в рядках коду 8, 9 і 10 ми викликаємо цю функцію тричі, але щоразу вона видає нам різний результат. Це відбувається тому, що використовується глобальна змінна initialValue, внаслідок чого функція викликана з тими самими аргументами повертає різний результат.
Перетворимо цей код на код чистої функції:
Приклад 6: Чиста функція
У цьому коді ми спираємося лише на вхідні аргументи. Ми приймаємо два з них (a, b), а потім повертаємо їхню суму. Такий код застрахований від впливу зовнішніх змінних і завжди буде стабільним і передбачуваним.
Побічні ефекти та композиція функцій
В теорії чисті функції не повинні містити побічні ефекти (Side Effects). Однак на практиці ми часто стикаємося з необхідністю робити, наприклад, асинхронні запити. Функціональне програмування пропонує своє рішення — монади (Monad). Тут ажливо відзначити, що при роботі, скажімо, з UI-частиною, часто неможливо повністю дотримуватися парадигми функціонального програмування, тому доводиться вдаватися до компромісних рішень.
А тепер поговоримо про композицію функцій. Якщо ви вже працювали на JavaScript, то, напевно, стикалися з цим процесом, навіть не підозрюючи про це. Композиція функції являє собою процес передачі результату виконання однієї функції на вхід іншої функції.
Розглянемо на прикладі, що це значить:
Приклад 7: Композиція функцій
Припустимо, у нас є дві функції: одна зводить змінну х в квадрат, а інша — додає до неї 2. Ми можемо викликати дві ці функції й отримати результат. Відповідно до законів математики, спочатку буде викликана функція addTwo, а потім — результат буде зведений у квадрат за допомогою square. Ми також можемо створити більш абстрактну функцію, яка зможе композувати дві і більше функцій:
Приклад 8: Compose
Функція compose бере на себе N-кількість інших функцій. Потім функція повертатиме функцію, до якої був застосований вхідний аргумент, і так до всіх функцій по черзі. Зауважимо, що в 11 рядку коду (де видно виклик кінцевого результату) дії з виклику функцій виконуються справа-наліво: спочатку виконується дія з додаванням до слова hello знака оклику, а потім дублюється слово, в результаті чого на екран виводиться: hello! hello!
Якщо ви бажаєте інвертувати цей процес виклику функцій, можете скористатися функцією pipe:
Приклад 9: Pipe
Ця функція виконує аналогічне завдання, не перевертаючи масив функцій і звично виконуючи його дії в послідовності зліва-направо: спочатку застосовується функція repeatTwice (повторення) і лише потім addExclamation (додавання знака вигуку).
Імутабельність і бібліотеки
Ще одна важлива концепція функціонального програмування — це імутабельність. Вона передбачає відсутність мутацій і роботу з константами. Створюючи один раз будь-яку змінну, ви втрачаєте можливість змінити її в майбутньому. Це робить код більш передбачуваним. У результаті вдається уникнути появи раніше не прогнозованих багів.
Щоб уникнути непередбачуваної поведінки в коді, слід не мутувати (не змінювати) дані, а перезаписувати їх. Це стосується як масивів, так і об’єктів.
Приклад 10: Імутабельність
Якщо ми створимо константу JavaScript (1 рядок), а потім спробуємо її перезаписати (2), то з’являється помилка. Однак, якщо створити константу об’єкта (4) і спробувати перезаписати поле об’єкта (8), ми не отримаємо помилку.
Важливо розуміти, що повної імутабельності досягти досить складно й іноді зміни все ж таки будуть прослизати. Однак за допомогою таких готових рішень як Immutable та Immer, цей процес можна звести до мінімуму.
Рекурсивна функція і функції вищого порядку
Рекурсивна функція — це функція, що викликає сама себе. Простіше кажучи, це заміна циклам у функціональному програмуванні. Слід пам’ятати, що рекурсія завжди повинна мати умови для виходу. В іншому випадку при досягненні максимальної глибини рекурсії заповниться Call Stack, що загрожує зациклюванням і збоєм.
Розглянемо приклад коду рекурсивної функції:
Приклад 11: Рекурсивна функція
Перед вами проста рекурсивна функція, призначена для обчислення факторіалу. Тут можна побачити, як відбувається перевірка вхідного аргументу. Якщо вона не дорівнює 1, функція повертає n (або факторіал (n-1)), а потім знову викликає себе.
Різновидом функцій вищого порядку (Higher-order Functions або HOF) також є комбіновані функції, які можуть приймати в себе функції у вигляді вхідного аргументу, повертати їх як результат або робити те й інше одночасно. До цих функцій належать map, reduce, filter.
У React також існує досить популярний концепт Higher-order Component (HOC), який дозволяє додавати функціонал до готових компонентів.
Карування
Карування — це підхід до написання функції, в якому вона завжди буде приймати лише один аргумент. Ця методика дозволяє викликати функцію частково.
Порівняємо JavaScript код функцій з каруванням та без нього:
Приклад 12: Функція без карування
У першому рядку коду зображено звичайну функцію multiply. Вона приймає три аргументи, після чого перемножує їх одне з одним. Перепишемо цей код таким чином, щоб отримати функцію з каруванням:
Приклад 13: Функція з каруванням
Ми будемо повертати функцію по черзі до тих пір, поки вона не поверне всі наші вхідні аргументи. Завдяки цій дії ми матимемо можливість виклику функції лише частково.
Особливості нефункціонального підходу у програмуванні
Давайте підсумуємо. Функціональний підхід у програмуванні НЕ повинен включати наступні атрибути:
- Цикли (while, do...while, for, for...of, for...in). Цикли вважаються імперативними, тому при використанні функціонального підходу необхідно змінювати їх на рекурсію.
- Let чи var. Ці змінні забезпечують мутації, а у функціональному програмуванні ми в першу чергу керуємося принципом імутабельності.
- Void функції. Вони є побічними ефектами, тому у функціональному програмуванні від них доводиться відмовитися.
- Мутація об’єктів. Вони не використовуються в методах ФП. Замість зміни об’єкта необхідно створювати на його основі новий (перезаписати).
- Мутуючі методи для масивів (copyWithin, fill, pop, push, reverse, shift, unshift, sort, splice). Всі ці методи призводять до мутації масиву вхідних аргументів, тому у ФП їм краще знайти заміну серед map, reduce та інших.
Fantasy Land
Говорячи про функціональне програмування на JS, неможливо не розповісти про Fantasy Land — специфікацію JavaScript, яка описує алгебраїчні структури.
Розберемо найпростіший приклад алгебраїчної структури — Functor:
Приклад 14: Functor
В екземпляра Functor має бути реалізована функція map, яка може бути викликана, як відомо, в масиві. Більшість алгебраїчних структур успадковуються від Functor, у тому числі згадані вище монади (Monads):
Алгебраїчні структури
Бібліотеки Ramda та Sanctuary
Ще одна бібліотека, яка спрощує написання коду у функціональному стилі — Ramda.js. Вона включає в себе різні функції і є найзручнішою для початку ознайомлення з функціональним програмуванням на JS, адже містить безліч функцій-помічників (хелперів), які істотно полегшують написання коду. Ramda не реалізує специфікацію Fantasy Land, тому ця бібліотека не містить специфікації алгебраїчні специфікації даних, лише набір карірованих функцій.
Розглянемо роботу в Ramda на прикладі:
Приклад 15: R.Compose
На 3 та 4 рядках коду можна побачити функцію classyGreating, яка конструює вітання залежно від імені та прізвища. На 6 рядку коду зображено виклик функції R.compose, яка спочатку викликає функцію classyGreeting, а потім використовує функцію-хелпер R.toUpper, яка дозволяє внести результат у upper case. На 8 рядку можна побачити вже інший, складніший приклад використання R.compose з каруванням.
Приклад 16: R._
Бібліотека Ramda дозволяє навіть пропускати аргументи, використовуючи замість них R. Безліч інших реальних прикладів використання Ramda можна знайти в Cookbook за посиланням.
Sanctuary — це бібліотека, яка реалізує специфікації Fantasy Land (на відміну від Ramda). У ній представлені всі необхідні алгебраїчні структури та специфікації, що трохи ускладнює поріг входження при використанні бібліотеки.
Розглянемо, як реалізується задача щодо повернення кількості слів на прикладі Sanctuary:
Приклад 17: Повернення кількості слів у тексті Sanctuary
На 6 рядку коду можна побачити застосування функції S.pipe, яка містить 2 helper-функції. Вониу відповідають за підрахунок слів у тексті.
Основні переваги і недоліки функціонального програмування
Отже, маємо такі переваги:
- Код, що читається. Це безперечно для кожного річ дуже суб’єктивна. Проте слід визнати, що математична логіка коду, написаного в декларативному стилі, набагато простіша для сприйняття.
- Спрощена процедура тестування. Для тестування коду у ФП не потрібно створювати додаткових умов. Ви просто передаєте вхідні аргументи, а потім перевіряєте отриманий результат. Внаслідок цього процес тестування займає набагато менше часу.
- Спрощене налагодження. Чисті функції набагато простіше дебатувати.
- Передбачуваний код. Методи ФП гарантують передбачувану поведінку коду, що зводить до мінімуму можливість раптової появи помилок.
Крім цього, у функціонального програмування є кілька недоліків:
- Високий поріг входження. ФП досить велика тема, для глибокого занурення у яку потрібен час. Це часто і відлякує багатьох програмістів-початківців.
- Менш продуктивний код. JavaScript за замовчуванням не є повністю функціональною мовою програмування. Тому перед тим, як писати код у стилі ФП, необхідно зрозуміти, чи дійсно вам це необхідно. Часто золота середина — це вміле комбінування методів ФП та ООП.
Для більшого занурення в основи функціонального програмування раджу вам ознайомитися з додатковою літературою. Також залишу корисні посилання:
Найкращі коментарі пропустити