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

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

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

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

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

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

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

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

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

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

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

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

функциональное программирование

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

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

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

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

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

Є кілька бібліотек, які слідують специфікаціям FL: monet.js, barely-functional, folktalejs, ramda-fantasyimmutable-ext, Fluture и т.д.

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

Такі бібліотеки, як 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 и проверок на null
const 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, хранящий значение 1
let 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.js

function 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];

// Используйте:
<userMaybe>.map(path(['prefs', 'languages', 'primary']))



// Шаг 3. Вместо
return indexURLs['en']; // захардкоженные значения по умолчанию

// Используйте:
<userMayBe>.getOrElse('http://site.com/en')

Каррінг (Currying)

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

Якщо ми хочемо створювати серії викликів функцій, як то 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.075
showTotalPrice(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.075
showTotalPrice(pant) // Error: Price must be numeric
showTotalPrice(chips) // Error: discount cant be applied for items priced below 10

Автор статті –  rajaraodv

Переклад – https://tproger.ru/translations/functional-js-1/

Схожі статті