Функциональное программирование (ФП) может улучшить ваш подход к написанию кода. Но ФП непросто освоить. Многие статьи и руководства не уделяют внимания таким подробностям, как монады (Monads), аппликативность (Applicative) и т. д., не приводят в качестве иллюстраций практические примеры, которые могли бы помочь нам в повседневном использовании мощных ФП-методик. Я решил исправить это упущение.
Хочу подчеркнуть: в статье сделан упор на том, ЗАЧЕМ нужна фича Х, а не на том, ЧТО такое фича Х.
Функциональное программирование
ФП — это стиль написания программ, при котором просто комбинируется набор функций. В частности, ФП подразумевает обёртывание в функции практически всего подряд. Приходится писать много маленьких многократно используемых функций и вызывать их одну за другой, чтобы получить результат вроде (func1.func2.func3) или комбинации типа func1(func2(func3())).
Но чтобы действительно писать программы в таком стиле, функции должны следовать определённым правилам и решать некоторые проблемы.
Проблемы ФП
Если всё можно сделать путём комбинирования набора функций, то…
- Как обрабатывать условие if-else? (Подсказка: монада Either)
- Как обрабатывать исключения Null? (Подсказка: монада Maybe)
- Как удостовериться, что функции действительно многократно используемые и могут использоваться везде? (Подсказка: чистые функции (Pure functions), ссылочная прозрачность (referential transparency))
- Как удостовериться, что данные, передаваемые нами в функции, не изменены и могут использоваться и в других местах? (Подсказка: чистые функции (Pure functions), неизменяемость)
- Если функция берёт несколько значений, но при объединении в цепочку (chaining) можно передавать только по одной функции, то как нам сделать эту функцию частью цепочки? (Подсказка: каррирование (currying) и функции высшего порядка)
- И многое другое <добавьте сюда ваш вопрос>.
ФП-решение
Для решения всех этих проблем полностью функциональные языки вроде Haskell из коробки предоставляют разные инструменты и математические концепции, например монады, функторы и т. д. JavaScript из коробки не даёт такого обилия инструментов, но, к счастью, у него достаточный набор ФП-свойств, позволяющих писать библиотеки.
Спецификации Fantasy Land и ФП-библиотеки
Если библиотеки хотят предоставить такие возможности, как функторы, монады и пр., то им нужно реализовать функции/классы, удовлетворяющие определённым спецификациям, чтобы предоставляемые возможности были такими же, как в языках вроде Haskell.
Яркий пример — спецификации Fantasy Land, объясняющие, как должна себя вести каждая JS-функция/класс.
На иллюстрации изображены все спецификации и их зависимости. Спецификации — по сути законы, они аналогичны интерфейсам в Java. С точки зрения JS спецификации можно рассматривать как классы или функции-конструкторы, реализующие в соответствии со спецификацией некоторые методы (вроде map, of, chain и т. д.).
Например:
JS-класс — это функтор (Functor), если он реализует метод map. И метод должен работать так, как предписано спецификацией (объяснение упрощённое, правил на самом деле больше).
JS-класс — это функтор Apply (Apply Functor), если он в соответствии со спецификацией реализует функции map и ap.
JS-класс — это монада (Monad Functor), если он реализует требования Functor, Apply, Applicative, Chain и самой Monad (в соответствии с цепочкой зависимостей).
Примечание: зависимость может выглядеть как наследование, но необязательно. Например, монада реализует обе спецификации — Applicative и Chain (в дополнение к остальным).
Библиотеки, совместимые со спецификациями Fantasy Land
Есть несколько библиотек, реализующих спецификации FL. Например: monet.js, barely-functional, folktalejs, ramda-fantasy (на базе Ramda), immutable-ext (на базе ImmutableJS), Fluture и др.
Какие библиотеки мне лучше использовать?
Библиотеки наподобие lodash-fp и ramdajs позволят только начать писать в стиле ФП. Но они не предоставляют функции для использования ключевых математических концепций вроде монад, функторов или редьюсера (Foldable), позволяющих решать реальные проблемы.
Так что я бы вдобавок порекомендовал выбрать одну из библиотек, использующих спецификации FL: monet.js, barely-functional, folktalejs, ramda-fantasy (на базе Ramda), immutable-ext (на базе ImmutableJS), Fluture и т. д.
Примечание: я пользуюсь ramdajs и ramda-fantasy.
Итак, мы получили представление об основах, теперь перейдём к практическим примерам и изучим различные возможности и методики ФП.
Пример 1. Работа с проверками на Null
В разделе рассматриваются: функторы, монады, монады Maybe, каррирование
Применение: мы хотим показывать разные начальные страницы в зависимости от пользовательской настройки предпочтительного языка. Нужно написать getUrlForUser, возвращающий соответствующий URL из списка URL’ов (indexURL’ы) для пользовательского (joeUser) предпочтительного языка (испанский).
Проблема: язык не может быть null. И пользователь тоже не может быть null (не залогинен). Языка может не быть в нашем списке indexURL’ов. Поэтому нам нужно позаботиться о многочисленных nulls или undefined.
//TODO Напишите это в императивном и функциональном стилях
const getUrlForUser = (user) => {
//todo
}
//Пользовательский объект
let joeUser = {
name: 'joe',
email: 'joe@example.com',
prefs: {
languages: {
primary: 'sp',
secondary: 'en'
}
}
};
//Глобальная схема indexURL’ов для разных языков
let indexURLs = {
'en': 'http://mysite.com/en', //Английский
'sp': 'http://mysite.com/sp', //Испанский
'jp': 'http://mysite.com/jp' //Японский
}
//apply url to window.location
const showIndexPage = (url) => { window.location = url };
Решение (императивное против функционального):
Не переживайте, если ФП-версия выглядит трудной для понимания. Дальше в этой статье мы разберём её шаг за шагом.
//Императивная версия:
//Слишком много if-else и проверок на null; зависимость от глобальных indexURL’ов; «английские» URL’ы берутся для всех стран по умолчанию
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]) {//если есть локализованная версия, то возвращает indexURLs[user.prefs.languages.primary];
} else {
return indexURLs['en'];
}
}
}
//вызов
showIndexPage(getUrlForUser(joeUser));
//Функциональное программирование:
//(Сначала сложно для понимания, но получается надёжнее, багов меньше)
//Используемые ФП-методики: функторы, монада Maybe и Currying
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, называется функтором (Functor).
Например: массив — это функтор, потому что он может хранить значения и имеет метод map, позволяющий нам применить (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 — это экземпляр функтора, хранящий значение 1
let temp = new MyFunctor(1);
temp.map(add1) //-> temp позволяет нам преобразовать (map) "add1"
P. S. Функторы должны реализовывать и другие спецификации (Fantasy-land) в дополнение к map, но здесь мы этого касаться не будем.
Монады
Монады тоже функторы, т. е. у них есть метод map. Но они реализуют не только его. Если вы снова взглянете на схему зависимостей, то увидите, что монады должны реализовывать разные функции из разных спецификаций, например Apply (метод ap), Applicative (методы ap и of) и Chain (метод chain).
Упрощённое объяснение. В JS монады — это классы или функции-конструкторы, хранящие какие-то данные и реализующие методы 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) {//Применяет функцию, но возвращает другую монаду!
return Monad.of(f(this.__value));
};
join() { // используется для получения значения из монады
return this.__value;
};
chain(f) {//вспомогательная функция, преобразующая (map), а затем извлекающая значение
return this.map(f).join();
};
ap(someOtherMonad) {//используется для работы с несколькими монадами
return someOtherMonad.map(this.__value);
}
}
Обычно используются монады не общего назначения, а более специфические и более полезные. Например, Maybe или Either.
Монада Maybe
Монада Maybe — это класс, реализующий спецификацию монады. Но особенность монады в том, что она корректно обрабатывает значения null или undefined.
В частности, если хранимые данные являются null или undefined, то функция map вообще не выполняет данную функцию, потому не возникает проблем с null и undefined. Такая монада используется в ситуациях, когда приходится иметь дело с null-значениями.
В коде ниже представлена ramda-fantasy реализация монады Maybe. В зависимости от значения она создаёт экземпляр одного из двух разных подклассов — Just или Nothing (значение или полезное, или null/undefined).
Хотя методы Just и Nothing одинаковы (map, orElse и т. д.), Just что-то делает, а Nothing не делает ничего.
Обратите особое внимание на методы 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 или иметь null-свойства, то создаём из него объект-монаду.
- Применим библиотеки наподобие ramdajs, которые используют Maybe для доступа к значению изнутри и снаружи монады.
- Предоставим значение по умолчанию, если реальное значение окажется null (т. е. заранее обработаем null-ошибки).
//Этап 1. Вместо...
if (user == null) { //не залоггирован
return indexURLs['en']; //возвращаем страницу по умолчанию
}
//Используем:
Maybe(user) //Возвращает Maybe({userObj}) или Maybe(null). То есть данные завёрнуты ВНУТРИ Maybe
//Этап 2. Вместо...
if (user.prefs.languages.primary && user.prefs.languages.primary != 'undefined') {
if (indexURLs[user.prefs.languages.primary]) {//если существует локализованная страница,
return indexURLs[user.prefs.languages.primary];
//Используем:
//библиотеку, умеющую работать с данными внутри Maybe, вроде map.path из Ramda:
<userMaybe>.map(path(['prefs', 'languages', 'primary']))
//Этап 3. Вместо...
return indexURLs['en']; //hardcoded default values
//Используем:
//все Maybe-библиотеки предоставляют метод orElse или getOrElse, который вернёт либо актуальные данные, либо значение по умолчанию
<userMayBe>.getOrElse('http://site.com/en')
Currying ?(помогает работать с глобальными данными и мультипараметрическими функциями)
В разделе рассматриваются: чистые функции (Pure functions) и композиция (Composition)
Если мы хотим составить цепочки функций — func1.func2.func3 или (func1(func2(func3())), то каждая из них может брать лишь по одному параметру. Например, если func2 берёт два параметра — func2(param1, param2), то мы не сможем включить её в цепочку!
Но ведь на практике многие функции берут по несколько параметров. Так как же нам их комбинировать? Решение: каррирование (Currying).
Каррирование — это преобразование функции, берущей несколько параметров за один раз, в функцию, берущую только один параметр. Функция не выполнится, пока не будут переданы все параметры.
Кроме того, каррирование можно использовать при обращении к глобальным переменным, т. е. делать это «чисто».
Посмотрим снова на наше решение:
//Ниже показано, как работать с глобальными переменными и при этом делать функции пригодными для включения в цепочки
//Глобал indexURLs сопоставлен с разными языками
let indexURLs = {
'en': 'http://mysite.com/en', //English
'sp': 'http://mysite.com/sp', //Spanish
'jp': 'http://mysite.com/jp' //Japanese
}
//Императивное решение
const getUrl = (language) => allUrls[language]; //Просто, но грязно (обращение к глобальной переменной), и есть риск ошибок
//Функциональное решение
//До каррирования:
const getUrl = (allUrls, language) => {
return Maybe(allUrls[language]);
}
//После каррирования:
const getUrl = R.curry(function(allUrls, language) {//curry преобразует это в функцию с одним аргументом
return Maybe(allUrls[language]);
});
const maybeGetUrl = getUrl(indexURLs) //Хранит в функции curried глобальное значение.
//С этого момента maybeGetUrl нужен только один аргумент (язык). Можно сделать цепочку наподобие
maybe(user).chain(maybeGetUrl).bla.bla
Пример 2. Работа с кидающими ошибки функциями и выход немедленно после возникновения ошибки
В разделе рассматривается: монада Either
Монада Maybe очень удобна, если у нас есть значения «по умолчанию» для замены Null-ошибок. Но что насчёт функций, которым действительно нужно кидать ошибки? И как узнать, какая функция кинула ошибку, если мы собрали в цепочку несколько кидающих ошибки функций? То есть нам нужен быстрый отказ (fast-failure).
Например: у нас есть цепочка func1.func2.func3…, и если func2 кинула ошибку, то нужно пропустить func3 и последующие функции и правильно показать ошибку из func2 для дальнейшей обработки.
Монада Either
Монады Either отлично подходят для работы с несколькими функциями, когда любая из них может кинуть ошибку и захотеть немедленно после этого выйти, так что мы можем определить, где именно произошла ошибка.
Применение: в приведённом ниже императивном коде мы вычисляем tax и discount для item’ов и отображаем showTotalPrice.
Обратите внимание, что функция tax кинет ошибку, если значение цены будет нечисловое. По той же причине кинет ошибку и функция discount. Но, кроме того, discount кинет ошибку, если цена item’а меньше 10.
Поэтому в showTotalPrice проводятся проверки на наличие ошибок.
//Императивное решение
//Возвращает либо ошибку, либо цену с учётом налога
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 }; //less than 10 dollars error
showTotalPrice(tShirt) // Сумма: 9,075
showTotalPrice(pant) // Ошибка: цена должна быть числом
showTotalPrice(chips) // Ошибка: скидка не применяется к цене ниже 10
Посмотрим, как можно улучшить showTotalPrice с помощью монады Either и переписать всё в ФП-стиле.
Монада Either предоставляет два конструктора: Either.Left и Either.Right. Их можно считать подклассами Either. Left и Right — это монады! Идея в том, чтобы хранить ошибки/исключения в Left, а полезные значения — в Right. То есть в зависимости от значения создаём экземпляр Either.Left или Either.Right. Сделав так, мы можем применить к этим значениям map, chain и т. д.
Хотя и Left, и Right предоставляют map, chain и пр., конструктор Left только хранит ошибки, а все функции реализует конструктор 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")); //<--Обернём ошибку в 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")); //<--Wrap Error in Either.Left
if (price < 10) return Left(new Error("discount cant be applied for items priced below 10")); //<-- Обернём ошибку в Either.Left
return Right(price - (price * dis)); //<--Обернём результат в Either.Right
});
Этап 2. Обернём исходное значение в Right, потому что оно валидное и мы можем его комбинировать (compose).
const getItemPrice = (item) => Right(item.price);
Этап 3. Создадим две функции: одну для обработки ошибки, вторую для обработки результата. Обернём их в Either.either (из ramda-fantasy.js api).
Either.either берёт три параметра: обработчика результата, обработчика ошибки и монаду Either. Either каррирована, поэтому мы можем передать обработчики сейчас, а Either (третий параметр) — позже.
Как только Either.either получает все три параметра, она передаёт Either либо в обработчик результата, либо в обработчик ошибки, в зависимости от того, чем является Either — Right или Left.
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);
//api
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' }; //error
let chips = { name: 't-shirt', price: 5 }; //less than 10 dollars error
showTotalPrice(tShirt) // Сумма: 9,075
showTotalPrice(pant) // Ошибка: цена должна быть числом
showTotalPrice(chips) // Ошибка: скидка не применяется к цене ниже 10
Пример 3. Присвоение значения потенциальным Null-объектам
***Использована ФП-концепция: аппликативность (Applicative)
Применение: допустим, нужно дать пользователю скидку, если он залогинился и если действует промоакция (т. е. существует скидка).
Воспользуемся методом applyDiscount. Он может кидать null-ошибки, если пользователь (слева) или скидка (справа) является null.
//Добавим скидку в объект user, если существует и пользователь, и скидка.
//Null-ошибки кидаются в том случае, если пользователь или скидка является null.
const applyDiscount = (user, discount) => {
let userClone = clone(user);// С помощью какой-нибудь библиотеки сделаем копию
userClone.discount = discount.code;
return userClone;
}
Посмотрим, как можно решить это с помощью аппликативности.
Аппликативность (Applicative)
Любой класс, имеющий метод ap и реализующий спецификацию Applicative, называется аппликативным. Такие классы могут использоваться в функциях, которые работают с null-значениями как с левой стороны (пользователь) уравнения, так и с правой (скидка).
Монады Maybe (как и все монады) тоже реализуют спецификацию ap, а значит, они тоже аппликативные, а не просто монады. Поэтому на функциональном уровне мы можем использовать монады Maybe для работы с null. Посмотрим, как заставить работать applyDiscount с помощью монады Maybe, используемой как аппликативная.
Этап 1. Обернём потенциальные null-значения в монады Maybe.
const maybeUser = Maybe(user);
const maybeDiscount = Maybe(discount);
Этап 2. Перепишем функцию и каррируем её, чтобы передавать по одному параметру за раз.
// перепишем функцию и каррируем её, чтобы
// передавать по одному параметру за раз
var applyDiscount = curry(function(user, discount) {
user.discount = discount.code;
return user;
});
Этап 3. Передадим через map первый аргумент (maybeUser) в applyDiscount.
// передадим через map первый аргумент (maybeUser) в applyDiscount
const maybeApplyDiscountFunc = maybeUser.map(applyDiscount);
//Обратите внимание, что поскольку applyDiscount каррирована и map передаст только один параметр, то возвращаемый результат (maybeApplyDiscountFunc) будет обёрнутой в монаду Maybe функцией applyDiscount, которая в своём замыкании теперь содержит maybeUser(первый параметр).
Иными словами, теперь у нас есть функция, обёрнутая в монаду!
Этап 4. Работаем с maybeApplyDiscountFunc.
На этом этапе maybeApplyDiscountFunc может быть:
1) функцией, обёрнутой в Maybe, — если пользователь существует;
2) Nothing (подклассом Maybe) — если пользователь не существует.
Если пользователь не существует, то возвращается Nothing, а все последующие действия с ним полностью игнорируются. Так что если мы передадим второй аргумент, то ничего не произойдёт. Также не будет null-ошибок.
Если пользователь существует, то можем попытаться передать второй аргумент в maybeApplyDiscountFunc через map, чтобы выполнить функцию:
maybeDiscount.map(maybeApplyDiscountFunc)! // ПРОБЛЕМА!
Ой-ёй: map не знает, как выполнять функцию (maybeApplyDiscountFunc), когда она сама внутри Maybe!
Поэтому нам нужен другой интерфейс для работы по такому сценарию. И этот интерфейс — ap!
Этап 5. Освежим информацию о функции ap. Метод ap берёт другую монаду Maybe и передаёт/применяет к ней хранимую им в данный момент функцию.
class Maybe {
constructor(val) {
this.val = val;
}
...
...
//ap берёт другую maybe и применяет к ней хранящуюся в нём функцию.
//this.val ДОЛЖЕН быть функцией или Nothing (и не может быть строкой или числом)
ap(differentMayBe) {
return differentMayBe.map(this.val);
}
}
Можем просто применить (ap) maybeApplyDiscountFunc к maybeDiscount вместо использования map, как показано ниже. И это будет прекрасно работать!
maybeApplyDiscountFunc.ap(maybeDiscount)
//Поскольку applyDiscount хранится внутри this.val в обёртке maybeApplyDiscountFunc:
maybeDiscount.map(applyDiscount)
//Далее, если maybeDiscount имеет скидку, то функция выполняется. Если maybeDiscount является Null, то ничего не происходит.
Для сведения: очевидно, в спецификации Fantasy Land внесли изменение. В старой версии нужно было писать: Just(f).ap(Just(x)), где f — это функция, х — значение. В новой версии нужно писать Just(x).ap(Just(f)). Но реализации по большей части пока не изменились. Спасибо keithalexander.
Подведём итог. Если у вас есть функция, работающая с несколькими параметрами, каждый из которых может быть null, то сначала каррируйте её, а затем поместите внутрь Maybe. Также поместите в Maybe все параметры, а для исполнения функции воспользуйтесь ap.
Функция curryN
Мы уже знакомы с каррированием. Это простое преобразование функции, чтобы она брала не несколько аргументов сразу, а по одному.
//Пример каррирования:
const add = (a, b) =>a+b;
const curriedAdd = R.curry(add);
const add10 = curriedAdd(10);//Передаёт первый аргумент. Возвращает функцию, берущую второй (b) параметр.
//Запускает функцию после передачи второго параметра.
add10(2) // -> 12 //внутренне запускает add с 10 и 2.
Но что если вместо добавления всего двух чисел функция add могла суммировать все числа, передаваемые в неё в качестве аргументов?
const add = (...args) => R.sum(args); //Суммирует все числа в аргументах
Мы всё ещё можем каррировать её, ограничив количество аргументов с помощью curryN:
//пример curryN
const add = (...args) => R.sum(args);
//пример CurryN:
const add = (...args) => R.sum(args);
const add3Numbers = R.curryN(3, add);
const add5Numbers = R.curryN(5, add);
const add10Numbers = R.curryN(10, add);
add3Numbers(1,2,3) // 6
add3Numbers(1) // возвращает функцию, берущую ещё два параметра.
add3Numbers(1, 2) // возвращает функцию, берущую ещё один параметр.
Использование curryN для ожидания количества вызовов функции
Допустим, нам нужна функция, которая пишет в лог только тогда, когда мы вызвали её три раза (один и два вызова игнорируются). Например:
//грязное решение
let counter = 0;
const logAfter3Calls = () => {
if(++counter == 3)
console.log('called me 3 times');
}
logAfter3Calls() // Ничего не происходит
logAfter3Calls() // Ничего не происходит
logAfter3Calls() // 'called me 3 times'
Можем эмулировать такое поведение с помощью curryN.
//чистое решение
const log = () => {
console.log('called me 3 times');
}
const logAfter3Calls = R.curryN(3, log);
//вызов
logAfter3Calls('')('')('')//'called me 3 times'
//Примечание: мы передаём '', чтобы удовлетворить CurryN фактом передачи параметра.
Примечание: мы будем использовать эту методику при аппликативной валидации.
Пример 4. Сбор и отображение ошибок
В разделе рассматривается: валидация (известна как функтор Validation, аппликативность Validation, монада Validation).
Валидациями обычно называют аппликативность Validation (Validation Applicative), потому что она чаще всего применяется для валидации с использованием функции ap (apply).
Валидации аналогичны монадам Either и часто используются для работы с комбинациями функций, кидающих ошибки. Но в отличие от Either, в которых мы для комбинирования обычно применяем метод chain, в монадах Validation мы для этого обычно используем метод ap. Кроме того, если chain позволяет собирать только первую ошибку, то ap, особенно в монадах Validation, даёт собирать в массив все ошибки.
Обычно эти монады используются при валидации форм ввода, когда нам нужно сразу показать все ошибки.
Применение: у нас есть форма регистрации, которая проверяет имя, пароль и почту с помощью трёх функций (isUsernameValid, isPwdLengthCorrect и ieEmailValid). Нам нужно одновременно показать все три ошибки, если они возникнут.
Для этого воспользуемся функтором Validation. Посмотрим, как это можно реализовать с помощью аппликативности Validation.
Возьмём из folktalejs библиотеку data.validation, в ramda-fantasy она ещё не реализована. По аналогии с монадой Either у нас есть два конструктора: Success и Failure. Это подклассы, каждый из которых реализует спецификации Either.
Этап 1. Для использования валидации нам нужно обернуть правильные значения и ошибки в конструкторы Success и Failure (т. е. создать экземпляры этих классов).
const Validation = require('data.validation') //из folktalejs
const Success = Validation.Success
const Failure = Validation.Failure
const R = require('ramda');
//вместо:
function isUsernameValid(a) {
return /^(0|[1-9][0-9]*)$/.test(a) ?
["Username can't be a number"] : a
}
//используем:
function isUsernameValid(a) {
return /^(0|[1-9][0-9]*)$/.test(a) ?
Failure(["Username can't be a number"]) : Success(a)
}
Повторите процесс для всех проверочных функций, кидающих ошибки.
Этап 2. Создадим пустую функцию (dummy function) для хранения успешного статуса проверки.
const returnSuccess = () => 'success';//просто возвращает success
Этап 3. Используем curryN для многократного применения ap
С ap есть проблема: левая часть должна быть функтором (или монадой), содержащим функцию.
Допустим, нам нужно многократно применить ap. Это будет работать, только если monad1 содержит функцию. И результат monad1.ap(monad2), т. е. resultingMonad, тоже должен быть монадой с функцией, чтобы можно было применить ap к monad3.
let finalResult = monad1.ap(monad2).ap(monad3)
//можно переписать как
let resultingMonad = monad1.ap(monad2)
let finalResult = resultingMonad.ap(monad3)
//будет работать, если monad1 содержит функцию, а результат monad1.ap(monad2) является другой монадой (resultingMonad) с функцией
В общем, чтобы дважды применить ap, нам нужны две монады, содержащие функции.
В данном случае у нас три функции, к которым нужно применить ap.
Допустим, мы сделали что-то такое.
Success(returnSuccess)
.ap(isUsernameValid(username)) //работает
.ap(isPwdLengthCorrect(pwd))//не работает
.ap(ieEmailValid(email))//не работает
Предыдущий код не станет работать, потому что результатом Success(returnSuccess).ap(isUsernameValid(username)) будет значение. И мы не сможем применить ap ко второй и третьей функциям.
Можно использовать curryN, чтобы возвращать функцию до тех пор, пока она не будет вызвана N раз.
//3 — потому что вызываем ap три раза.
let success = R.curryN(3, returnSuccess);
Теперь каррированная success возвращает функцию три раза.
function validateForm(username, pwd, email) {
//3 — потому что вызываем ap три раза.
let success = R.curryN(3, returnSuccess);
return Success(success)// по умолчанию; используется для трёх ap
.ap(isUsernameValid(username))
.ap(isPwdLengthCorrect(pwd))
.ap(ieEmailValid(email))
}
Соберём всё вместе:
const Validation = require('data.validation') //из folktalejs
const Success = Validation.Success
const Failure = Validation.Failure
const R = require('ramda');
function isUsernameValid(a) {
return /^(0|[1-9][0-9]*)$/.test(a) ? Failure(["Username can't be a number"]) : Success(a)
}
function isPwdLengthCorrect(a) {
return a.length == 10 ? Success(a) : Failure(["Password must be 10 characters"])
}
function ieEmailValid(a) {
var re = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
return re.test(a) ? Success(a) : Failure(["Email is not valid"])
}
const returnSuccess = () => 'success';//просто возвращает success
function validateForm(username, pwd, email) {
let success = R.curryN(3, returnSuccess);// 3 — потому что вызываем ap три раза.
return Success(success)
.ap(isUsernameValid(username))
.ap(isPwdLengthCorrect(pwd))
.ap(ieEmailValid(email))
}
validateForm('raja', 'pwd1234567890', 'r@r.com').value;
//Output: success
validateForm('raja', 'pwd', 'r@r.com').value;
//Output: ['Password must be 10 characters' ]
validateForm('raja', 'pwd', 'notAnEmail').value;
//Output: ['Password must be 10 characters', 'Email is not valid']
validateForm('123', 'pwd', 'notAnEmail').value;
//['Username can\'t be a number', 'Password must be 10 characters', 'Email is not valid']
Комментарии (45)
alexkunin
28.04.2017 19:33В примере с валидацией формы curryN выглядит откровенным костылем — с чего бы мне считать самому количество вызовов? Понимающие (в отличие от меня) люди, подскажите, пожалуйста, это обусловлено подходом в целом или конкретной реализацией?
sshikov
01.05.2017 16:17Это выглядит как придуманный пример. В реальном случае полей для валидации будет неизвестное число, и тут было бы что-то совсем другое. Например, список полей.
wheercool
28.04.2017 19:42Если кто-то действительно хочет разобраться в ФП на js то советую почитать это. И на русском языке (но не все переведено)
JSmitty
28.04.2017 20:46Не очень вижу смысл переводить статью, которую на тпрогер публиковали еще в декабре прошлого года. И Zfort в своем дайджесте по фронтэнду №239 приводили ссылку.
TheShock
30.04.2017 14:39+2Как удостовериться, что функции действительно многократно используемые и могут использоваться везде? (Подсказка: чистые функции (Pure functions), ссылочная прозрачность (referential transparency))
Как удостовериться, что данные, передаваемые нами в функции, не изменены и могут использоваться и в других местах? (Подсказка: чистые функции (Pure functions), неизменяемость)
Если функция берёт несколько значений, но при объединении в цепочку (chaining) можно передавать только по одной функции, то как нам сделать эту функцию частью цепочки? (Подсказка: каррирование (currying) и функции высшего порядка)
Из разряда «давайте придумаем несущественные проблемы, которые зато легко решаются на ФП и героически их решим»
Хотелось бы правда практических примеров, а не переповторение одних и тех же «преимуществ».
Вот как быстро убедится, что вa(b(x))
— «a» на самом деле корректно работает с тем, что пришло ему в аргументы, Если в нем эти аргументы передаются в другую процедуру и так далее. Ходить по всему стеку?
Где в чистом фукциональном коде хранить состояние приложения и, Главное, как с ним работать? Только прошу — не приходите в пример Редакс — Это грязная и процедурная библиотека, которая имититирует функциональный контракт.
&& user.prefs.languages.primary != 'undefined'
Куда делась эта проверка в функциональном коде? Зачем столь избыточная проверка в процедурном коде? Она сокращается в 4 раза без потери смысла. Если написать корректно, то вся статья разрушится?
const getUrlForUser = (user) => { const defLang = indexURLs['en']; if (user == null) { //не залоггирован return defLang; } else { return indexURLs[user.prefs.languages.primary] || defLang; } }
Вот код, который делает то же самое, но без искусственных фор для фп. Что, без лжи нельзя доказать преимущество?taujavarob
30.04.2017 22:57+1без лжи нельзя доказать преимущество?
Трудно наверное.
Вот к примеру, стандартная задача в ООП — расчёт зарплаты (веса, и прочего) Института.
Институт состоит из Отделений, Отделения из отделов, отделы из секторов.
В ООП — надо просто:
Институт.geЗарплата();
И всё, ООП — далее уже каждый объект(Отделение, отдел, сектор) имеет метод .geЗарплата();
То есть обычная задача обхода объектов и суммирования того, что выдаёт каждый объект по известному методу.
Она конечно имеет место быть в ФП — и там она тоже решается. Наверное.
Но вот ООП тут ясно как день ясный. — А вот также же ясно будет с ФП?
ООП обычно винят в том, что при передвижении методов в классах — всё… ломается. Да, это так, но:
Программировать в парадигме ООП просто — при разработке библиотеки — передвигай методы в классы, образуй интерфейсы и смотри где можно внедрить абстрактный класс. — Как всё удалось — выпускай библиотеку.
Если что потом надо поломать (метод передвинуть и прочее) — выпускай новую версию библиотеки.
Если программируешь не библиотеку — а прикладную задачу — просто перевыпускай приложение.
И так поступают все кто пишет в парадигме ООП. (С)
Чем же лучше парадигма ФП? — Функции чистые и потому легче тестировать и легче искать ошибки?
И это всё?JSmitty
03.05.2017 14:55Насколько я понимаю ФП, у вас будет какой-то тип a, и функция geЗарплата: a -> b. В питоне методы класса явно принимают объект в параметры, в JS есть .call / .apply — позволяющий указать контекст исполнения метода — то есть вполне себе функционально (хотя и ООП) — geЗарплата.call(a).
Абстрагирование в ФП есть, оно просто иначе строится. И да, на JS это всё местами очень криво ложится — TCO того же до сих пор в браузерах нету.taujavarob
03.05.2017 16:27+1Насколько я понимаю ФП, у вас будет какой-то тип a, и функция geЗарплата: a -> b. В питоне методы класса явно принимают объект в параметры, в JS есть .call / .apply — позволяющий указать контекст исполнения метода — то есть вполне себе функционально (хотя и ООП) — geЗарплата.call(a).
Как это сделать на ООП в JS — это не проблема и это ясно.
Как это сделать в ФП — я не знаю, но очевидно так же возможно.
Вопрос в ином. — Показать что в ФП это не только можно и это более ясно можно реализовать, в крайнем случае если не более ясно, то более чем-то (чем?) лучше реализовать.
Пока такого не встречал.
JSmitty
04.05.2017 10:46+1Классическая задача — определение бинарных (и выше) операций. Операция — классическая функция, и на ООП вид ложится очень некрасиво. Извините, но вот так писать — objA.plus(objB) — просто фу, гораздо лучше — plus(objA, objB). Экстремистское ООП (java) вынуждает писать вот такое во втором случае — Helper.plus(objA, objB). Это всё к чему — есть границы применения. Думаю, что всем понятно, что трансформация массивов через map/filter/reduce удобнее и читается легче. Хотя и по идее медленнее, чем цикл (накладные расходы на вызов функций).
Потоковое преобразование данных, как по мне, гораздо удобнее делать в функциональном ключе. Чем нагораживать тонны абстракций в ООП, лишь бы оно было ООП. С другой стороны, если есть какие-то явно выделяемые сущности (а не исходящие из логики ОО дизайна) и операции, к ним относящиеся — глупо не воспользоваться тем, что в ООД придумано.
Еще ФП упрощает переиспользование кода, в классическом ОО для этого надо очень много телодвижений совершать. Обход дерева — есть тип А, у нее есть свойство children, в котором массив А, надо обойти A. Для типа B, который тоже с children — придется или: а) копипастить код обхода, б) делать абстрактного предка с методом обхода — что может быть недоступно (разные иерархии), в) использовать трейт или (java) дефолтную реализацию интерфейса — что в языках появилось вот только-только по сути. Против старой-доброй функции обхода дерева, внешней к типу — которой будет без разницы, что обходить. И разные действия можно комбинировать и запоминать как новые функции. Которые будут относительно универсальны по вводу и выводу. На ООП такое вынужденно обрастет лесом вспомогательного кода.
По ФП — я бы предложил — более элегантно. Мне лично нравятся тестовые примеры из Mostly adequate guide to FP.TheShock
04.05.2017 17:50+1гораздо лучше — plus(objA, objB).
Это чем лучше? Естественно писатьa + b
, а значитa.plus(b)
— более естественный способ записи. А учитывая перегрузку операторов — он вообще сводится кa + b
.
Думаю, что всем понятно, что трансформация массивов через map/filter/reduce удобнее и читается легче
Мне непонятно. Особенно reduce (особенно в JS) — абсолютно нечитабелен даже на маленьких функциях. Вот вам жизненный пример на JS, где есть map (item => item.id
), filter (item.isValid()
) и reduce — напишите это в функциональном и чистом стиле на JS. Оно будет медленно и нечитабельно:
const hash = {}; for (let item of array) { if (!item.isValid()) continue; if (hash[item.category] == null) { hash[item.category] = [ item.id ]; } else { hash[item.category].push(item.id); } } return hash
На ООП такое вынужденно обрастет лесом вспомогательного кода.
Или просто заюзать одно из двух — интерфейсы или дженерики, зависимо от деталей задачи. И мы кроме кратного кода получим типобезопасность и поддерживаемость.oxidmod
04.05.2017 19:191. Все тело цикла можно просто завернуть в колбек редьюса:
return array.reduce(function (acc, item) { if (!item.isValid()) return acc; acc[item.category] = acc[item.category] || []; acc[item.category].push(item.id); return acc; });
2. Ну или так.
array .filter(item => item.isValid()) .reduce( (acc, item) => { acc[item.category] = acc[item.category] || []; acc[item.category].push(item.id); return acc; }, {} )
TheShock
05.05.2017 00:01+1Ну это разве ФП? Это «я слышал, что reduce — модно, и потому использовал его в своем процедурном коде». Ни чистоты, ни декларативности. У вас дважды мутируются переменные.
А еще первый код работать не будет, потому что вы забыли объявить acc. Но это для reduce нормально — один из его главных и очень критических недостатков, из-за которого он нечитабелен, что по коду содержимое переменной объявляется значительно позже, чем само содержимое используется.
wheercool
04.05.2017 19:48+1Используя Ramda
let group = R.pipe(R.filter(isValid), R.groupBy(d => d.category), R.mapObjIndexed(R.map(d => d.id))) //или R.pipe( R.filter(isValid), R.reduceBy((category, d) => category.concat(d.id), [], d => d.category)) //Используем так: group(array)
Если хотите на родных map, filter, reduce, можно что-то вроде
function group(by, selector) { return function(acc, next) { let key = by(next), newItem = selector(next), category = acc[key]? acc[key]: []; return Object.assign(acc, {[key]: category.concat(newItem)}); } } array.filter(isValid) .reduce(group(d => d.category, d => d.id), {})
Функция group пишется один раз, и может быть использована в других местах.TheShock
05.05.2017 00:14+1Вот это (с Ramda) больше похоже на ФП, только совершенно не соответствует «трансформация массивов через map/filter/reduce удобнее и читается легче». Потому что даже зная Ramda и такой подход читается он хуже императивного (дайте какому либо мидлу код мой и код на Ramda — сами увидите), а уж в больших количествах поддерживать его нереально. Я сам лет 5 назад очень увлекался таким кодом и результате такие места были самые сложные для поддержки, изменения и фикса багов.
Код без Ramda — опять грязный, мутируете acc. Если мутируете, зачем тогда вообще reduce?wheercool
05.05.2017 00:35Код без Ramda — опять грязный, мутируете acc. Если мутируете, зачем тогда вообще reduce?
Да нет же. Object.assign — создает копию. А вообще на самом деле, т.к. js однопоточный и этот объект генерируется только внутри reduce можно было смела заменить на мутации. Получили бы выигрышь в производительности :)TheShock
05.05.2017 01:43+1Object.assign — создает копию
Нет)
const acc = {}; Object.assign(acc, { foo: 123 }); console.log(acc); // Object {foo: 123}
А если бы создавал копию, то получили бы квадратическую сложность для линейного алгоритма исключительно ради понтов.
этот объект генерируется только внутри reduce можно было смела заменить на мутации
А можно просто писать императивно и не заблуждаться о ФП)wheercool
05.05.2017 06:47Да, действительно, забыл добавить {} :).
Вот так надо было
Object.assign({}, acc, {foo: 123});
TheShock
05.05.2017 07:24+2Ну вы ведь понимаете, что такой код делает сложность квадратичной вместо линейной? И зачем?
wheercool
05.05.2017 10:19Конечно понимаю. Я ведь даже писал, что нам на самом деле нет смысла использовать иммутабельные данные внутри reduce.
Вообще говоря это обычная практика когда надо произвести много операций над иммутабельным объектом, то заменять его на мутабельный, производить операции, а затем если нужно делать опять иммутабельным.
Тот же StringBuilder из C# примерно так и работает.
Если мы не используем всякие сторонние библиотеки для иммутабельных структур, то любой объект можно интерпретировать как мутабельный так и иммутабельный. И т.к. в случае reduce создается пустой объект — соответственно нет необходимости в преобразованиях вида
Иммутабельный->Мутабельный и Мутабельный->Иммутабельный
RubaXa
05.05.2017 16:37+2Повторюсь, всё потому, что бытует крайне ошибочное мнение, что JS-функциональный язык. Ramda просто либа, интересная, но это либа, на место которой придет другая.
JS != Haskell, зачем лепить горбатого, пишите на Haskell, транслируйте в WASM и будет вам счастье.
faiwer
05.05.2017 18:22JS != Haskell, зачем лепить горбатого, пишите на Haskell, транслируйте в WASM и будет вам счастье.
Хм. Было бы интересно почитать про подобный практический опыт.
wheercool
04.05.2017 20:12гораздо лучше — plus(objA, objB).
Это чем лучше? Естественно писать a + b, а значит a.plus(b) — более естественный способ записи. А учитывая перегрузку операторов — он вообще сводится к a + b.
Раз Вы сами заговорили про перегрузку операторов, каким образом она реализуется?
... public static ComplexNumber operator+(ComplexNumber a, ComplexNumber b) { return new ComplexNumber(a.real + b.real, a.imaginary + b.imaginary); } ...
Ничего не напоминает статический метод? Да ведь это обычная функция :) Просто ее нужно ведь куда-то поместить… Статический метод, кстати это маркер того, что на самом деле нам нужна функция, а не метод.
На самом деле все просто — если мы пишем a.plus(b) — мы делегируем ответственность за операцию классу a. А чем он лучше чем класс b? (Конечно имеется ввиду что а и b разного типа).
В случае plus(a, b) типы a,b равноценны.TheShock
05.05.2017 00:24+1Да ведь это обычная функция :)
И что? Вообще-то императивный код не отрицает процедур. И чистых функций. НапримерMath.sqrt
. И на том же C# я вполне люблю использовать Linq:
int CountActiveModules (Room[] rooms) { return rooms .Where(room => room.IsActive()) .Sum(room => room.modules); }
Но это именно что «иногда бывает удобнее», а не глупые и вредные крайности «необходимо отказаться от классов и писать все в функциональном стиле»
На самом деле все просто — если мы пишем a.plus(b) — мы делегируем ответственность за операцию классу a. А чем он лучше чем класс b?
А как тогда на ФП написать, когда реально первое «лучше» чем второе. Раз функция — это когда равнозначые. Ну этот пример.
category.concat(newItem)
JSmitty
05.05.2017 12:08+1Или просто заюзать одно из двух — интерфейсы или дженерики, зависимо от деталей задачи. И мы кроме кратного кода получим типобезопасность и поддерживаемость.
Я что-то пропустил, и в JS появились интерфейсы и/или дженерики? Или это код на шарпе?
Это чем лучше? Естественно писать a + b, а значит a.plus(b) — более естественный способ записи. А учитывая перегрузку операторов — он вообще сводится к a + b.
А как вы коммутативность обеспечите в общем случае? Ну там int + float, complex + long? Расширяемость по типам — плюс тип и все классы править/etc?
taujavarob
04.05.2017 21:05+1Операция — классическая функция, и на ООП вид ложится очень некрасиво. Извините, но вот так писать — objA.plus(objB) — просто фу, гораздо лучше — plus(objA, objB). Экстремистское ООП (java) вынуждает писать вот такое во втором случае — Helper.plus(objA, objB).
O, спор о красоте — это уже хорошо — ибо остальное можно просто доказать. ;-)
Понимаете, «plus» то может быть много?
Когда вы пишите plus(objA, objB) вы какой «plus» имеете ввиду? Откуда вы взяли эту функцию?
Вы её наверняка где-то вверху импортировали, к примеру, из какого-то файла, так?
Записи же:
Helper.plus(objA, objB) MyHelper.plus(objA, objB) OtherHelper.plus(objA, objB)
однозначно определяют — прямо в строке — откуда взялась эта функция (обычно статический метод в Java). — Не так ли?
Запись вида
objA.plus(objB) или objA.addObject(objB)
Обычно выражает что objA что-то «втягивает» («прибавляет») к себе (в данном случае объект objB)
И обычно применяется к объектам типа список:
MуList.addObject(objB)
А вот выражение:
addObject(MуList, objB)
уже потребует пояснения о порядке следования параметров — типа, первым следует тот куда добавляют то, что следует вторым. — Согласитесь, это потребует некого напряжения ума. Оно того стоит?
Думается мне, чтобы не заморачиваться (не напрягаться лишний, ненужный раз) в ФП и пишут типа функции с одним параметром, типа:
var addObject = list => obj => { list.push(obj); return list } var mуList = []; addObject(mуList)("test objB"); addObject(mуList)("test objC"); // Возвращает mуList = [ "test objB", "test objC" ]
Хотя это не спасает от того, что приходиться пояснять, что писать:
addObject("test objB")(mуList);
нельзя.
JSmittyДумаю, что всем понятно, что трансформация массивов через map/filter/reduce удобнее и читается легче. Хотя и по идее медленнее, чем цикл (накладные расходы на вызов функций).
Пишу перемежая map и обычный цикл for…
И когда пишу for… испытываю горечь и угрызения совести что не пишу в этом месте map. Но узнав что for… быстрее, горечь проходит. ;-)
JSmittyЕще ФП упрощает переиспользование кода, в классическом ОО для этого надо очень много телодвижений совершать. Обход дерева — есть тип А, у нее есть свойство children, в котором массив А, надо обойти A. Для типа B, который тоже с children — придется или: а) копипастить код обхода, б) делать абстрактного предка с методом обхода — что может быть недоступно (разные иерархии), в) использовать трейт или (java) дефолтную реализацию интерфейса — что в языках появилось вот только-только по сути.
Новинка типа «дефолтная реализация метода интерфейса» — это конечно иногда спасает от копи-пасты. Это верно.
Но понимаете что - классическое ООП подразумевает что реализация метода в разных объектах… разная.
Обычно в начальник учебниках по ООП приводят зоопарк — и обходя зоопарк каждое животное издаёт свой звук при вызове метода yelp()
JSmittyПротив старой-доброй функции обхода дерева, внешней к типу — которой будет без разницы, что обходить. И разные действия можно комбинировать и запоминать как новые функции. Которые будут относительно универсальны по вводу и выводу. На ООП такое вынужденно обрастет лесом вспомогательного кода.
Вот, вам придётся городить где-то во вне связь между объектом и его голосом -> yelp()
В ООП место для хранения поведение определено чётко — это объект.
В ФП такого места нет. Вам придётся как-то устанавливать (по признаку объекта?) что это за животное и потом где-то находить функции, которые вы, наверное используя map (ключ значение(функция)), сопоставите этому животному и вызовите.
Вы считаете что это нормально?
JSmitty
05.05.2017 11:53Нормальный ФП в JS сложно представить. В Хаскеле например функции типизированные, т.е. именно та функция, которая умеет с типом работать — и будет вызвана (полиморфизм). Поэтому ваш пример про addObject (который еще и мутирующий входящий аргумент) — будет мимо (сигнатура будет [a] -> a -> [a]). Надо наоборот — пишите функцию для a -> [a] -> [a].
Я имею в виду проблему параллельных иерархий в ООП, когда есть типы, относящиеся к разным иерархиям — но по факту реализующие одни и те же методы одними и теми же способами. Появление механизма трейтов/миксинов в практически всех языках — это отражает, то есть потребность такая в ООД есть.
Перефразируя ваш пример про зоопарк — когда у вас рядом дендрарий, и его так же надо обойти, и каждое растение тоже издает свой звук.
ФП (как мне кажется, возможно не очень обоснованно) разделяет поведение и данные, и считает первичным именно поведение.
По скорости работы map vs for — в конечном итоге для языков высокого уровня всё упирается в эффективность компилятора/интерпретатора. Простой цикл обработки для конкретного CPU эффективнее всего писать на асме, если уж на то пошло. JS VM ни разу под ФП не заточена, странно было бы ожидать большую скорость.
Преимущество здесь — в декларативности, лучшей понятности и (да, субъективно) красивости.
function f(arr) { for (let item of arr) { if (item.isValid() { continue; } // processing } return _processed_data_; } // vs function f(arr) { return arr.filter(i => i.isValid()).map(i => { /* processing */ }); }
PS Не очень понимаю агрессивный тон некоторых (не вас) комментирующих. Статья мне тоже не нравится, но это ничего не говорит о ФП — там есть конструктивные элементы, которые можно (и нужно) использовать. Очень надеюсь, что хотя бы .filter к массивам применяют в некритичных к скорости местах, вместо тут показанного if (...) { continue; }
JSmitty
05.05.2017 12:50однозначно определяют — прямо в строке — откуда взялась эта функция (обычно статический метод в Java). — Не так ли?
Не так. Вы всё равно импортируете класс, а затем в коде его используете. Без знания, откуда класс импортирован — вы не можете точно рассчитывать на какую-то реализацию. С импортом функции — никаких различий.
wheercool
04.05.2017 21:57Вот как можно решить, используя стрелки Клейсли
Собственно решение занимает по факту 1 строчку:
const totalSallary = R.compose(R.sum, R.pipeK(departments, branches, sectors, sallary))
Ниже привел захардкоженные данные, чтобы можно было поиграться
/* Функции определящие стуктуру нашего институа. В них просто захордкожены данные */ //Institue -> [Department] //По институту возвращает список отделений const departments = institue => ['D1', 'D2', 'D3']; //Department -> [Branch] //По отделению возвращает список отделов const branches = department => { switch (department) { case 'D1': return ['B11', 'B12', 'B13']; case 'D2': return ['B21', 'B22']; case 'D3': return ['B31'] } } //Branch -> [Sector] //По отделу возвращает список секторов const sectors = branch => { switch(branch) { case 'B11': return ['S111', 'S112']; case 'B12': return ['S121'] case 'B13': return ['S131', 'S132'] case 'B21': return ['S211'] case 'B22': return ['S221', 'S222'] case 'B31': return ['S311'] } } //Sector -> [Sallary] //Возвращает зарплату для сектора const sallary = sector => [+sector.slice(1, 4)]; //Institue -> Int //Возвращает суммарную ЗП по институту const totalSallary = R.compose(R.sum, R.pipeK(departments, branches, sectors, sallary)) totalSallary('Some institue')
Покажите теперь пожалуйста, как вы реализовываете это на ООП.taujavarob
05.05.2017 21:09Покажите теперь пожалуйста, как вы реализовываете это на ООП.
Мощно вы написали. Но я мало что понял. :-(
//Возвращает зарплату для сектора const sallary = sector => [+sector.slice(1, 4)]; //Institue -> Int //Возвращает суммарную ЗП по институту const totalSallary = R.compose(R.sum, R.pipeK(departments, branches, sectors, sallary))
То есть вам чтобы подсчитать totalSallary надо передать в функцию заранее известный состав Института? -> departments, branches, sectors
А если он меняется? Если там нет секторов или есть отделы не входящие в Департамент, а подчиняющие прямо директору?
Вот мой примитивный пример — я обошёлся без классов, но с объектами.
Каждый объект имеет и данные и методы. Ну, это же фишка ООП.
Как обычно в ООП — мы только и делаем что дёргаем метод объекта, если у объекта есть метод.
Вы считаете ваш пример выше понятнее или лучше?
var getSallary = function (){ var sallary = this.sallary || 0; if (this.consist) { sallary = this.consist.reduce(function(sum, current) { return sum + (current.getSallary? current.getSallary() : 0); }, 0); } return sallary; }; var getWeight = function (){ var weight = this.weight || 0; if (this.consist) { weight = this.consist.reduce(function(sum, current) { return sum + (current.getWeight? current.getWeight() : 0); }, 0); } return weight; }; var getPower = function (){ var power = this.power || 0; if (this.consist) { power = this.consist.reduce(function(sum, current) { return sum + (current.getPower? current.getPower() : 0); }, 0); } return power; }; var getNames = function (){ if (this.consist) { this.consist.forEach(function(current) { current.getNames(); }); } else if (this.sallary) { console.log(' ' +this.name); } }; var institut = { name : 'NII-CHAVO', getNames, getSallary, getWeight, getPower, consist: [ { name : 'Petrov-director', sallary: 10000, getNames, getSallary}, { name : 'Ivanova-secretar', sallary: 1000, getNames, getSallary}, { name : 'Sidorov-vodila', sallary: 1000, getNames, getSallary}, { name : 'avtomobil for director', weight: 4000, power: 400, getNames, getWeight, getPower}, { name : 'Departament1', getNames, getSallary, getWeight, getPower, consist: [ { name : 'Petrov-ml', sallary: 5000, getNames, getSallary}, { name : 'Sidorova-worker', sallary: 500, getNames, getSallary}, { name : 'avtomobil for Departament1', weight: 3000, power: 200, getNames, getWeight, getPower}, { name : 'Otdel-11', getNames, getSallary, getWeight, getPower, consist: [ { name : 'Ivanov-shef', sallary: 3000, getNames, getSallary}, { name : 'Sidorova-ml-worker', getNames, sallary: 500, getSallary}]}]}, { name : 'Departament2', getNames, getSallary, getWeight, getPower, consist: [ { name : 'Petrova', sallary: 5000, getNames, getSallary}, { name : 'Stepanova-worker', sallary: 500, getNames, getSallary}, { name : 'avtomobil for Departament2', weight: 2500, power: 150, getNames, getWeight, getPower}, { name : 'Otdel-21', getNames, getSallary, getWeight, getPower, consist: [ { name : 'Petrovich-shef', sallary: 3000, getNames, getSallary}, { name : 'Stepanova-ml-worker', sallary: 500, getNames, getSallary}]}]}], }; console.log(`Total Sallary: ${institut.getSallary()}`); // 30000 console.log(`Total Weight: ${institut.getWeight()}`); // 9500 console.log(`Total Power: ${institut.getPower()}`); // 750 console.log('List of employees: '); institut.getNames();
Можно просто кинуть этот код в консоль броузера (или в сниппеты броузера) и… выполнить.
Можно заметить что институт этот это не только отделы, но и директор, сотрудники,мебель, автомобили — то есть довольно разношерстнаяпубликаобъекты.
Да, семейственность, но уж как есть так есть. ;-)
Дерево содержит «узлы» и «листья».
«Узлы» всегда имеют методы getName, getSallary, getWeight, getPower.
«Листья» имеют только те методы, значения для которых у них есть. Например автомобиль не имеет метода getSallary ибо зарплаты не получает.
Но это не принципиально. Дерево может быть любое. Главное что это не данные, а это объекты.
P.S. Да, копи-пасты в моём коде (в функциях get...) хватает, можно было иметь одну универсальную функцию, наверное. Но этот код для ясности понимания… ООП. По крайней мере я надеюсь. ;-)JSmitty
06.05.2017 21:48+1Очень приятно, что вы все-таки не ООП-экстремист (это про .reduce :) ). Вы с предыдущим комментатором решаете несколько разные задачи. Проектирование в JS да, отчасти ближе к ООП, чем к ФП — но тоже, при просмотре структуры данных вы неявно полагаетесь на то, что все «узлы» и «листья» будут поддерживать один и тот же интерфейс. Форсировать это ограничение — нормально нельзя (как в Java или C++).
И все же, согласитесь, композиция функции подсчета из более простых — выглядит забавно (да, я тоже вынужден был гуглить, что делает R.pipeK).
RubaXa
01.05.2017 11:45+1Куда делась эта проверка в функциональном коде?
Вы пропустили: path(['prefs', 'languages', 'primary'])
Она сокращается в 4 раза без потери смысла.
Тут такое дело, что использовав Ramda, они избавляют себя от проверок на
null/undefined
на всём пути использования, так что еслиuser.perfs.languages
будетnull
, код не сломается и так везде. В итоге получается типа безопасно, а функции простыми и чистыми, не перегруженными лишними проверками.
Но я ненастоящий сварщик, да и 200+ методов...
TheShock
01.05.2017 16:21+2Вы пропустили: path(['prefs', 'languages', 'primary'])
Нет, я не пропустил. Этот кусок не проверяет, содержится ли там внутри строка 'undefined'. Конечно, автор мог ошибится и имел ввиду безсмысленную проверку «typeof x == 'undefined'».
… использовав Ramda, они ...
Тут можно задать другой вопрос — зачем снова эта ложь и присваивание достижений сторонней библиотеки функциональной парадигме?
Вот то же самое используя код из библиотеки MooTools 10-тилетней давности:
var primary = Object.getFromPath(indexURLs, 'prefs.languages.primary');
То есть «ФП с кучей сторонних библиотек круче, чем другие парадигмы без библиотек»? «Шахматист с автоматом убъет боксера, значит боксеры — слабее шахматистов»?
Large
01.05.2017 17:31-2Ну таки пропустил, вот это может упасть с RTE
indexURLs[user.prefs.languages.primary]
если пользователь есть, но без настроек, а вот это не упадет
Maybe.of(user).map(path('prefs.languages.primary')).getOr(default);
RubaXa
01.05.2017 18:48+2Конечно, автор мог ошибится
100% ошибка, иначе глупо, да и примеры будут неравнозначные тогда.
MooTools 10-тилетней давности
Да, но есть нюанс, это просто хелперные функции, которые выполняют конкретное действие, без возможности комбинации, в ФП всё интереснее и это важное отличие (важно на уровне самой идеи, а не реализации).
То есть «ФП с кучей сторонних библиотек круче
По мне так всё началось с того, что кто-то угорел по Хаскелю и потом решил, что коль в JS есть функции (да ещё и анонимные), значит JS функциональный язык, но т.к. на этом сходство закончилось и Монад™ не оказалось… имеем, что имеем :]
TheShock
03.05.2017 18:51+1100% ошибка, иначе глупо, да и примеры будут неравнозначные тогда.
Тогда эта проверка добавлена исключительно для того, чтобы сделать классический код «еще более ужасным», она не имеет никакого смысла
в ФП всё интереснее
А почему в топике это не освещено?RubaXa
03.05.2017 19:16+3А почему в топике это не освещено?
?\_(?)_/? Тут как и с любым другим примером Bad vs. Good. Bad — всегда нарочито «грязный», а Good бескомпромиссный, но реальный мир другой.
В ФП есть интересные идеи и подходы, многие мы в той или иной степени уже давно используем, но смотря на 200+ методов типичной Ramd'ы, хочется только посочувствовать ребятам. Но, как известно, адептам FP и/или FRP и так норм, так что все счастливы :]
TheShock
03.05.2017 19:49+1Но, как известно, адептам FP и/или FRP и так норм, так что все счастливы
Стокгольмский синдром же. Жаль только, что они насильника навязывают окружающим.
Large
01.05.2017 17:18-2Из реальных проблем для которых ФП структуры данных хорошо помогли — реализация аналога json-path. Правда все готовые реализации ужасно написаны и их невозможно наследовать, особенно плох имбютаблжс и рамда имьютабл, но они в принципе все ужасны (вместо синглтонов используют скрытые подклассы, имьютаблжс вообще кривой, нарушает спецификацию и работает только потому, что предварительно компилится бабелем). И вот это вообще в них бесит
const a = Maybe.of({}); // Just({})
const b = a.map(prop('x')); // Just(undefined) - неведомая бесполезная чушь
const c = b.map(prop('y')); // Error
Сделано это в угоду соответствия определениям (u.map(x => f(g(x))) is equivalent to u.map(g).map(f) обе части будут кидать ошибку), но полностью убивает смысл мейби монады которая должна от таких ошибок спасать (хотя можно было ограничить множество функций для которых определение должно работать и все было бы прекрасно). Можно переписать то же через чейн, но это начинает выглядеть плохо и опять же плохо расширяется и наследуется.
taujavarob
02.05.2017 15:10Не понял 1 :
//Монада — образец реализации class Monad { constructor(val) { this.__value = val; } static of(val) {//Monad.of проще, чем new Monad(val) return new Monad(val); }; map(f) {//Применяет функцию, но возвращает другую монаду! return Monad.of(f(this.__value)); }; join() { // используется для получения значения из монады return this.__value; }; chain(f) {//вспомогательная функция, преобразующая (map), а затем извлекающая значение return this.map(f).join(); }; ap(someOtherMonad) {//используется для работы с несколькими монадами return someOtherMonad.map(this.__value); } }
ap(someOtherMonad) {//используется для работы с несколькими монадами
return someOtherMonad.map(this.__value);
Но ведь
map(f) {//Применяет функцию, но возвращает другую монаду!
return Monad.of(f(this.__value));
};
map принимает на вход не value, а f (фунцию). Как же так?
Не понял 2 :
//Глобал indexURLs сопоставлен с разными языками let indexURLs = { 'en': 'http://mysite.com/en', //English 'sp': 'http://mysite.com/sp', //Spanish 'jp': 'http://mysite.com/jp' //Japanese } //Императивное решение const getUrl = (language) => allUrls[language]; //Просто, но грязно (обращение к глобальной переменной), и есть риск ошибок
Наверное надо писать?:
//Императивное решение const getUrl = (language) => indexURLs[language]; //Просто, но грязно (обращение к глобальной переменной), и есть риск ошибок
P.S.
Как-то не верится что эта довольно разжёванная статья как-то подвинет в сторону понимания ФП.
Имхо, конечно, имхо (С)
JSmitty
04.05.2017 10:56по второму — ну прямо вот функционально :)
const getUrl = (indexURLs) => (language) => indexURLs[language];
wheercool
04.05.2017 14:39+1ap(someOtherMonad) {//используется для работы с несколькими монадами
return someOtherMonad.map(this.__value);
Но ведь
map(f) {//Применяет функцию, но возвращает другую монаду!
return Monad.of(f(this.__value));
};
В случае ap в контейнере находится не значение, а функция. Т.е. this.__value — это функция поднятая на уровень контейнера.
P.S.
Как-то не верится что эта довольно разжёванная статья как-то подвинет в сторону понимания ФП.
Согласен с Вами. Как уже упоминал выше, если есть желание действительно разобраться почитайте это.
gnomeby
Более чем странная КДПВ