Функціональне програмування з прикладами на JavaScript. Частина перша. Основна техніка функціонального програмування


Дізнайтесь більше про нові кар'єрні можливості в EchoUA. Цікаві проекти, ринкова оплата, гарний колектив. Надсилайте резюме та приєднуйтеся до нас.

Розповідає rajaraodv


Функціональне програмування, або ФП, може змінити стиль Вашого написання програм на краще. Проте освоїти його непросто, а багато постів і туторіалів не розглядають деталі (типу монад, аплікативних функторів і т. ін.) і не наводять приклади з практики, які допомогли б новачкам використати потужну техніку ФП щодня. Тому я вирішив написати статтю, в якій висвітлю основні ідеї ФП.

У першій частині Ви вивчите засади ФП, такі як карирування, чисті функції, fantasy-land, функтори, монади, Maybe-монади і Either-монади на декількох прикладах.

Функціональне програмування – це стиль написання програм через складання набору функцій.

Основний принцип ФП – обертати практично все у функції, писати багато маленьких багаторазових функцій, а потім просто викликати їх одну за іншою, щоб отримати результат типу (func1.func2.func3) чи в композиційному стилі func1 (func2 (func3())).

Крім цього, структура функцій повинна наслідувати деякі правила, описані нижче.

Отже, у Вас могли постати запитання. Якщо будь-яку задачу можна розв’язати, об’єднуючи виклики декількох функцій, то:

  1. Як реалізувати умови (if-else)? (Порада: використайте монаду Either).
  2. Як перехопити виняток типу Null Exception? (У цьому може допомогти монада Maybe).
  3. Як переконатися в тому, що функція дійсно “багаторазова” і може використовуватися у будь-якому місці? (Чисті функції).
  4. Як переконатися, що дані, які ми передаємо, не змінюються, щоб ми могли б використати їх десь ще? (Чисті функції, імутабельність).
  5. Якщо функція набуває декілька значень, але ланцюжок може передавати тільки одне значення, як ми можемо зробити цю функцію частиною ланцюжка? (Карирування і функції вищого порядку).

Щоб відповісти на зазначені запитання, функціональні мови, на зразок Haskell, надають інструменти і розв’язання з математики, такі як монади, функтори і т. д., з коробки.

Хоча в JavaScript ці інструменти не вбудовані, на щастя, у нього є переваги, що дозволяють людям реалізовувати функції, яких бракує, у власних бібліотеках.

Специфікація Fantasy – Land і бібліотеки ФП

У бібліотеках, що містять такі інструменти, як функтори і монади, реалізуються функції і класи, які наслідують деякі специфікації, щоб надавати функціональність, подібну до стандартної бібліотеки Haskell.

Fantasy-Land – одна з таких специфікацій, в якій описано, як повинні діяти та або інша функція або клас у JS.

На рисунку вище показані всі специфікації та їх залежності. Специфікації – це, по суті, описи функціонала, подібні до інтерфейсів у Java. З точки зору JS, Ви можете думати про специфікації, як про класи або функції-конструктори, які реалізовують деякі методи (map, of, chain), наслідуючи специфікацію.

Наприклад, клас в JavaScript є функтором, якщо він реалізує метод map. Метод map повинен працювати, наслідуючи специфікацію.

Аналогічно, клас в JS є аплікативним функтором, якщо він реалізує функції map і ap.

JS-клас – монада, якщо він реалізує функції, що вимагає функтор, аплікативний функтор, ланцюжок і монада.

Бібліотеки, що наслідують специфікації Fantasy-Land

Які ж з них мені використати?

Такі бібліотеки, як lodash-fp і ramdajs, дозволяють Вам розпочати програмувати у функціональному стилі. Проте вони не реалізують функції, що дозволяють використати ключові математичні концепти (монади, функтори, згортки), а без них неможливо виконувати деякі з реальних задач у функціональному стилі.

Так що на додаток до них Ви повинні використати одну з бібліотек, що наслідують специфікації FL.

Тепер, коли ми знаємо засади, давайте подивимося на декілька практичних прикладів і вивчимо на них деякі з можливостей і техніки функціонального програмування.

Приклад 1: проходимо перевірку на NULL

Тема покриває: функтори, монади, Maybe-монади і карирування. 

Сценарій використання: Ми хочемо показати різні стартові сторінки залежно від мови, вибраної користувачем у налаштуваннях. У цьому прикладі ми реалізовуємо функцію getUrlForUser, яка повертає правильний URL зі списку indexURLs для іспанської мови, вибраної користувачем joeUser.

Проблема: мова може бути не вибрана, тобто дорівнювати null. Також сам користувач може бути не залогінений і дорівнювати null. Вибрана мова може бути не доступна в нашому списку indexURLs. Так що ми повинні потурбуватися про декілька випадків, за яких значень null або undefined може викликати помилку.

const getUrlForUser =  (user)  => {}// Об'єкт пользователяlet joeUser ={ name: 'joe', email: 'joe@example.com', prefs: { languages: { primary: 'sp', secondary: 'en'} }};// Список стартових сторінок залежно від вибраного языкаlet indexURLs ={ 'en': 'http://mysite.com/en', // Англійський 'sp': 'http://mysite.com/sp', // Іспанський 'jp': 'http://mysite.com/jp' // Японський}// Перезаписуємо window.location
const showIndexPage =  (url)  => { window.location = url };

Розв’язання (імперативне проти функціонального):

Не турбуйтеся, якщо функціональне розв’язання поки що Вам не зрозуміле, я поясню його крок за кроком трохи пізніше.

// Імперативний стиль:// Надто багато if - else і перевірок на nullconst getUrlForUser =  (user)  => { if (user == null) { // не залогинен return indexURLs['en']; // повертаємо сторінку за умовчанням } if (user.prefs.languages.primary && user.prefs.languages.primary != 'undefined') { if (indexURLs[user.prefs.languages.primary]) { // Якщо існує переклад return indexURLs[user.prefs.languages.primary]; } else { return indexURLs['en']; } }}// вызовshowIndexPage (getUrlForUser (joeUser));// Функціональний стиль:
// Спочатку трохи складніше зрозуміти, але він набагато надійніший) const R = require ('ramda');const prop = R.prop;const path = R.path;const curry = R.curry;const Maybe = require ('ramda - fantasy').Maybe;const getURLForUser =  (user)  => { return Maybe (user)  // Обертається користувача в об'єкт Maybe .map (path (['prefs', 'languages', 'primary']))  // Використовуваний Ramda щоб отримати мову .chain (maybeGetUrl); // передаємо мову в maybeGetUrl; отримуємо url або Монаду null}const maybeGetUrl = R.curry (function (allUrls, language) { // Каррируем для того, щоб перетворити на функцію з одним параметром return Maybe (allUrls[language]); // Повертаємо Монаду (url або null)}) (indexURLs); // Передаємо indexURLs замість того, щоб звертатися до глобальної переменнойfunction boot (user, defaultURL) { showIndexPage (getURLForUser (user).getOrElse (defaultURL));}
boot (joeUser, 'http://site.com/en'); // 'http://site.com/sp'

Давайте спершу спробуємо зрозуміти деякі з концептів ФП, які були використані в цьому розв’язанні.

Функтори

Будь-який клас або тип даних, який зберігає значення і реалізує метод map, називається функтором.

Наприклад, Array – це функтор, тому що масив зберігає значення і реалізує метод map, що дозволяє нам застосовувати функцію до значень, які він зберігає.

const add1 = (a) => a+1;let myArray = new Array (1, 2, 3, 4); // зберігає значенияmyArray.map (add1) // -> [2,3,4,5] // застосовує функції

Давайте напишемо власний функтор “MyFunctor”. Це просто JS-клас (функція-конструктор). Метод map застосовує функцію до значень, що зберігаються, і повертає новий екземпляр MyFunctor.

const add1 =  (a)  => a + 1;class MyFunctor { constructor (value) { this.val = value; } map (fn) { // Застосовує функцію до this.val + повертає новий екземпляр Myfunctor return new Myfunctor (fn (this.val)); }}// temp --- це екземпляр Functor, що зберігає значення 1let temp = new MyFunctor (1); temp.map (add1)  // -> temp дозволяє нам застосувати add1

Функтори так само повинні реалізовувати інші специфікації на додаток до методу map, але я не розповідатиму про них у цій статті.

Монади

Монади – це підтип функторів, оскільки у них є метод map, але вони також реалізують інші методи, наприклад, ap, of, chain.

Нижче представлена проста реалізація монади.

// Монада - проста реалізація class Monad { constructor (val) { this.__value = val; } static of (val) { // Monad.of простіше, ніж new Monad (val) return new Monad (val); }; map (f) { // Застосовує функцію, повертає новий екземпляр Monad return Monad.of (f (this.__value)); }; join (){ // використовується для набуття значення монади return this.__value; }; chain (f) { // Хелпер, який застосовує функцію і повертає значення монади return this.map (f).join (); };
ap (someOtherMonad) { // Використовується, щоб взаємодіяти з іншими монадами return someOtherMonad.map (this.__value); }}

Звичайні монади використовуються нечасто, на відміну від специфічних монад, таких як “монада Maybe” і “монада Either“.

“Maybe”-монада

Монада ” Maybe” – це клас, який імплементує специфікацію монади. Її особливість полягає в тому, що за її допомогою можна розв’язувати проблеми з null і undefined.

Зокрема, у разі, якщо дані дорівнюють null або undefined, функція map пропускає їх.

Код, представлений нижче, показує імплементацію Maybe-монади у бібліотеці ramda-fantasy. Вона повертає екземпляр одного з двох підкласів: Just чи Nothing, залежно від значення.

Класи Just і Nothing містять однакові методи (map, orElse і т. д.). Відмінність між ними полягає в реалізації цих методів.

Зверніть особливу увагу на функції “map” і ” orElse”

// Найважливіші частини реалізації Maybe з бібліотеки ramda - fantasy// Для того, щоб подивитися повний вихідний код, відвідаєте https://github.com/ramda/ramda-fantasy/blob/master/src/Maybe.jsfunction Maybe (x) { // <-- Головний конструктор, повертаючий Maybe.Just або Nothing return x == null ?_nothing: Maybe.Just (x);}function Just (x) { this.value = x;}util.extend (Just, Maybe);Just.prototype.isJust = true;Just.prototype.isNothing = false;function Nothing (){}util.extend (Nothing, Maybe);Nothing.prototype.isNothing = true;Nothing.prototype.isJust = false;
var_nothing = new Nothing ();Maybe.Nothing = function (){ return_nothing;};Maybe.Just = function (x) { return new Just (x);};Maybe.of = Maybe.Just;Maybe.prototype.of = Maybe.Just;// функторJust.prototype.map = function (f) { // Застосування map на Just запускає функцію і повертає Just (результат) return this.of (f (this.value));};Nothing.prototype.map = util.returnThis; // <-- Застосування Map на Nothing не робить ничегоJust.prototype.getOrElse = function (){ return this.value;};Nothing.prototype.getOrElse = function (a) { return a;};module.exports = Maybe;

Давайте розглянемо, як Maybe-монада здійснює перевірку на null.

  1. Якщо є об’єкт, який може дорівнювати null або мати нульові властивості, створюємо екземпляр монади з нього.
  2. Використовуємо бібліотеки, типу ramdajs, щоб отримати значення монади і працювати з ним.
  3. Повертаємо значення за умовчанням, якщо дані дорівнюють null.
// Крок 1. Вместоif (user == null) { // не залогінений return indexURLs['en']; // повертає значення за умовчанням }// Використайте: Maybe (user)  // Повертає Maybe ({userObj}) або Maybe (null) // Крок 2. Замість if (user.prefs.languages.primary && user.prefs.languages.primary != 'undefined') { if (indexURLs[user.prefs.languages.primary]) { // якщо є переклад return indexURLs[user.prefs.languages.primary]; // Використайте: .map (path (['prefs', 'languages', 'primary']))  // Крок 3. Замість return indexURLs['en']; // захардкоженные значення за умовчанням // Використайте:.getOrElse ('http://site.com/en')

Карирування

Висвітлені теми: чисті функції і композиція.

Якщо ми хочемо створювати серії викликів функцій, як те func1.func2.func3 чи (func1 (func2 (func3 ())), всі ці функції повинні приймати тільки один параметр. Наприклад, якщо func2 приймає два параметри (func2 (param1, param2)), ми не зможемо включити її в серію.

З практичної точки зору, багато функцій можуть приймати декілька параметрів. Як же нам створювати з них ланцюжки? Відповідь: За допомогою карирування.

Карирування перетворює функцію, яка приймає декілька параметрів на функцію, яка приймає тільки один параметр за один раз. Функція не запуститься, поки всі параметри не будуть передані.

На додаток, карирування може бути також використане в ситуаціях, коли ми звертаємося до глобальних значень.

Давайте знову подивимось на наше розв’язання:

// Глобальний список мов let indexURLs ={ 'en': 'http://mysite.com/en', // Англійський 'sp': 'http://mysite.com/sp', // Іспанський 'jp': 'http://mysite.com/jp' // Японський}// Імперативний стильconst getUrl =  (language)  => allUrls[language]; // Простий, але схильний до помилок і нечистий стиль (звернення до глобальної змінної) // Функціональний стиль// До каррирования: const getUrl =  (allUrls, language)  => { return Maybe (allUrls[language]);}// Після каррирования: const getUrl = R.curry (function (allUrls, language) { return Maybe (allUrls[language]);});const maybeGetUrl = getUrl (indexURLs)  // Зберігаємо глобальне значення в каррированной функції
// maybeGetUrl вимагає тільки один аргумент, так що можемо об'єднати в ланцюжок: maybe (user).chain (maybeGetUrl).bla.bla

Приклад 2: обробка функцій, що виводять виняток і вихід відразу після помилки

Висвітлені теми: Монада ” Either”

Монада Maybe підходить нам, щоб обробити помилки, пов’язані з null і undefined. Проте що робити з функціями, від яких вимагається виводити виняток? І як визначити, яка з функцій в ланцюжку викликала помилку, коли в серії декілька функцій, що виводять виняток?

Наприклад, якщо func2 з ланцюжка func1.func2.func3... вивела виняток, ми повинні пропустити виклик func3 і подальші функції та коректно обробити помилку.

Монада Either

Монада Either чудово підійде для подібної ситуації.

Приклад використання: У прикладі нижче ми розраховуємо “tax” і “discont” для “items” і врешті-решт викликаємо showTotalPrice.

Зверніть увагу на те, що функції “tax” і ” discount” виведуть виняток, якщо як ціну передане нечислове значення. Функція “discount”, крім цього, поверне помилку у разі, якщо ціна буде меншою за 10.

// Імперативний:// Повертає помилку або ціну, що включає налогconst tax =  (tax, price)  => { if (!_.isNumber (price)) return new Error ("Price must be numeric"); return price +  (tax * price);};// Повертає помилку або ціну, що включає скидкуconst discount =  (dis, price)  => { if (!_.isNumber (price)) return (new Error ("Price must be numeric")); if (price < 10) return new Error ("discount cant be applied for items priced below 10"); return price - (price * dis);}; const isError = (e) => e && e.name == 'Error';const getItemPrice =  (item)  => item.price;// Виводить загальну ціну, включаючи податок і знижку. Вимагає обробки декількох ошибокconst showTotalPrice =  (item, taxPerc, disount)  => { let price = getItemPrice (item); let result = tax (taxPerc, price); if (isError (result)) { return console.log ('Error: ' + result.message); } result = discount (discount, result); if (isError (result)) { return console.log ('Error: ' + result.message); } // виводимо результат console.log ('Total Price: ' + result);}let tShirt ={ name: 't - shirt', price: 11 };let pant ={ name: 't - shirt', price: '10 dollars'};let chips ={ name: 't - shirt', price: 5 }; // ошибкаshowTotalPrice (tShirt)  // Total Is: 9.075showtotalprice (pant)  // Error: Price must be numeric
showTotalPrice (chips)  // Error: discount cant be applied for items priced below 10

Давайте подивимося, як можна реалізувати цей приклад у функціональному стилі, використовуючи монаду Either.

Either-монада надає два конструктори: “Either.Left” і “Either.Right”. Думайте про них, як про підкласи Either. І “Left”, і “Right” також є монадами. Ідея полягає в тому, щоб зберігати помилки або винятки Left і корисні значення в Right.

Екземпляри Either.Left чи Either.Right створюються залежно від значення функції.

Так давайте ж подивимося, як змінити наш імперативний приклад на функціональний.

Крок 1: Оберніть повернені значення в Left і Right. “Обертання” означає створення екземпляра класу за допомогою оператора new.

var Either = require ('ramda - fantasy').Either;var Left = Either.Left;var Right = Either.Right;const tax = R.curry ((tax, price) => { if (!_.isNumber (price)) return Left (new Error ("Price must be numeric")); // <--Обертається Error в Either.Left return Right (price +  (tax * price)); // <-- Обертаємо результат в Either.Right});const discount = R.curry ((dis, price) => { if (!_.isNumber (price)) return Left (new Error ("Price must be numeric")); // <--Обертається Error в Either.Left if (price < 10) return Left (new Error ("discount cant be applied for items priced below 10")); // <--Обертається Error в Either.Left
return Right (price - (price * dis)); // <--Обертається result в Either.Right});

Крок 2: Оберніть початкове значення в Right, оскільки воно валідне.

const getItemPrice =  (item)  => Right (item.price);

Крок 3: Створіть дві функції: одну для обробки помилок, а іншу для відображення результату. Оберніть їх в Either.either (з бібліотеки ramda-fantasy.js).

Either.either приймає 3 параметри: обробник успішного завершення, обробник помилок і монаду Either. Зараз ми можемо передати тільки обробники, а третій параметр, Either, передати пізніше.

Щойно Either.either отримає три параметри, вона передасть третій параметр в обробник успішного завершення або обробник помилок, залежно від типу монади: Left чи Right.

const displayTotal =  (total)  => { console.log ('Total Price: ' + total) };const logError =  (error)  => { console.log ('Error: ' + error.message); };const eitherLogOrShow = Either.either (logError, displayTotal);

Крок 4: Використайте метод chain, щоб створити ланцюжок з декількох функцій, що виводять винятки. Передайте результат їх виконання в Either.either (eitherLogOrShow).

const showTotalPrice =  (item)  => eitherLogOrShow (getItemPrice (item).chain (apply25PercDisc).chain (addCaliTax));

Все разом набуває такого вигляду:

const tax = R.curry ((tax, price) => { if (!_.isNumber (price)) return Left (new Error ("Price must be numeric")); return Right (price +  (tax * price));});const discount = R.curry ((dis, price) => { if (!_.isNumber (price)) return Left (new Error ("Price must be numeric")); if (price < 10) return Left (new Error ("discount cant be applied for items priced below 10")); return Right (price - (price * dis));});const addCaliTax = (tax (0.1)); // 10%const apply25PercDisc = (discount (0.25)); // скидка25%const getItemPrice = (item) => Right (item.price);
const displayTotal =  (total)  => { console.log ('Total Price: ' + total) };const logError =  (error)  => { console.log ('Error: ' + error.message); };const eitherLogOrShow = Either.either (logError, displayTotal);const showTotalPrice =  (item)  => eitherLogOrShow (getItemPrice (item).chain (apply25PercDisc).chain (addCaliTax));let tShirt ={ name: 't - shirt', price: 11 };let pant ={ name: 't - shirt', price: '10 dollars'}; // ошибкаlet chips ={ name: 't - shirt', price: 5 }; // ошибкаshowTotalPrice (tShirt)  // Total Is: 9.075showtotalprice (pant)  // Error: Price must be numericshowTotalPrice (chips)  // Error: discount cant be applied for items priced below 10

У наступній частині ми розглянемо аплікативні функтори, curryN і Validation Applicative.

Переклад статті “Functional Programming In JS – With Practical Examples (Part 1)”

Київ, Харків, Одеса, Дніпро, Запоріжжя, Кривий Ріг, Вінниця, Херсон, Черкаси, Житомир, Хмельницький, Чернівці, Рівне, Івано-Франківськ, Кременчук, Тернопіль, Луцьк, Ужгород, Кам'янець-Подільський, Стрий - за статистикою саме з цих міст програмісти найбільше переїжджають працювати до Львова. А Ви розглядаєте relocate?


Залишити відповідь

Ваша e-mail адреса не оприлюднюватиметься. Обов’язкові поля позначені *