Одина річ яка нам потрібна, це ідея чистоти функції.
Чиста функція, це така функція, котра, при однакових вхідних данних, завжди повертатиме однаковий результат і не має жодних видимих побічних ефектів.
Візьмемо, наприклад, slice
та splice
. Це дві функції, які роблять точнісінько одну й ту саму річ, нехай і у абсолютно різний спосіб, подумали ви, але все ж таки одну й ту саму річ. Ми кажемо slice
є чистою, бо вона кожен раз гарантовано повертає одноковий результат. splice
, натомість, пережує отриманий масив та виплюне назавжди змінений, що є нічим іншим як видимим побічним ефектом.
const xs = [1,2,3,4,5];
// чисто:
xs.slice(0,3); // [1,2,3]
xs.slice(0,3); // [1,2,3]
xs.slice(0,3); // [1,2,3]
// не чисто:
xs.splice(0,3); // [1,2,3]
xs.splice(0,3); // [4,5]
xs.splice(0,3); // []
У функціональному програмуванні ми зневажаємо, так би мовити, функція як от splice
, які мутують(змінюють) данні. Нам таке не підходить, оскільки ми прагнемо покладатись на надійні функції, котрі завжди повертають один й той самий результат, а не лишають за собою безлад, як це робить метод splice
.
Давайте розглянемо інший приклад.
// не чисто:
let minimum = 21;
const checkAge = age => age >= minimum;
// чисто
const checkAge = (age) => {
const minimum = 21;
return age >= minimum;
};
У "не чистій" частині, функція checkAge
у своїх підрахунках залежить від змінюємої(мутабельної) змінної minimum
. Інакше кажучи, вона залежить від стану середовища, що не може не засмучувати, оскільки воно збільшує когнітивне навантаження шляхом введення зовнішнього середовища (а тепер зверніть увагу на назву книги, ключове слово - "переважно", переважно адекватне :) ).
У цьому прикладі це може здатись не на стільки вже й значущою проблемою, але ця залежність від стану є одним із основних внесків у складність всієї системи (http://curtclifton.net/papers/MoseleyMarks06a.pdf). Ця функція checkAge
може повертати різні результати в залежності від факторів, котрі є сторонніми по відношенню до вхідних данних, що не тільки віддаляє її від так званої "чистоти", але ще й змушує нас напрягатись кожен раз, коли ми розмірковуємо над програмним забезпеченням.
А от ця чиста форма - абсолютно самодостатня. Ми можемо також зробити minimum
іммутабельним(незмінюваним), що збереже чистоту, осткільки стан ніколи не зміниться. Щоб зробити це, нам потрібно всього лиш створити "заморожений" об'єкт.
const immutableState = Object.freeze({ minimum: 21 });
Давайте більш детальніше поглянемо на ці "побічні ефекти", для того, щоб покращити нашу інтуіцію. То щож це за такі мерзенні побічні ефекти про які згадувалось у визначенні чистої функції? Ми будемо посилатись на ефекти, як щось, що стається під час виконання наших розрахунків і не є безпосереднім вираховуванням результату.
Власне кажучи, немає нічого поганого в ефектах і ми будемо використовувати їх усюди а наступних розділах. А от побічні - це саме те, що несе в собі додаткове і негативне значення. Вода, сама по собі, не є інкубатором для личинок, але це застій води призводить до їх розмноження, і я вас запевняю, що побічні ефекти - це таке саме середовище в наших програмах.
Побічний ефект - це зміна стану системи чи видима взаємодія з зовнішнім світом, що виникає в ході вирахунку результату.
Побічні ефекти включаються в себе(і не обмежуються лише цим переліком)
- зміна файлової системи
- внесення запису у базу данних
- виконання http запиту
- мутації
- вивід на екран / логування
- одержання користувацького вводу
- запит до DOM
- доступ до стану системи
І цей перелік можна продовжувати і продовжувати. Будь-яка взаємодія зі світом, який знаходиться поза межами функції є побічним ефектом, який, напевно, змусить вас засумніватися в практичності програмування без їх використання. Філософія функціонального програмування стоїть горою на тому, що побічні ефекти є головною причиною неправильної поведінки.
Не те щоб нам було суворо забороненно їх використовувати, ні, ми радше воліємо опанувати їх і використовувати контрольовано. Ми вивчимо як це робити, коли дістанемось до функторів і монад в подальших розділах, але покищо, давайте тримати ці підступні функції окремо від наших чистих.
Через побічні ефекти функція припиняє бути чистою. І в цьому є сенс: чисті функції, за визначенням, мають завжди повертати однаковий результат при однакових вхідних даних, що неможливо гарантувати, коли щось залежить від оточуючого світу поза межами конкретної функції.
Давайте більш ретельно розглянемо, чому ми наполягаємо на однакових результатах при однакових вхідних даних. Підніміть ваші комірці, бо зараз ми з вами поглянемо на математику за 8ий клас.
З mathisfun.com:
Функція - спеціальни зв'язок між значеннями: Кожна її вхідна величина віддає рівно одне вихідне значення.
Інакше кажучи, це лише зв'язок між двума величинами: вхідними даними та результатом. Не дивлячись на те, що кожена вхідна величина має конкретно одне кінцеве значення, це не означає що кінцеве значення мусить бути унікальним для кожнної вхідної величини. Нижче наведена діаграма з ідеально валідною функцією:
(https://www.mathsisfun.com/sets/function.html)
На противагу цьому, наступна діаграмма демонструє зв'язок, що не є функцією, оскільки значення вхідної величини(5
) веде до кількох рузельтатів:
(https://www.mathsisfun.com/sets/function.html)
Фунції можуть бути описані як набір пар з положенням (вхідна величина, результат): [(1,2), (3,6), (5,10)]
(Схоже на те, що ця функція подвоює отримувану величину).
Чи можлива таблиця:
Вхідна величина | Результат |
---|---|
1 | 2 |
2 | 4 |
3 | 6 |
Чи навіть графік з x
як вхідна величина та y
як результат:
Немає жодної потреба в деталях реалізації, до тих пір, поки вхідна величина диктує результат. А оскільки функції є простими поєднаннями вхідних величини та результатів, то можна побачити, що модна прибрати фігурні дужки і запустити функцію з []
замість ()
.
const toLowerCase = {
A: 'a',
B: 'b',
C: 'c',
D: 'd',
E: 'e',
F: 'f',
};
toLowerCase['C']; // 'c'
const isPrime = {
1: false,
2: true,
3: true,
4: false,
5: true,
6: false,
};
isPrime[3]; // true
Звісно, ви можете захотіти вираховувати замість вручну виписувати результати, але це демонструє різні способи думати про функції. (Ви можете подумати "а що ж щодо функції з кількома аргументами?". Дійсно, це трохи незручно, коли мислимо з точки зору математики. Покищо, ми можемо скласти їх в масив чи просто думати про об'єкт arguments
як про вхідну величину. Коли ми почнемо вчити каррування, ми побачимо, як ми можемо безпосередньо моделювати математичне визначення функції).
І тут настає драматичне відкриття: чисті функції - це математичні функції, і саме це - функціональне програмування. Програмування за допомогою цих маленьких ангелів може забезпечити величезну користь. Давайте розглянемо деякі причини, чому ми можемо вдатись до великих довжин, заради збереження чистоти.
Для початку, чисті функції завжди можуть бути закешовані вхідною величиною. Це робиться за допомогою техніки, яка називається мемоізація(memoization):
const squareNumber = memoize(x => x * x);
squareNumber(4); // 16
squareNumber(4); // 16, повертає кеш для вхіного значення 4
//=> 16
squareNumber(5); // 25
squareNumber(5); // 25, повертає кеш для вхіного значення 5
Ось проста реалізація, хоча існує безліч більш надійних версій.
const memoize = (f) => {
const cache = {};
return (...args) => {
const argStr = JSON.stringify(args);
cache[argStr] = cache[argStr] || f(...args);
return cache[argStr];
};
};
Варто відзначити, що ви можете перетворити деякі не чисті функції у чисті за допомогою відтермінування обчислення(evaluation):
const pureHttpCall = memoize((url, params) => () => $.getJSON(url, params));
Цікавий момент тут це те, що ми не виконуємо http запит - ми, натомість, повертаємо функцію, яка виконає запит в момент коли її викличуть. Ця функція є чистою, бо вона завжди поверне однаковий результат при одному й тому самому вхідному значенні: фунцію, що виконає конкретний http запит з аргументами url
та params
.
Наша memoize
функція працює чудово, не дивлячись на те, що вона не кешує результат http запиту, бо вона кешує згенеровану функцію.
Це, покищо, не дуже корисно, але ми скоро вивчимо деякі фокуси, які нам в цьому допоможуть. Висновок полягає в тому, що ми можемо кешувати кожну функцію, в не залежності наскільки руйнівною вона виглядає.
Чисті функції повністю автономні. Все що потрібно функції передається в неї, як на срібній таці. Обдумайте це хвилинку... Але як це може бути користим? Ну, для початку, залежності функції є явними, тому їх простіше бачити і розуміти - жодних смішних процесів під капотом.
//не чиста
const signUp = (attrs) => {
const user = saveUser(attrs);
welcomeUser(user);
};
//чиста
const signUp = (Db, Email, attrs) => () => {
const user = saveUser(Db, attrs);
welcomeUser(Email, user);
};
Цей приклад демострує, що чиста функція має бути чесною про свої залежності і тому каже нам точно про що вона. Вже по її сигнатурі ми знаємо, що вона буде використовувати Db
, Email
, та attrs
, і це важливо це продемонструвати.
Ми вивчимо, як робити функції чистими як ця без відкладеного обчислення(evaluation), але має бути чітко зрозуміло, що чиста форма набагато інформативніша, аніж її підступна та слизька аналогія, про яку лише Богові відомо, що і як вона робить насправді.
Іще важливо відзначити, що ми змушені "вставляти"("inject") залежності чи передавати їх в якості аргументів, що робить нашу програму більш гнучкою, бо ми параметризували нашу базу даних чи поштовий клієнт чи будь що ще (не хвилюйтесь, ми побачимо як робити це менш неприємним ніж воно звучить). Якщо ми раптом вирішили використовувати іншу базу даних, нам лише потрібно викликати нашу функцію з цією залежністю. Якщо ми пишимо нову програму в якій ми хотіли б використати нашу надійну функцію - ми просто передаємо в неї будь-яку Db
та Email
яка нам потрібна в цьому випадку.
У JavaScript налаштування та портативність можуть значити серіалізацію та відправку функцій через сокет (socket). Це може означати, що весь код нашої програми може виконуватись у веб-воркерах (web workers). Портативність - дуже потужна риса.
На відміну від "типових" методів та процедур в імперативному програмуванні, глибоко занурених у їх оточення через стан, залежності та доступні ефекти, чисті функції можуть бути запущені будь-де, де наше серце тільки забажає.
Коли в останній раз ви скопіювали метод у нову програму? Одна з моїх улюблених цитат належить творцю Ерланга Джо Армстронгу: «Проблема з об'єктно-орієнтованими мовами - це все це неявне оточення, яке вони несуть із собою. Ви хотіли банан, але те, що ви отримали, - це горилла, яка тримає банан ... і всі джунглі".
Наступним ми усвідомлюємо, що чисті функції роблять тестування значно простішим. Нам не потрібно робити заглушку(mock) зі "справжньою" відповіддю, чи налаштувати та зазначити стан програми після кожного тесту. Ми просто передаємо функції вхідне значення і зазначаємо вихідне.
Насправді, ми розуміємо, що функціональна спільнота розробляє нові новаторські інструменти тестування, які можуть вистрілювати наші функції зі згенерованими вхідними значеннями та стверджувати, що властивості все таки лишаються на виході. Це поза межами цієї книги, але я наполегливо рекомендую вам пошукати та спробувати Quickcheck - інструмент тестування, який призначений для суто функціонального середовища.
Багато людей вірять, що найбільшою перемогою, при роботі з чистими функціями є референтна прозорість. Шматок коду референтно прозорий тоді, коли він може бути замінений на його обчислений результат без зміни поведінки програми.
Оскільки чисті функції не мають побічних ефектів, вони можуть впливати на поведінку програми лише через свої вихідні значення. Більше того, оскільки їх вихідні значення можуть бути надійно розраховані лише за допомогою їх вхідних значень, чисті функції завжди зберігають референтну прозорість. Давайте подивимося на приклад.
const { Map } = require('immutable');
// Aliases: p = player, a = attacker, t = target
const jobe = Map({ name: 'Jobe', hp: 20, team: 'red' });
const michael = Map({ name: 'Michael', hp: 20, team: 'green' });
const decrementHP = p => p.set('hp', p.get('hp') - 1);
const isSameTeam = (p1, p2) => p1.get('team') === p2.get('team');
const punch = (a, t) => (isSameTeam(a, t) ? t : decrementHP(t));
punch(jobe, michael); // Map({name:'Michael', hp:19, team: 'green'})
decrementHP
, isSameTeam
та punch
- чисті функції і тому референтно прозорі. Для обмірковування коду, ми можемо використати техніку, яку називають рівноправними міркуваннями, де один замінює "рівними для рівних". Це трохи схоже на ручну оцінку коду без урахування примх програмної оцінки. Використовуючи референтну прозорість, давайте трохи пограємо з цим кодом.
Спочатку ми запишимо в одну строку функцію isSameTeam
.
const punch = (a, t) => (a.get('team') === t.get('team') ? t : decrementHP(t));
Оскільки наші дані немутабельні, ми можемо просто замінити команди їхніми актуальними значеннями.
const punch = (a, t) => ('red' === 'green' ? t : decrementHP(t));
Ми бачимо, що це false
у цьому випадку, тож ми можемо прибрати усю if гілку
const punch = (a, t) => decrementHP(t);
І якщо ми запишимо строкою decrementHP
то побачимо, що, в цьому випадку, punch перетворюється на виклик зменшення hp
на 1.
const punch = (a, t) => t.set('hp', t.get('hp') - 1);
Ця здатність до роздумів щодо коду надзвичайна для обробки/виправлення та розуміння коду взагалі. Насправді, ми використали цю техніку для виправлення нашої програми про чайок. Ми використовували порівняльні обгрунтовування, щоб використовувати властивості додавання та множення. І, насправді, ми будемо використовувати ці методи в усій книзі.
I нарешті переламний момент: ми можемо виконувати будь-яку чисту функцію паралельно, оскільки вона не потребує доступу до спільної пам'яті, і, згідно визначенню, вона не може бути в стані перегонів через якийсь побічний ефект.
Це дуже вірогідний сценарій у js-середовищі з потоками на стороні сервера, а також в браузері з веб-воркерами(пер. web workers), хоча існуюча культура, здається, уникає його через складність при роботі з нечистими функціями.
Ми дізнались, що таке чисті функції і чому ми, як програмісти функціонального стилю, вважаємо, що вони - святкове вбрання для котиків. З цього моменту ми будемо намагатись писати всі наші функції чисто. Нам потрібні додаткові інструменти, які допоможуть нам це зробити, але, тим часом, ми намагатимемось відокремити нечисті функції від решти чистого коду.
Без додаткових інструментів у нашому арсеналі, написання програм за допомогою чистих функцій може бути трохи трудомістким. Ми повинні жонглювати даними, передаючи аргументи всюди де тільки можливо, і при цьому нам заборонено використовувати стан програми, не кажучи вже про побічні ефекти. Як можна писати ці мазохістські програми? Давайте ознайомимось з новим інструментом під назвою каррування(пер. curry).