Если вы пробуете свои силы в функциональном программировании, то это значит, что вы довольно скоро столкнётесь с концепцией чистых функций. Продолжая занятия, вы обнаружите, что программисты, предпочитающие функциональный стиль, похоже, прямо-таки одержимы этими функциями. Они говорят о том, что чистые функции позволяют рассуждать о коде. Они говорят, что чистые функции — это сущности, которые вряд ли будут работать настолько непредсказуемо, что приведут к термоядерной войне. Ещё вы можете узнать от таких программистов, что чистые функции обеспечивают ссылочную прозрачность. И так — до бесконечности.
Кстати, функциональные программисты правы. Чистые функции — это хорошо. Но есть одна проблема…
Автор материала, перевод которого мы представляем вашему вниманию, хочет рассказать о том, как бороться с побочными эффектами в чистых функциях.
// logSomething :: String -> String
function logSomething(something) {
const dt = (new Date())toISOString();
console.log(`${dt}: ${something}`);
return something;
}
logSomething()
есть две проблемы, не позволяющие признать её чистой: она создаёт объект Date
и что-то выводит в консоль. То есть, наша функция не только выполняет операции ввода-вывода, она ещё и выдаёт, при её вызове в разное время, разные результаты.// logSomething: Date -> Console -> String -> *
function logSomething(d, cnsl, something) {
const dt = d.toIsoString();
return cnsl.log(`${dt}: ${something}`);
}
const something = "Curiouser and curiouser!"
const d = new Date();
logSomething(d, console, something);
// "Curiouser and curiouser!"
log
объекта cnsl
приведёт к выполнению оператора ввода-вывода. Мне просто это кто-то передал, а я знать не знаю, откуда всё это взялось». Такое отношение к делу — это неправильно.logSomething()
. Если вы хотите сделать нечто нечистым, то вы должны сделать это самостоятельно. Скажем, этой функции можно передавать различные параметры:const d = {toISOString: () => '1865-11-26T16:00:00.000Z'};
const cnsl = {
log: () => {
// не делать ничего
},
};
logSomething(d, cnsl, "Off with their heads!");
// "Off with their heads!"
something
). Но она — совершенно чистая. Если вы вызовете её с этими же параметрами несколько раз, она всякий раз будет возвращать одно и то же. И всё дело именно в этом. Для того чтобы сделать эту функцию нечистой, нам нужно преднамеренно выполнить определённые действия. Или, если сказать иначе, всё, от чего зависит функция, находится в её сигнатуре. Она не обращается ни к каким глобальным объектам вроде console
или Date
. Это всё формализует.// getUserNameFromDOM :: () -> String
function getUserNameFromDOM() {
return document.querySelector('#username').value;
}
const username = getUserNameFromDOM();
username;
// "mhatter"
document
— это глобальный объект, который может в любой момент измениться. Один из способов сделать подобную функцию чистой заключается в передаче ей глобального объекта document
в качестве параметра. Однако ей ещё можно передать функцию querySelector()
. Выглядит это так:// getUserNameFromDOM :: (String -> Element) -> String
function getUserNameFromDOM($) {
return $('#username').value;
}
// qs :: String -> Element
const qs = document.querySelector.bind(document);
const username = getUserNameFromDOM(qs);
username;
// "mhatter"
getUsernameFromDOM()
то, что не позволяет называть её чистой. Однако от этого мы не избавились, лишь перенеся обращение к DOM в другую функцию, qs()
. Может показаться, что единственным заметным результатом подобного шага стало то, что новый код оказался длиннее старого. Вместо одной нечистой функции у нас теперь две функции, одна из которых всё ещё является нечистой.getUserNameFromDOM()
. Теперь, сравнивая два варианта этой функции, подумайте о том, с каким из них будет легче работать? Для того чтобы нечистая версия функции вообще заработала, нам нужен глобальный объект документа. Более того, в этом документе должен быть элемент с идентификатором username
. Если понадобится протестировать подобную функцию за пределами браузера, тогда нужно будет воспользоваться чем-то вроде JSDOM или браузером без пользовательского интерфейса. Обратите внимание на то, что всё это нужно лишь для того, чтобы протестировать маленькую функцию длиной в несколько строк. А для того, чтобы протестировать второй, чистый, вариант этой функции, достаточно сделать следующее:const qsStub = () => ({value: 'mhatter'});
const username = getUserNameFromDOM(qsStub);
assert.strictEqual('mhatter', username, `Expected username to be ${username}`);
getUserNameFromDOM()
стала полностью предсказуемой. Если мы передадим ей функцию qsStub()
, она всегда возвратит mhatter
. «Непредсказуемость» мы переместили в маленькую функцию qs()
.function app(doc, con, ftch, store, config, ga, d, random) {
// Тут находится код приложения
}
app(document, console, fetch, store, config, ga, (new Date()), Math.random);
// fZero :: () -> Number
function fZero() {
console.log('Launching nuclear missiles');
// Тут будет код для запуска ядерных ракет
return 0;
}
fZero()
в другую функцию, которая просто её возвращает. Скажем, это будет нечто вроде обёртки для обеспечения безопасности:// fZero :: () -> Number
function fZero() {
console.log('Launching nuclear missiles');
// Тут будет код для запуска ядерных ракет
return 0;
}
// returnZeroFunc :: () -> (() -> Number)
function returnZeroFunc() {
return fZero;
}
returnZeroFunc()
. При этом, до тех пор, пока не осуществляется выполнение того, что она возвращает, мы (теоретически), в безопасности. В нашем случае это означает, что выполнение следующего кода не приведёт к началу ядерной войны:const zeroFunc1 = returnZeroFunc();
const zeroFunc2 = returnZeroFunc();
const zeroFunc3 = returnZeroFunc();
// Никаких ракет запущено не было.
returnZeroFunc()
. Итак, функция является чистой при соблюдении следующих условий:returnZeroFunc()
.returnZeroFunc()
не приводит к запуску ракет. Если не вызывать то, что возвращает эта функция, ничего не произойдёт. Поэтому мы можем заключить, что у этой функции нет побочных эффектов.zeroFunc1 === zeroFunc2; // true
zeroFunc2 === zeroFunc3; // true
returnZeroFunc()
пока не вполне чиста. Она ссылается на переменную, находящуюся за пределами её собственной области видимости. Для того чтобы эту проблему решить, перепишем функцию:// returnZeroFunc :: () -> (() -> Number)
function returnZeroFunc() {
function fZero() {
console.log('Launching nuclear missiles');
// Тут будет код для запуска ядерных ракет
return 0;
}
return fZero;
}
===
для проверки ссылочной прозрачности функции. Происходит это из-за того, что returnZeroFunc()
всегда будет возвращать новую ссылку на функцию. Правда, ссылочную прозрачность можно проверить, изучив код самостоятельно. Такой анализ покажет, что при каждом вызове функции она возвращает ссылку на одну и ту же функцию.fZero()
:// fZero :: () -> Number
function fZero() {
console.log('Launching nuclear missiles');
// Тут будет код для запуска ядерных ракет
return 0;
}
fZero()
и добавляет к нему единицу:// fIncrement :: (() -> Number) -> Number
function fIncrement(f) {
return f() + 1;
}
fIncrement(fZero);
// Запуск ядерных ракет
// 1
// fIncrement :: (() -> Number) -> (() -> Number)
function fIncrement(f) {
return () => f() + 1;
}
fIncrement(zero);
// [Function]
const fOne = fIncrement(zero);
const fTwo = fIncrement(one);
const fThree = fIncrement(two);
// И так далее…
f
(назовём их f*()
-функции), предназначенных для работы с «возможными числами»:// fMultiply :: (() -> Number) -> (() -> Number) -> (() -> Number)
function fMultiply(a, b) {
return () => a() * b();
}
// fPow :: (() -> Number) -> (() -> Number) -> (() -> Number)
function fPow(a, b) {
return () => Math.pow(a(), b());
}
// fSqrt :: (() -> Number) -> (() -> Number)
function fSqrt(x) {
return () => Math.sqrt(x());
}
const fFour = fPow(fTwo, fTwo);
const fEight = fMultiply(fFour, fTwo);
const fTwentySeven = fPow(fThree, fThree);
const fNine = fSqrt(fTwentySeven);
// Никакого вывода в консоль, никакой ядерной войны. Красота!
Math.sqrt()
. Было бы очень здорово, если бы существовал способ использования этих вот обычных функций с нашими «отложенными значениями». Собственно, об этом мы сейчас и поговорим.Effect
. В такой объект мы поместим нашу функцию fZero()
. Но, прежде чем так поступить, сделаем эту функцию немного безопаснее:// zero :: () -> Number
function fZero() {
console.log('Starting with nothing');
// Тут мы, определённо, не будем запускать никаких ракет.
// Но чистой эта функция пока не является.
return 0;
}
Effect
:// Effect :: Function -> Effect
function Effect(f) {
return {};
}
fZero()
с объектом Effect
. Для обеспечения такого сценария работы напишем метод, который принимает обычную функцию, и когда-нибудь применяет её к нашему «отложенному значению». И мы сделаем это, не вызывая функцию Effect
. Мы называем такую функцию map()
. Такое название она имеет из-за то, что она создаёт маппинг между обычной функцией и функцией Effect
. Выглядеть это может так:// Effect :: Function -> Effect
function Effect(f) {
return {
map(g) {
return Effect(x => g(f(x)));
}
}
}
map()
. Выглядит происходящее подозрительно похожим на композицию. Мы вернёмся к этому вопросу позже, а пока опробуем в деле то, что у нас имеется в данный момент:const zero = Effect(fZero);
const increment = x => x + 1; // Самая обыкновенная функция.
const one = zero.map(increment);
Effect
для того, чтобы, так сказать, получить возможность «спускать курок»:// Effect :: Function -> Effect
function Effect(f) {
return {
map(g) {
return Effect(x => g(f(x)));
},
runEffects(x) {
return f(x);
}
}
}
const zero = Effect(fZero);
const increment = x => x + 1; // Обычная функция.
const one = zero.map(increment);
one.runEffects();
// Начинаем с пустого места
// 1
map()
:const double = x => x * 2;
const cube = x => Math.pow(x, 3);
const eight = Effect(fZero)
.map(increment)
.map(double)
.map(cube);
eight.runEffects();
// Начинаем с пустого места
// 8
Effect
есть функция map()
и он подчиняется некоторым правилам. Однако это не такие правила, которые что-либо запрещают. Эти правила посвящены тому, что можно делать. Они больше похожи на привилегии. Так как объект Effect
— это функтор, он подчиняется этим правилам. В частности это — так называемое «правило композиции».Effect
с именем e
, и две функции, f
и g
, тогда e.map(g).map(f)
эквивалентно e.map(x => f(g(x)))
.map()
эквивалентны композиции двух функций. Это означает, что объект типа Effect
может выполнять действия, подобные следующему (вспомните один из вышеприведённых примеров):const incDoubleCube = x => cube(double(increment(x)));
// Если бы мы пользовались библиотеками вроде Ramda или lodash/fp мы могли бы переписать это так:
// const incDoubleCube = compose(cube, double, increment);
const eight = Effect(fZero).map(incDoubleCube);
map()
. Мы можем это использовать при рефакторинге кода, и можем быть уверены в том, что код будет работать правильно. В некоторых случаях, меняя один подход на другой, можно даже добиться повышения производительности.Effect
принимает, в качестве аргумента, функцию. Это удобно, так как большинство побочных эффектов, выполнение которых мы хотим отложить, являются функциями. Например, это Math.random()
и console.log()
. Однако иногда нужно поместить в объект Effect
некое значение, функцией не являющееся. Например, предположим, что мы прикрепили к глобальному объекту window
в браузере некий объект с конфигурационными данными. Нам понадобятся данные из этого объекта, но такая операция недопустима в чистых функциях. Для того чтобы упростить выполнение подобных операций, мы можем написать небольшой вспомогательный метод (в разных языках этот метод называется по-разному, например, не знаю почему, в Haskell он называется pure
):// of :: a -> Effect a
Effect.of = function of(val) {
return Effect(() => val);
}
window.myAppConf = {
selectors: {
'user-bio': '.userbio',
'article-list': '#articles',
'user-name': '.userfullname',
},
templates: {
'greet': 'Pleased to meet you, {name}',
'notify': 'You have {n} alerts',
}
};
Effect.of()
, мы можем легко поместить нужное нам значение в обёртку Effect
:const win = Effect.of(window);
userBioLocator = win.map(x => x.myAppConf.selectors['user-bio']);
// Effect('.userbio')
Effect
. Скажем, это функция getElementLocator()
, которая возвращает объект Effect
, содержащий строку. Если нам нужно найти элемент DOM, тогда надо вызвать document.querySelector()
— ещё одну функцию, которая не отличается чистотой. Очистить её можно так:// $ :: String -> Effect DOMElement
function $(selector) {
return Effect.of(document.querySelector(s));
}
map()
:const userBio = userBioLocator.map($);
// Effect(Effect(<div>))
div
, то приходится вызывать map()
с функцией, которая тоже выполняет маппинг, что в итоге даёт нужный результат. Например, если нам понадобится innerHTML
, то код будет выглядеть так:const innerHTML = userBio.map(eff => eff.map(domEl => domEl.innerHTML));
// Effect(Effect('<h2>User Biography</h2>'))
userBio
, а отсюда пойдём дальше. Разбирать это будет скучновато, но нам это нужно для того, чтобы как следует разобраться с тем, что здесь происходит. Тут, в ходе описаний, мы будем пользоваться конструкциями вида Effect('user-bio')
. Для того чтобы в них не запутаться, надо учитывать, что если записывать подобные конструкции в виде кода, они будут выглядеть примерно так:Effect(() => '.userbio');
Effect(() => window.myAppConf.selectors['user-bio']);
map()
, это оказывается аналогичным композиции внутренней функции и другой функции (мы уже видели это выше). В результате, например, когда мы выполняем маппинг с функцией $
, выглядит это примерно так:Effect(() => $(window.myAppConf.selectors['user-bio']));
Effect(
() => Effect.of(document.querySelector(window.myAppConf.selectors['user-bio'])))
);
Effect.of
, то перед нами откроется более ясная картина происходящего:Effect(
() => Effect(
() => document.querySelector(window.myAppConf.selectors['user-bio'])
)
);
Effect
он не попадает.Effect
. Если мы собираемся это сделать, то мы должны удостовериться в том, что мы не вносим в этот процесс нежелательных побочных эффектов.Effect
заключается в вызове .runEffect()
для внешней функции. Однако это может показаться непонятным. Мы уже прошли через многое, из-за того, что нам нужно было обеспечить такое поведение системы, при котором код, содержащий побочные эффекты, не выполняется. Теперь мы создадим ещё одну функцию, решающую ту же задачу. Назовём её join()
. Её будем использовать для разворачивания вложенных структур из объектов Effect
, а функцию runEffect()
будем использовать тогда, когда нам нужно запустить код с побочными эффектами. Это проясняет наше намерение даже в случае, когда запускаемый нами код остаётся тем же самым.// Effect :: Function -> Effect
function Effect(f) {
return {
map(g) {
return Effect(x => g(f(x)));
},
runEffects(x) {
return f(x);
}
join(x) {
return f(x);
}
}
}
const userBioHTML = Effect.of(window)
.map(x => x.myAppConf.selectors['user-bio'])
.map($)
.join()
.map(x => x.innerHTML);
// Effect('<h2>User Biography</h2>')
.map()
, за которым следует вызов метода .join()
, встречается довольно часто. На самом деле, так часто, что для его реализации было бы удобно создать отдельный вспомогательный метод. В результате мы сможем использовать этот метод всякий раз, когда у нас будет функция, возвращающая объект Effect
. Благодаря его использованию нам не придётся постоянно использовать конструкцию, состоящую из последовательности методов .map()
и .join()
. Вот как, с добавлением этой функции, будет выглядеть конструктор объектов типа Effect
:// Effect :: Function -> Effect
function Effect(f) {
return {
map(g) {
return Effect(x => g(f(x)));
},
runEffects(x) {
return f(x);
}
join(x) {
return f(x);
}
chain(g) {
return Effect(f).map(g).join();
}
}
}
chain()
из-за того, что она позволяет объединять операции, выполняемые над объектами Effect
(на самом деле, мы так назвали её ещё и потому что стандарт предписывает называть подобную функцию именно так). Теперь наш код для получения внутреннего HTML-кода блока со сведениями о пользователе будет выглядеть так:const userBioHTML = Effect.of(window)
.map(x => x.myAppConf.selectors['user-bio'])
.chain($)
.map(x => x.innerHTML);
// Effect('<h2>User Biography</h2>')
flatMap
. Подобное имя обладает глубоким смыслом, так как тут сначала выполняется обычный маппинг, а потом — раскрытие того, что получилось, с помощью join()
. В Haskell, однако, тот же самый механизм имеет сбивающее с толку имя bind
. Поэтому, если вы читаете какие-то материалы о функциональном программировании на разных языках, учитывайте, что chain
, flatMap
и bind
— это варианты именования похожих механизмов.Effect
, реализация которого может оказаться несколько неудобной. Он заключается в комбинировании двух или большего количества таких объектов с использованием одной функции. Например, что если нам понадобилось бы получить имя пользователя из DOM, а затем вставить его в шаблон, предоставленный конфигурационным объектом приложения? Для этого, например, у нас могла бы быть функция для работы с шаблонами, подобная следующей. Обратите внимание на то, что мы создаём каррированную версию функции. Если раньше вы не встречались с каррированием — взгляните на этот материал.// tpl :: String -> Object -> String
const tpl = curry(function tpl(pattern, data) {
return Object.keys(data).reduce(
(str, key) => str.replace(new RegExp(`{${key}}`, data[key]),
pattern
);
});
const win = Effect.of(window);
const name = win.map(w => w.myAppConfig.selectors['user-name'])
.chain($)
.map(el => el.innerHTML)
.map(str => ({name: str});
// Effect({name: 'Mr. Hatter'});
const pattern = win.map(w => w.myAppConfig.templates('greeting'));
// Effect('Pleased to meet you, {name}');
name
и pattern
) обёрнуты в объект Effect
. Нам нужно вывести функцию tpl()
на более высокий уровень, сделав так, чтобы она работала с объектами Effect
.map()
объекта Effect
с передачей этому методу функции tpl()
:pattern.map(tpl);
// Effect([Function])
map()
выглядит примерно так:map :: Effect a ~> (a -> b) -> Effect b
tpl :: String -> Object -> String
map()
объекта pattern
, мы получаем частично применённую функцию (вспомните о том, что мы каррировали функцию tpl()
) внутри объекта Effect
.Effect (Object -> String)
pattern
типа Effect
. Однако у нас пока нет нужного для выполнения этого действия механизма. Поэтому мы создадим новый метод объекта Effect
, который позволит это сделать. Назовём его ap()
:// Effect :: Function -> Effect
function Effect(f) {
return {
map(g) {
return Effect(x => g(f(x)));
},
runEffects(x) {
return f(x);
}
join(x) {
return f(x);
}
chain(g) {
return Effect(f).map(g).join();
}
ap(eff) {
// Если кто-то вызывает ap, мы исходим из предположения, что в eff имеется функция (а не значение).
// Мы будем использовать map для того, чтобы войти в eff и получить доступ к этой функции (назовём её 'g')
// После получения g, мы организуем работу с f()
return eff.map(g => g(f()));
}
}
}
.ap()
для работы с шаблоном и получения итогового результата:const win = Effect.of(window);
const name = win.map(w => w.myAppConfig.selectors['user-name'])
.chain($)
.map(el => el.innerHTML)
.map(str => ({name: str}));
const pattern = win.map(w => w.myAppConfig.templates('greeting'));
const greeting = name.ap(pattern.map(tpl));
// Effect('Pleased to meet you, Mr Hatter')
.ap()
иногда является источником путаницы. А именно, сложно запомнить, что сначала надо воспользоваться методом map()
, а затем вызывать ap()
. Далее, можно забыть о том, в каком порядке применяются параметры.Effect
, у которых есть метод ap()
. Мы можем написать функцию, которая всё это автоматизирует:// liftA2 :: (a -> b -> c) -> (Applicative a -> Applicative b -> Applicative c)
const liftA2 = curry(function liftA2(f, x, y) {
return y.ap(x.map(f));
// Ещё можно было бы написать так:
// return x.map(f).chain(g => y.map(g));
});
liftA2()
, так как она работает с функцией, которая принимает два аргумента. Похожим образом можно написать и функцию liftA3()
:// liftA3 :: (a -> b -> c -> d) -> (Applicative a -> Applicative b -> Applicative c -> Applicative d)
const liftA3 = curry(function liftA3(f, a, b, c) {
return c.ap(b.ap(a.map(f)));
});
liftA2()
и liftA3()
объект типа Effect
даже не упоминается. В теории, они могут работать с любыми объектами, имеющими совместимый метод ap()
.liftA2()
можно переписать так:const win = Effect.of(window);
const user = win.map(w => w.myAppConfig.selectors['user-name'])
.chain($)
.map(el => el.innerHTML)
.map(str => ({name: str});
const pattern = win.map(w => w.myAppConfig.templates['greeting']);
const greeting = liftA2(tpl)(pattern, user);
// Effect('Pleased to meet you, Mr Hatter')
Effect
и возня с методом ap()
предусматривают выполнение слишком больших объёмов сложной работы. Зачем это всё, если обычный код и так вполне нормально работает? И понадобится ли вообще нечто подобное в реальном мире?Effect
, может оказаться полезным в реальном мире?const pattern = window.myAppConfig.templates['greeting'];
быстрее и проще, чем писать, например, так:const pattern = Effect.of(window).map(w => w.myAppConfig.templates('greeting'));
Facebook
или Gmail
. Но что если вы такими масштабными проектами не занимаетесь? Рассмотрим один весьма распространённый сценарий.map()
и reduce()
, которые поддерживают параллельную обработку данных. Возможным это делает функциональная чистота. Однако, это ещё не всё. Конечно, пользуясь чистыми функциями, можно написать нечто интересное, выполняющее параллельную обработку данных. Но в вашей системе всего 4 ядра (или, может быть, 8, или 16, если вам повезло). На решение подобных задач, даже с использованием многоядерного процессора, всё ещё может потребоваться уйма времени. Но вычисления можно серьёзно ускорить, если выполнять их посредством множества процессоров. Скажем, задействовать видеокарту или какой-нибудь кластер серверов.node1 = tf.constant(3.0, tf.float32)
node2 = tf.constant(4.0, tf.float32)
node3 = tf.add(node1, node2)
Effect
, код в add()
не будет выполнен до тех пор, пока мы явным образом не попросим систему это сделать (в данном случае это делается с помощью конструкции sess.run()
).print("node3: ", node3)
print("sess.run(node3): ", sess.run(node3))
# node3: Tensor("Add_2:0", shape=(), dtype=float32)
# sess.run(node3): 7.0
sess.run()
. Несложно заметить, что это очень похоже на наши отложенные функции. Тут мы тоже заранее планируем действия системы, а затем, когда всё готово, запускаем процесс вычислений.Effect
.Effect
, с другой стороны, занимается оборачиванием всего, что имеет отношение к функции. Для того чтобы запустить соответствующий код, программисту нужно принять обдуманное решение.К сожалению, не доступен сервер mySQL