Функциональное программирование, или ФП, может изменить стиль вашего написания программ к лучшему. Но освоить его довольно непросто, а многие посты и туториалы не рассматривают детали (вроде монад, аппликативных функторов и т.п.) и не предоставляют примеры из практики, которые помогли бы новичкам использовать мощные техники ФП ежедневно. Поэтому я решил написать статью, в которой освещу основные идеи ФП.
В первой части вы изучите основы ФП, такие как каррирование, чистые функции, 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 может вызвать ошибку.
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
constgetUrlForUser=(user)=>{
}
// Объект пользователя
let joeUser={
name:'joe',
email:'Этот адрес электронной почты защищён от спам-ботов. У вас должен быть включен JavaScript для просмотра.',
prefs:{
languages:{
primary:'sp',
secondary:'en'
}
}
};
// Список стартовых страниц в зависимости от выбранного языка
let indexURLs={
}
// Перезаписываем window.location
constshowIndexPage=(url)=>{window.location=url};
|
Решение (Императивное против Функционального):
Не беспокойтесь, если функциональное решение пока вам не понятно, я объясню его шаг за шагом немного позже.
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
|
// Императивный стиль:
// Слишком много if-else и проверок на null
constgetUrlForUser=(user)=>{
if(user==null){// не залогинен
returnindexURLs['en'];// возвращаем страницу по умолчанию
}
if(user.prefs.languages.primary&&user.prefs.languages.primary!='undefined'){
if(indexURLs[user.prefs.languages.primary]){// Если существует перевод
returnindexURLs[user.prefs.languages.primary];
}else{
returnindexURLs['en'];
}
}
}
// вызов
showIndexPage(getUrlForUser(joeUser));
// Функциональный стиль:
// Поначалу чуть сложнее понять, но он намного более надежен)
constR=require('ramda');
constprop=R.prop;
constpath=R.path;
constcurry=R.curry;
constMaybe=require('ramda-fantasy').Maybe;
constgetURLForUser=(user)=>{
returnMaybe(user)// Оборачиваем пользователя в объект Maybe
.map(path(['prefs','languages','primary']))// Используем Ramda чтобы получить язык
.chain(maybeGetUrl);// передаем язык в maybeGetUrl; получаем url или Монаду null
}
constmaybeGetUrl=R.curry(function(allUrls,language){// Каррируем для того, чтобы превратить в функцию с одним параметром
returnMaybe(allUrls[language]);// Возвращаем Монаду(url или null)
})(indexURLs);// Передаем indexURLs вместо того, чтобы обращаться к глобальной переменной
functionboot(user,defaultURL){
showIndexPage(getURLForUser(user).getOrElse(defaultURL));
}
|
Давайте для начала попробуем понять некоторые из концептов ФП, которые были использованы в этом решении.
Функторы
Любой класс или тип данных, который хранит значение и реализует метод map, называется функтором.
Например, Array — это функтор, потому что массив хранит значения и реализует метод map, позволяющий нам применять функцию к значениям, которые он хранит.
|
1
2
3
|
constadd1=(a)=>a+1;
let myArray=newArray(1,2,3,4);// хранит значения
myArray.map(add1)// -> [2,3,4,5] // применяет функции
|
Давайте напишем свой собственный функтор «MyFunctor». Это просто JS-класс (функция-конструктор). Метод map применяет функцию к хранимым значениям и возвращает новый экземпляр MyFunctor.
|
1
2
3
4
5
6
7
8
9
10
11
12
|
constadd1=(a)=>a+1;
classMyFunctor{
constructor(value){
this.val=value;
}
map(fn){ // Применяет функцию к this.val + возвращает новый экземпляр Myfunctor
returnnewMyfunctor(fn(this.val));
}
}
// temp --- это экземпляр Functor, хранящий значение 1
let temp=newMyFunctor(1);
temp.map(add1)// -> temp позволяет нам применить add1
|
Функторы так же должны реализовывать и другие спецификации в дополнение к методу map, но я не буду рассказывать о них в этой статье.
Монады
Монады — это подтип функторов, так как у них есть метод map, но они также реализуют другие методы, например, ap, of, chain.
Ниже представлена простая реализация монады.
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
// Монада - простая реализация
classMonad{
constructor(val){
this.__value=val;
}
staticof(val){// Monad.of проще, чем new Monad(val)
returnnewMonad(val);
};
map(f){// Применяет функцию, возвращает новый экземпляр Monad
returnMonad.of(f(this.__value));
};
join(){// используется для получения значения монады
returnthis.__value;
};
chain(f){// Хелпер, который применяет функцию и возвращает значение монады
returnthis.map(f).join();
};
ap(someOtherMonad){// Используется, чтобы взаимодействовать с другими монадами
returnsomeOtherMonad.map(this.__value);
}
}
|
Обычные монады используются нечасто, в отличие от более специфичных монад, таких как «монада Maybe» и «монада Either«.
«Maybe»-монада
Монада «Maybe» — это класс, который имплементирует спецификацию монады. Её особенность заключается в том, что с помощью нее можно решать проблемы с null и undefined.
В частности, в случае, если данные равны null или undefined, функция map пропускает их.
Код, представленный ниже, показывает имплементацию Maybe-монады в библиотеке ramda-fantasy. Она возвращает экземпляр одного из двух подклассов: Just или Nothing, в зависимости от значения.
Классы Just и Nothing содержат одинаковые методы (map, orElse и т.д.). Отличие между ними в реализации этих самых методов.
Обратите особое внимание на функции «map» и «orElse».
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
|
// Самые важные части реализации Maybe из библиотеки ramda-fantasy
// Для того, чтобы посмотреть полный исходный код, посетите https://github.com/ramda/ramda-fantasy/blob/master/src/Maybe.js
functionMaybe(x){// <-- Главный конструктор, возвращающий Maybe.Just или Nothing
returnx==null?_nothing:Maybe.Just(x);
}
functionJust(x){
this.value=x;
}
util.extend(Just,Maybe);
Just.prototype.isJust=true;
Just.prototype.isNothing=false;
functionNothing(){}
util.extend(Nothing,Maybe);
Nothing.prototype.isNothing=true;
Nothing.prototype.isJust=false;
var_nothing=newNothing();
Maybe.Nothing=function(){
return_nothing;
};
Maybe.Just=function(x){
returnnewJust(x);
};
Maybe.of=Maybe.Just;
Maybe.prototype.of=Maybe.Just;
// функтор
Just.prototype.map=function(f){// Применение map на Just запускает функцию и возвращает Just(результат)
returnthis.of(f(this.value));
};
Nothing.prototype.map=util.returnThis;// <-- Применение Map на Nothing не делает ничего
Just.prototype.getOrElse=function(){
returnthis.value;
};
Nothing.prototype.getOrElse=function(a){
returna;
};
module.exports=Maybe;
|
Давайте поймем, как Maybe-монада осуществляет проверку на null.
- Если есть объект, который может равняться null или иметь нулевые свойства, создаем экземпляр монады из него.
- Используем библиотеки, вроде ramdajs, чтобы получить значение из монады и работать с ним.
- Возвращаем значение по умолчанию, если данные равняются null.
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
// Шаг 1. Вместо
if(user==null){// не залогинен
returnindexURLs['en'];// возвращает значение по умолчанию
}
// Используйте:
Maybe(user)// Возвращает Maybe({userObj}) или Maybe(null)
// Шаг 2. Вместо
if(user.prefs.languages.primary&&user.prefs.languages.primary!='undefined'){
if(indexURLs[user.prefs.languages.primary]){// если есть перевод
returnindexURLs[user.prefs.languages.primary];
// Используйте:
<userMaybe>.map(path(['prefs','languages','primary']))
// Шаг 3. Вместо
returnindexURLs['en'];// захардкоженные значения по умолчанию
// Используйте:
|
Каррирование
Освещенные темы: чистые функции и композиция.
Если мы хотим создавать серии вызовов функций, как то func1.func2.func3 или (func1(func2(func3())), все эти функции должны принимать только один параметр. Например, если func2 принимает два параметра (func2(param1, param2)), мы не сможем включить её в серию.
Но с практической точки зрения многие функции могут принимать несколько параметров. Так как же нам создавать из них цепочки? Ответ: С помощью каррирования.
Каррирование превращает функцию, которая принимает несколько параметров в функцию, которая принимает только один параметр за один раз. Функция не запустится, пока все параметры не будут переданы.
В дополнение, каррирование может быть также использовано в ситуациях, когда мы обращаемся к глобальным значениям.
Давайте снова взглянем на наше решение:
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
// Глобальный список языков
let indexURLs={
}
// Императивный стиль
constgetUrl=(language)=>allUrls[language];// Простой, но склонный к ошибкам и нечистый стиль(обращение к глобальной переменной)
// Функциональный стиль
// До каррирования:
constgetUrl=(allUrls,language)=>{
returnMaybe(allUrls[language]);
}
// После каррирования:
constgetUrl=R.curry(function(allUrls,language){
returnMaybe(allUrls[language]);
});
constmaybeGetUrl=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.
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
|
// Императивный:
// Возвращает ошибку или цену, включающую налог
consttax=(tax,price)=>{
if(!_.isNumber(price))returnnewError("Price must be numeric");
returnprice+(tax *price);
};
// Возвращает ошибку или цену, включающую скидку
constdiscount=(dis,price)=>{
if(!_.isNumber(price))return(newError("Price must be numeric"));
if(price<10)returnnewError("discount cant be applied for items priced below 10");
returnprice-(price *dis);
};
constisError=(e)=>e&&e.name=='Error';
constgetItemPrice=(item)=>item.price;
// Выводит общую цену, включая налог и скидку. Требует обработки нескольких ошибок
constshowTotalPrice=(item,taxPerc,disount)=>{
let price=getItemPrice(item);
let result=tax(taxPerc,price);
if(isError(result)){
returnconsole.log('Error: '+result.message);
}
result=discount(discount,result);
if(isError(result)){
returnconsole.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.
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
varEither=require('ramda-fantasy').Either;
varLeft=Either.Left;
varRight=Either.Right;
consttax=R.curry((tax,price)=>{
if(!_.isNumber(price))returnLeft(newError("Price must be numeric"));// <--Оборачиваем Error в Either.Left
return Right(price+(tax *price));// <-- Оборачиваем результат в Either.Right
});
constdiscount=R.curry((dis,price)=>{
if(!_.isNumber(price))returnLeft(newError("Price must be numeric"));// <--Оборачиваем Error в Either.Left
if(price<10)returnLeft(newError("discount cant be applied for items priced below 10"));// <--Оборачиваем Error в Either.Left
returnRight(price-(price *dis));// <--Оборачиваем result в Either.Right
});
|
Шаг 2: Оберните исходное значение в Right, так как оно валидно.
|
1
|
constgetItemPrice=(item)=>Right(item.price);
|
Шаг 3: Создайте две функции: одну для обработки ошибок, а другую для отображения результата. Оберните их в Either.either (из библиотеки ramda-fantasy.js).
Either.either принимает 3 параметра: обработчик успешного завершения, обработчик ошибок и монаду Either. Сейчас мы можем передать только обработчики, а третий параметр, Either, передать позже.
Как только Either.either получит все три параметра, она передаст третий параметр в обработчик успешного завершения или обработчик ошибок, в зависимости от типа монады: Left или Right.
|
1
2
3
|
constdisplayTotal=(total)=>{console.log(‘Total Price:‘+total)};
constlogError=(error)=>{console.log(‘Error:‘+error.message);};
consteitherLogOrShow=Either.either(logError,displayTotal);
|
Шаг 4: Используйте метод chain, чтобы создать цепочку из нескольких функций, выбрасывающих исключения. Передайте результат их выполнения в Either.either (eitherLogOrShow).
|
1
|
constshowTotalPrice=(item)=>eitherLogOrShow(getItemPrice(item).chain(apply25PercDisc).chain(addCaliTax));
|
Все вместе выглядит следующим образом:
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
|
consttax=R.curry((tax,price)=>{
if(!_.isNumber(price))returnLeft(newError("Price must be numeric"));
return Right(price+(tax *price));
});
constdiscount=R.curry((dis,price)=>{
if(!_.isNumber(price))returnLeft(newError("Price must be numeric"));
if(price<10)returnLeft(newError("discount cant be applied for items priced below 10"));
returnRight(price-(price *dis));
});
constaddCaliTax=(tax(0.1));// 10%
constapply25PercDisc=(discount(0.25));// скидка25%
constgetItemPrice=(item)=>Right(item.price);
constdisplayTotal=(total)=>{console.log('Total Price: '+total)};
constlogError=(error)=>{console.log('Error: '+error.message);};
consteitherLogOrShow=Either.either(logError,displayTotal);
constshowTotalPrice=(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.
Перевод статьи //medium.com/@rajaraodv/functional-programming-in-js-with-practical-examples-part-1-87c2b0dbc276#.44ysl85sk" «Functional Programming In JS — With Practical Examples (Part 1)»
