Кстати, функциональные программисты правы. Чистые функции — это хорошо. Но есть одна проблема…
Автор материала, перевод которого мы представляем вашему вниманию, хочет рассказать о том, как бороться с побочными эффектами в чистых функциях.
Проблема чистых функций
Чистая функция — это функция, не имеющая побочных эффектов (на самом деле, это — не полное определение чистой функции, но мы к такому определению ещё вернёмся). Однако если вы хоть что-то понимаете в программировании, то вы знаете, что самое важное здесь как раз таки и заключается в побочных эффектах. Зачем вычислять число Пи до сотого знака после запятой, если никто это число не сможет прочесть? Для того чтобы вывести что-то на экран или распечатать на принтере, или представить в каком-то другом виде, доступном для восприятия, нам нужно вызвать из программы подходящую команду. А какая польза от баз данных, если в них ничего нельзя записывать? Для обеспечения работы приложений нужно считывать данные из устройств ввода и запрашивать информацию из сетевых ресурсов. Всё это нельзя сделать без побочных эффектов. Но, несмотря на такое положение дел, функциональное программирование построено вокруг чистых функций. Как же программистам, которые пишут программы в функциональном стиле, удаётся решить этот парадокс?
Если ответить на этот вопрос в двух словах, то функциональные программисты делают то же, что и математики: они жульничают. Хотя, несмотря на это обвинение, надо сказать, что они, с технической точки зрения, просто следуют определённым правилам. Но они находят в этих правилах лазейки и расширяют их до невероятных размеров. Делают они это двумя основными способами:
- Они пользуются внедрением зависимостей (dependency injection). Я называю это перебрасыванием проблемы через забор.
- Они применяют функторы (functor), что мне кажется крайней формой прокрастинации. Тут надо отметить, что в Haskell это называется «IO functor» или «IO monad», в PureScript используется термин «Effect», который, как мне кажется, немного лучше подходит для описания сущности функторов.
Внедрение зависимостей
Внедрение зависимостей — это первый метод работы с побочными эффектами. Используя этот подход, мы берём всё, что загрязняет код, и выносим это в параметры функции. Затем мы можем рассматривать всё это как нечто, входящее в сферу ответственности какой-то другой функции. Поясню это на следующем примере:
// 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"
В данном случае мы пытаемся загрузить какую-то информацию из DOM. Чистые функции так не поступают, так как
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}`);
Это, конечно, не значит, что для испытания подобных функций не нужны интеграционные тесты, выполняемые в реальном браузере (или, как минимум, с использованием чего-то вроде JSDOM). Но этот пример демонстрирует очень важную вещь, которая заключается в том, что теперь функция
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);
На самом деле, это не так уж и плохо. Минусы таких конструкций проявляются в том случае, если некоторые из параметров нужно передавать неким функциям, которые очень глубоко вложены в другие функции. Выглядит это как необходимость передавать параметры через много уровней вызовов функций. Когда число таких уровней растёт, это начинает раздражать. Например, может возникнуть необходимость в передаче объекта, представляющего дату, через 5 промежуточных функций, при том, что ни одна из промежуточных функций этим объектом не пользуется. Хотя, конечно, нельзя сказать, что подобная ситуация — это нечто вроде вселенской катастрофы. К тому же, это даёт возможность чётко видеть зависимости функций. Правда, как бы там ни было, это всё равно не так уж и приятно. Поэтому рассмотрим следующий механизм.
▍Ленивые функции
Взглянем на вторую лазейку, которую используют приверженцы функционального программирования. Она заключается в следующей идее: побочный эффект — это не побочный эффект до тех пор, пока он не произойдёт на самом деле. Знаю, звучит это таинственно. Для того чтобы в этом разобраться, рассмотрим следующий пример:
// fZero :: () -> Number
function fZero() {
console.log('Launching nuclear missiles');
// Тут будет код для запуска ядерных ракет
return 0;
}
Пример это, пожалуй, дурацкий, я это знаю. Если нам понадобится число 0, то для того, чтобы оно у нас появилось, достаточно вписать его в нужном месте кода. А ещё я знаю, что вы не станете писать на JavaScript код для управления ядерным оружием. Но этот код нам нужен для того, чтобы проиллюстрировать рассматриваемую технологию.
Итак, перед нами пример нечистой функции. Она выводит данные в консоль и ещё является причиной ядерной войны. Однако вообразите, что нам нужен тот ноль, который эта функция возвращает. Представьте себе сценарий, в соответствии с которым нам надо что-то посчитать после запуска ракеты. Скажем, нам может понадобиться запустить таймер обратного отсчёта или что-то в этом роде. В данном случае совершенно естественным будет заранее подумать о выполнении вычислений. И мы должны позаботиться о том, чтобы ракета запустилась именно тогда, когда нужно. Нам не надо выполнять вычисления таким образом, чтобы они могли бы случайно привести к запуску этой ракеты. Поэтому подумаем над тем, что произойдёт, если мы обернём функцию 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;
}
Теперь функцию можно признать чистой. Однако в данной ситуации правила JavaScript играют против нас. А именно, мы больше не можем использовать оператор
===
для проверки ссылочной прозрачности функции. Происходит это из-за того, что 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);
// Никакого вывода в консоль, никакой ядерной войны. Красота!
Видите, что мы тут сделали? С «возможными числами» можно делать то же самое, что и с обычными числами. Математики называют это изоморфизмом. Обычное число всегда можно превратить в «возможное число», поместив его в функцию. Получить «возможное число» можно, вызвав функцию. Другими словами, у нас имеется маппинг между обычными числами и «возможными числами». Это, на самом деле, гораздо интереснее, чем может показаться. Скоро мы вернёмся к данной идее.
Вышеописанный приём с использованием функции-обёртки — это допустимая стратегия. Мы можем скрываться за функциями столько, сколько нужно. И, так как мы пока не вызывали ни одной из этих функций, все они, теоретически, являются чистыми. И войну никто не начинает. В обычном коде (не связанном с ракетами) нам, на самом деле, в итоге нужны побочные эффекты. Оборачивание всего, что нужно, в функции, позволяет нам с точностью контролировать эти эффекты. Мы сами выбираем время появления этих эффектов.
Надо отметить, что не очень-то удобно всюду использовать однообразные конструкции с кучами скобок для объявления функций. И создавать новые версии каждой функции — тоже занятие не из приятных. В JavaScript есть замечательные встроенные функции, вроде Math.sqrt()
. Было бы очень здорово, если бы существовал способ использования этих вот обычных функций с нашими «отложенными значениями». Собственно, об этом мы сейчас и поговорим.
Функтор Effect
Тут мы будем говорить о функторах, представленных объектами, содержащими наши «отложенные функции». Для представления функтора мы будем пользоваться объектом
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()
. Мы можем это использовать при рефакторинге кода, и можем быть уверены в том, что код будет работать правильно. В некоторых случаях, меняя один подход на другой, можно даже добиться повышения производительности.
Теперь предлагаю прекратить эксперименты с числами и поговорить о том, что больше похоже на код, используемый в реальных проектах.
▍Метод of()
Конструктор объекта
Effect
принимает, в качестве аргумента, функцию. Это удобно, так как большинство побочных эффектов, выполнение которых мы хотим отложить, являются функциями. Например, это Math.random()
и console.log()
. Однако иногда нужно поместить в объект Effect
некое значение, функцией не являющееся. Например, предположим, что мы прикрепили к глобальному объекту window
в браузере некий объект с конфигурационными данными. Нам понадобятся данные из этого объекта, но такая операция недопустима в чистых функциях. Для того чтобы упростить выполнение подобных операций, мы можем написать небольшой вспомогательный метод (в разных языках этот метод называется по-разному, например, не знаю почему, в Haskell он называется pure
):
// of :: a -> Effect a
Effect.of = function of(val) {
return Effect(() => val);
}
Для того чтобы продемонстрировать ситуацию, в которой может пригодиться подобный метод, представим, что мы работаем над веб-приложением. У этого приложения есть некие стандартные возможности, скажем, оно может выводить список статей и сведения о пользователе. Однако расположение этих элементов в HTML-коде у разных пользователей нашего приложения различается. Так как мы привыкли принимать продуманные решения, мы решили хранить сведения о расположении элементов в глобальном конфигурационном объекте. Благодаря этому мы всегда сможем к ним обратиться. Например:
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 и раскрытие вложенных структур
Маппинг функций с побочными эффектами может завести нас довольно далеко. Но иногда мы занимаемся маппингом функций, возвращающих объекты
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
он не попадает.
▍Метод join()
Зачем мы со всем этим разбираемся? Делаем мы это для того, чтобы развернуть все эти вложенные объекты
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>')
▍Метод chain()
Вышерассмотренный паттерн, в котором используется вызов метода
.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
Вот ещё один сценарий работы с объектами
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'));
И если это — всё, что вы когда либо делали в программировании, то первый вариант, и правда, проще и лучше. Побочные эффекты в таком случае значения не имеют. Однако это — всего одна строка кода, находящаяся в приложении, которое может содержать тысячи или даже миллионы строк. Функциональная чистота начинает приобретать куда большее значение, когда вы пытаетесь выяснить, почему приложение таинственным образом, и, как кажется, совершенно без причин, перестаёт работать. В подобной ситуации происходит нечто неожиданное, и вы пытаетесь разбить проблему на части и изолировать её причину. Чем больше кода вы сможете исключить из рассмотрения — тем лучше. Если ваши функции являются чистыми, это позволяет вам быть уверенным в том, что на их поведение влияет только то, что им передают. И это значительно сужает диапазон возможных причин ошибки, которые вам нужно рассмотреть.
Другими словами это позволяет вам тратить меньше усилий на размышления. Это крайне важно при отладке больших и сложных приложений.
▍Паттерн Effect в реальном мире
Итак, возможно функциональная чистота имеет значение в том случае, если вы занимаетесь разработкой большого и сложного приложения. Это может быть что-то вроде
Facebook
или Gmail
. Но что если вы такими масштабными проектами не занимаетесь? Рассмотрим один весьма распространённый сценарий.
Скажем, у вас есть некие данные. Причём этих данных у вас довольно много. Это могут быть миллионы строк в виде текстовых CSV-файлов или огромные таблицы базы данных. Вы должны эти данные обработать. Возможно, вы, используя их, обучаете нейронную сеть для построения модели логического вывода. Возможно, вы пытаетесь предсказать следующее крупное движение на рынке криптовалют. На самом деле, цель обработки больших объёмов данных может быть какой угодно. Дело тут в том, что для того, чтобы достичь этой цели, требуется выполнить огромный объём вычислений.
Джоэл Спольски убедительно доказывает, что функциональное программирование может помочь при решении подобных задач. Например, можно создать альтернативные версии методов map()
и reduce()
, которые поддерживают параллельную обработку данных. Возможным это делает функциональная чистота. Однако, это ещё не всё. Конечно, пользуясь чистыми функциями, можно написать нечто интересное, выполняющее параллельную обработку данных. Но в вашей системе всего 4 ядра (или, может быть, 8, или 16, если вам повезло). На решение подобных задач, даже с использованием многоядерного процессора, всё ещё может потребоваться уйма времени. Но вычисления можно серьёзно ускорить, если выполнять их посредством множества процессоров. Скажем, задействовать видеокарту или какой-нибудь кластер серверов.
Для того чтобы сделать это возможным, вам нужно сначала описать вычисления, которые вы хотите выполнить. Но описывать их нужно, не выполняя реального запуска таких вычислений. Ничего не напоминает? В идеале, после того, как вы создадите подобное описание, вы передаёте его некоему фреймворку. Этот фреймворк затем позаботится о чтении информации из хранилища и о распределении задач по узлам обработки данных. Затем тот же фреймворк соберёт результаты и сообщит вам о том, что получилось после выполнения вычислений.
Именно так работает опенсорсная библиотека TensorFlow, предназначенная для выполнения высокопроизводительных численных расчётов.
Используя TensorFlow, не применяют обычные типы данных языка программирования, на котором пишут приложение. Вместо этого создают так называемые «тензоры». Скажем, если нам надо сложить два числа, то выглядеть это будет так:
node1 = tf.constant(3.0, tf.float32)
node2 = tf.constant(4.0, tf.float32)
node3 = tf.add(node1, node2)
Этот код написан на Python, но он не особенно сильно отличается от JavaScript. И, что роднит его с рассмотренными выше примерами использования класса
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
Как видите, результат (7.0) мы не получим до тех пор, пока не вызовем
sess.run()
. Несложно заметить, что это очень похоже на наши отложенные функции. Тут мы тоже заранее планируем действия системы, а затем, когда всё готово, запускаем процесс вычислений.
Итоги
В этой статье мы рассмотрели много вопросов, относящихся к функциональному программированию. В частности, мы разобрали два способа поддержания функциональной чистоты кода. Это внедрение зависимостей и использование функтора
Effect
.Механизм внедрения зависимостей работает благодаря вынесению того, что нарушает чистоту кода, за пределы этого кода, то есть, за пределы функций, которые должны быть чистыми. То, что из них вынесено, нужно передавать им в виде параметров. Функтор
Effect
, с другой стороны, занимается оборачиванием всего, что имеет отношение к функции. Для того чтобы запустить соответствующий код, программисту нужно принять обдуманное решение.
Оба рассмотренных подхода — это своего рода жульничество. И тот и другой не позволяют избавиться от конструкций, нарушающих функциональную чистоту кода. Они лишь выносят подобные конструкции, так сказать, на периферию. Но это весьма полезно. Это позволяет чётко разделять код на чистый и нечистый. Возможность подобного разделения может дать программисту реальные преимущества в ситуациях, когда ему приходится отлаживать большие и сложные приложения.
Уважаемые читатели! Пользуетесь ли вы концепциями функционального программирования в своих проектах?
Комментариев нет:
Отправить комментарий