Рассказывает rajaraodv
Функциональное программирование, или ФП, может изменить стиль вашего написания программ к лучшему. Но освоить его довольно непросто, а многие посты и туториалы не рассматривают детали (вроде монад, аппликативных функторов и т.п.) и не предоставляют примеры из практики, которые помогли бы новичкам использовать мощные техники ФП ежедневно. Поэтому я решил написать статью, в которой освещу основные идеи ФП.
В первой части вы изучите основы ФП, такие как каррирование, чистые функции, 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-fantasy, immutable-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.
- Если есть объект, который может равняться null или иметь нулевые свойства, создаем экземпляр монады из него.
- Используем библиотеки, вроде ramdajs, чтобы получить значение из монады и работать с ним.
- Возвращаем значение по умолчанию, если данные равняются 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')
Каррирование
Освещенные темы: чистые функции и композиция.
Если мы хотим создавать серии вызовов функций, как то 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
В следующей части мы рассмотрим аппликативные функторы, curryN и Validation Applicative.
Перевод статьи «Functional Programming In JS — With Practical Examples (Part 1)»