Когда слышишь, как некоторые люди говорят о функциональном программировании, можно подумать, что они попали в какую-то секту. Они болтают о том, как оно изменило их способ восприятия кода. Они превозносят преимущества чистоты. И провозглашают, что теперь они способны «рассуждать о коде», как будто весь остальной код иррационален и непонятен. Этого вполне достаточно, чтобы начать относиться ко всему этому скептически.
Однако вы можете задаться вопросом: должна же быть какая-то причина того, почему эти адепты настолько восхищены? По моему личному опыту, интерес к функциональному программированию обретают не ленивые и некомпетентные программисты. [Один из тех, кому я показал эту статью, прореагировал интересным образом. Он сказал что-то вроде: «Вообще-то я люблю функциональное программирование, потому что я ленивый и компетентный. Благодаря нему мне не приходится думать о многих вещах».] Скорее наоборот, осваивать его были склонны самые умные кодеры, которых я знал; люди, сильнее всех стремившиеся писать хороший код. (Хотя они обычно были исследователями.) И это вызывает вопрос: отчего же они все в таком восторге?
Столкнувшись с таким вопросом, большинство преподавателей начинает с основ. Они помещают вас в метафорический «детский бассейн». Пытаются объяснить, что же такое функциональное программирование. Они говорят о «кодинге при помощи выражений», побочных эффектах, чистоте и… они совершенно правы. Но рассказ о том, что же такое фунциональное программирование, не объясняет, для чего оно полезно.
Будем откровенными, никого не волнует, что такое функциональное программирование, по крайней мере, не в первое время. Нам важно, сможем ли мы поставлять более качественный код быстрее. А нашим проект-менеджерам важно то же, но в обратном порядке. Поэтому давайте поступим иначе и пропустим этап с детским бассейном. Не будем говорить об определении функционального программирования, а сразу перейдём к его преимуществам. Поговорим об алгебраических структурах.
Алгебраические структуры
Алгебраические структуры позволяют нам писать выразительный код с большей уверенностью. Они выразительны, потому что передают большой объём информации. Они говорят нам, как можно повторно использовать код, оптимизировать его и модифицировать. И всё это при полной уверенности в том, что мы ничего не поломаем. В некоторых случаях они даже позволяют использовать автоматическую генерацию кода.
Это смелое заявление. Но к концу этого раздела мы покажем следующее:
- Многократно используемый код
- Оптимизацию производительности с гарантированной безопасностью.
Более того, в последующих главах мы покажем, как алгебраические структуры позволяют нашему коду доносить больше информации.
Что такое алгебраические структуры?
Если вкратце, это то, что многие люди считают пугающим в функциональном программировании. Они включают в себя такие понятия, как «моноиды», «полугруппы», «функторы» и пугающие «монады». Кроме того, они суперабстрактны, в буквальном смысле. Алгебраические структуры — это абстракции абстракций. В этом смысле они чем-то походят на паттерны проектирования, например те, которые описаны в книге «банды четырёх» Design Patterns: Elements of Reusable Object-Oriented Software. Однако есть между ними и некоторые существенные различия.
Но давайте снова не будем разбираться, что же они такое, а начнём с того, что они могут делать.
Пример задачи из реального мира
Если мы хотим увидеть, в чём польза функционального программирования (и алгебраических структур), нет смысла решать простенькие задачи. Можно придумать что-то получше, чем сложение двух чисел. Давайте рассмотрим то, с чем часто приходится иметь дело разработчикам на JavaScript.
Представим, что мы работаем над веб-приложением. У нас есть список уведомлений, которые нужно показывать пользователю. И все они находятся в массиве POJO. Однако нам нужно преобразовать их в формат, с которым может работать код UI фронтенда. Допустим, данные выглядят вот так:
const notificationData = [
{
username: 'sherlock',
message: 'Watson. Come at once if convenient.',
date: -1461735479,
displayName: 'Sherlock Holmes',
id: 221,
read: false,
sourceId: 'note-to-watson-1895',
sourceType: 'note',
},
{
username: 'sherlock',
message: 'If not convenient, come all the same.',
date: -1461735359,
displayName: 'Sherlock Holmes',
id: 221,
read: false,
sourceId: 'note-to-watson-1895',
sourceType: 'note',
},
// … и так далее. Представьте, что здесь множество других элементов.
];
Чтобы преобразовать эти данные так, чтобы их могла обрабатывать наша система шаблонизации, нам нужно сделать следующее:
- Сгенерировать читаемые данные;
- Санировать сообщение, чтобы предотвратить XSS-атаки;
- Создать ссылку на страницу профиля отправителя;
- Создать ссылку на источник уведомления; и
- Сообщить шаблону, какой значок отображать на основании типа источника.
Для начала мы напишем по функции для каждого пункта. [Пожалуйста, не пишите самостоятельно функцию для защиты от XSS. Используйте проверенную временем библиотеку или оставьте это на долю view-библиотеки (например, React). Это всего лишь пример для обучения.]
const getSet = (getKey, setKey, transform) => (obj) =>
({
...obj,
[setKey]: transform(obj[getKey]),
});
const addReadableDate = getSet(
'date',
'readableDate',
t => new Date(t * 1000).toGMTString()
);
const sanitizeMessage = getSet(
'message',
'message',
msg => msg.replace(/</g, '<')
);
const buildLinkToSender = getSet(
'username',
'sender',
u => `https://example.com/users/${u}`
);
const buildLinkToSource = (notification) => ({
...notification,
source: `https://example.com/${
notification.sourceType
}/${notification.sourceId}`
});
const iconPrefix = 'https://example.com/assets/icons/';
const iconSuffix = '-small.svg';
const addIcon = getSet(
'sourceType',
'icon',
sourceType => `${urlPrefix}${sourceType}${iconSuffix}`
);
Один из способов соединить их все вместе — выполнять их одну за другой, сохраняя результаты в именованные переменные. Например:
const withDates = notificationData.map(addReadableDate);
const sanitized = withDates.map(sanitizeMessage);
const withSenders = sanitized.map(buildLinkToSender);
const withSources = withSenders.map(buildLinkToSource);
const dataForTemplate = withSources.map(addIcon);
Однако эти промежуточные переменные не добавляют никакой новой информации. Мы можем понять, что происходит, по имени функции, с которыми мы их сопоставляем. Ещё один способ соединить всё это — использовать какой-нибудь старый скучный JavaScript array method chaining. Если мы это сделаем, то код станет чуть более «функциональным»:
const dataForTemplate = notificationData
.map(addReadableDate)
.map(sanitizeMessage)
.map(buildLinkToSender)
.map(buildLinkToSource)
.map(addIcon);
Хотя это и действительно «функциональный» код, он не особо необычен. Разве мы не хотели поговорить о чудесных преимуществах алгебраических структур?
Теперь мы перепишем этот код, воспользовавшись парой вспомогательных функций. Первая не очень сложна. Мы напишем функцию
map()
, которая вызывает .map()
. [Если вы не привыкли видеть стрелочные функции, возвращающие стрелочные функции таким образом, прочитайте статью What are higher–order functions, and why should anyone care?.]const map = f => functor => functor.map(f);
Далее мы напишем функцию
pipe()
, позволяющую нам передать значение через серию функций. Это вариация на тему композиции функций. [Если вы незнакомы с композицией функций, можно подробнее прочитать о них в посте JavaScript function composition: What’s the big deal?]const pipe = (x0, ...funcs) => funcs.reduce(
(x, f) => f(x),
x0
);
Функция pipe использует оператор spread для превращения в массив всех аргументов, кроме первого. Затем она передаёт этот первый аргумент первой функции. А результат её передаёт следующей функции, и так далее.
Теперь мы можем переписать код преобразований следующим образом:
const dataForTemplate = pipe(
notificationData,
map(addReadableDate),
map(sanitizeMessage),
map(buildLinkToSender),
map(buildLinkToSource),
map(addIcon)
);
Первым делом стоит здесь заметить, что это во многом похоже на предыдущую версию с цепочкой методов. Но в остальном это по-прежнему довольно банальный код. Мы можем выполнить преобразование в массив, но что в этом такого? Хуже того, этот код неэффективен.
Не торопитесь, дальше всё будет интереснее.
Maybe
Ради удобства объяснения мы немного изменим сценарий. Предположим, что вместо списка уведомлений мы получили самое недавнее. Но у нас нет полной уверенности в сервере. Иногда что-то идёт не так и он отправляет нам HTML-страницу вместо данных JSON. И в результате мы получаем не уведомление, а
undefined
.Одним из способов решения этой проблемы стало бы замусоривание кода конструкциями if. Сначала мы отлавливаем ошибку и возвращаем
undefined
, если ответ не парсится.const parseJSON = (dataFromServer) => {
try {
const parsed = JSON.parse(dataFromServer);
return parsed;
} catch (_) {
return undefined;
}
};
Затем мы добавляем конструкции if в каждую из наших служебных функций.
const addReadableDate = (notification) => {
if (notification !== undefined) {
return getSet(
'date',
'readableDate',
t => new Date(t * 1000).toGMTString()
)(notification);
} else {
return undefined;
}
}
const sanitizeMessage = (notification) => {
if (notification !== undefined) {
return getSet(
'message',
'message',
msg => msg.replace(/</g, '<')
)(notification)
} else {
return undefined;
}
};
const buildLinkToSender = (notification) => {
if (notification !== undefined) {
return getSet(
'username',
'sender',
u => `https://example.com/users/${u}`
);
} else {
return undefined;
}
};
const buildLinkToSource = (notification) => {
if (notification !== undefined) {
return ({
...notification,
source: `https://example.com/${
notification.sourceType
}/${notification.sourceId}`
});
} else {
return undefined;
}
};
const iconPrefix = 'https://example.com/assets/icons/';
const iconSuffix = '-small.svg';
const addIcon = (notification) => {
if (notification !== undefined) {
getSet(
'sourceType',
'icon',
sourceType =>
`${urlPrefix}${sourceType}${iconSuffix}`
);
} else {
return undefined;
}
};
В конечном итоге, наш основной вызов
pipe()
будет выглядеть по-старому.const dataForTemplate = pipe(
notificationData,
map(addReadableDate),
map(sanitizeMessage),
map(buildLinkToSender),
map(buildLinkToSource),
map(addIcon)
);
Но, как вы видите, это делает остальные функции многословными и повторяющимися. Наверно, есть какая-то альтернатива? И она действительно есть. Мы напишем пару функций вот так:
const Just = (val) => ({
map: f => Just(f(val)),
});
const Nothing = () => {
const nothing = { map: () => nothing };
return nothing;
};
И
Just
, и Nothing
возвращают объект с методом .map()
. И при совместном использовании мы назовём эту пару Maybe. Будем использовать их следующим образом:const parseJSON = (data) => {
try {
return Just(JSON.parse(data));
} catch () {
return Nothing();
}
}
const notificationData = parseJSON(dataFromServer);
Сделав это, перейдём к коду преобразования. В этом новом сценарии мы больше не работаем с массивами. У нас есть одно значение, которое может быть
Nothing
. Или оно может быть уведомлением Just
. Но вспомним код, который у нас был для массивов:const dataForTemplate = pipe(
notificationData,
map(addReadableDate),
map(sanitizeMessage),
map(buildLinkToSender),
map(buildLinkToSource),
map(addIcon)
);
Что нужно сделать, чтобы заставить его работать с единственным значением Maybe? Практически ничего. Нам всего лишь нужно как-то получить в конце значение из обёртки
Just
. Для этого добавим ещё один метод в Just
и Nothing
.const Just = (val) => ({
map: f => Just(f(val)),
reduce: (f, x0) => f(x0, val),
});
const Nothing = () => {
const nothing = {
map: () => nothing,
reduce: (_, x0) => x0,
};
return nothing;
};
Обратите внимание, что мы добавили
reduce()
и в Just
, и в Nothing
. Это позволяет нам написать отдельную функцию reduce()
, почти так же, как мы сделали это для map()
:const reduce = (f, x0) => foldable =>
foldable.reduce(f, x0);
Если мы хотим получить наше значение из
Just
, то можно вызвать reduce()
следующим образом:reduce((_, val) => val, fallbackValue);
Если
reduce()
встретит Nothing
, то вернёт fallback-значение. В противном случае она проигнорирует его и вернёт данные.То есть наш конвейер будет выглядеть так:
const dataForTemplate = pipe(
notificationData,
map(addReadableDate),
map(sanitizeMessage),
map(buildLinkToSender),
map(buildLinkToSource),
map(addIcon),
reduce((_, val) => val, fallbackValue),
);
Вероятно, вы задаётесь вопросом, для чего нужна вся эта канитель с
.reduce()
? Почему бы просто не добавить метод, сразу предоставляющий fallback-значение? Например:const Just = (val) => ({
map: f => Just(f(val)),
fallbackTo: (_) => val,
});
const Nothing = () => {
const nothing = {
map: () => nothing,
fallBackTo: (x0) => x0,
};
return nothing;
};
Так как мы добавили
.fallBackTo()
к обеим функциям, можно написать ещё одну служебную функцию. Это сработает вне зависимости от того, получим ли мы Just
или Nothing
. В любом случае код будет делать то, что мы ожидаем.const fallBackTo = (x0) => (m) => m.fallBackTo(x0);
Эта служебная функция
fallBackTo()
кратка и эффективна. Зачем заморачиваться с reduce()
?Хороший вопрос. Поначалу это кажется без нужды усложнённым кодом, который так раздражает у функциональных программистов. Они всегда добавляют слои абстракции, усложняющие чтение кода и сбивающие с толку джунов. Ведь так?
Однако существует веская причина использовать
reduce()
вместо fallBackTo()
. Функция reduce()
может работать с другими структурами данных, а не только с Just
и Nothing
. Это портируемый код. На самом деле, в этом коде мы можем заменить Just
и Nothing
на что-то другое. Что произойдёт, если мы перепишем код парсинга вот так?const parseJSON = strData => {
try { return [JSON.parse(strData)]; }
catch () { return []; }
};
const notificationData = parseJSON(dataFromServer);
Вместо использования
Just
и Nothing
мы теперь возвращаем старые добрые массивы JavaScript. Снова взглянем на конвейер:const dataForTemplate = pipe(
notificationData,
map(addReadableDate),
map(sanitizeMessage),
map(buildLinkToSender),
map(buildLinkToSource),
map(addIcon),
reduce((_, val) => val, fallbackValue),
);
Мы не изменили ни одной строки, но она всё равно выдаёт тот же результат.
Result
Давайте продолжим рассматривать этот сценарий. В коде парсинга JSON мы в операторе
catch
игнорируем ошибку. Но что если внутри этой ошибки содержится полезная информация? Возможно, нам захочется логировать эту ошибку куда-нибудь, чтобы мы могли отлаживать баги.Вернёмся к старому коду с
Just
/Nothing
. Перенесём Nothing
в немного другую функцию Err
. И заодно переименуем Just
в OK
.const OK = (val) => ({
map: (f) => OK(f(val)),
reduce: (f, x0) => f(x0, val),
});
const Err = (e) => ({
const err = {
map: (_) => err,
reduce: (_, x0) => x0,
};
return err;
});
Назовём эту пару функций Result. [В функциональных библиотеках часто есть схожая структура, называющаяся «Either».] Разобравшись с этим, мы можем изменить наш код
parseJSON()
так, чтобы он использовал Result.const parseJSON = strData => {
try { return OK(JSON.parse(strData)); }
catch (e) { return Err(e); }
}
const notificationData = parseJSON(dataFromServer);
Теперь мы не игнорируем ошибку, а записываем её в объект
Err
. Если мы вернёмся назад по конвейеру, нам не придётся ничего менять. Так как Err
имеет совместимые методы .map()
и .reduce()
, всё продолжает работать.const dataForTemplate = pipe(
notificationData,
map(addReadableDate),
map(sanitizeMessage),
map(buildLinkToSender),
map(buildLinkToSource),
map(addIcon),
reduce((_, val) => val, fallbackValue),
);
Разумеется, мы всё ещё игнорируем ошибку, когда добираемся до последней
reduce()
. Чтобы исправить это, нужно чётко определиться с тем, что мы хотим делать с этой ошибкой. Выводить ли её в лог консоли, добавляя побочный эффект? Передавать ли её по сети на платформу логирования? Или извлекать из неё что-то и показывать пользователю?Пока давайте считать, что нас устраивает небольшой побочный эффект, и мы будем выводить её в консоль. Добавим метод
.peekErr()
в OK
и Err
:const OK = (val) => ({
map: (f) => OK(f(val)),
reduce: (f, x0) => f(x0, val),
peekErr: () => OK(val),
});
const Err = (e) => {
const err = {
map: (_) => err,
reduce: (_, x0) => x0,
peekErr: (f) => { f(e); return err; }
}
return err;
};
Добавленная в
OK
версия не делает ничего, потому что нет ошибки, которую нужно рассмотреть. Однако наличие этого метода позволяет нам написать служебную функцию, работающую и с OK
, и с Err
.const peekErr = (f) => (result) => result.peekErr(f);
Затем можно добавить в конвейер
peekErr()
:const dataForTemplate = pipe(
notificationData,
map(addReadableDate),
map(sanitizeMessage),
map(buildLinkToSender),
map(buildLinkToSource),
map(addIcon),
peekErr(console.warn),
reduce((_, val) => val, fallbackValue),
);
Если произойдёт ошибка, мы выведем её и двинемся дальше. Если бы нам понадобилась более сложная обработка ошибок, мы могли бы использовать другие структуры.
Разумеется, добавление
peekErr()
ломает совместимость с Arrays и структурой Maybe. И это вполне нормально. Arrays и Maybe не имеют этих дополнительных данных ошибок.Task
Всё это замечательно, но пока мы игнорировали нечто важное. Всё это время мы говорили, что данные поступают с сервера. Однако получение данных с сервера подразумевает, что задействуется какой-то сетевой вызов. А в JavaScript это чаще всего означает наличие асинхронного кода.
Например, предположим, что у нас есть какой-то код, получающий данные уведомлений при помощи стандартных промисов JavaScript:
const notificationDataPromise = fetch(urlForData)
.then(response => response.json());
Давайте посмотрим, сможем ли мы построить структуру, работающую и с асинхронным кодом. Для этого мы построим структуру с функцией-конструктором, похожей на Promise. Она ожидает функцию, получающую два аргумента:
- Один для вызова при успешной проверке
- Другой для вызова, когда что-то пойдёт не так.
Мы можем вызвать её так:
const notificationData = Task((resolve, reject) => {
fetch(urlForData)
.then(response => response.json())
.then(resolve)
.catch(reject);
});
В этом примере мы можем получить и передать ей URL нашего уведомления. Затем мы вызываем
.json()
в ответ для парсинга данных. А после этого мы выполняем resolve()
, если вызов был успешным, или reject()
в противном случае. По сравнению с кодом fetch()
с одними Promise это выглядит немного неуклюже. Но это нужно для того, чтобы мы могли подключить resolve и reject. Чуть позже мы добавим вспомогательную функцию для подключения асинхронных функций наподобие fetch()
.Реализация структуры
Task
не особо сложна:const Task = (run) => {
map: (f) => Task((resolve, reject) => {
run(
(x) => (resolve(f(x))),
reject
);
}),
peekErr: (f) => Task((resolve, reject) => {
run(
resolve,
(err) => { f(err); reject(err); }
)
}),
run: (onResolve, onReject) => run(
onResolve,
onReject
);
}
Мы создали
.map()
и .peekErr()
, как это было сделано для Result. Однако метод .reduce()
не имеет смысла для асинхронного кода. После перехода в мир асинхронности назад возврата нет. Также мы добавили метод .run()
для запуска нашего Task.Чтобы немного упростить работу с промисами, мы можем добавить для
Task
статическую вспомогательную функцию. И ещё одну вспомогательную функцию для получения данных JSON:Task.fromAsync = (asyncFunc) => (...args) =>
Task((resolve, reject) => {
asyncFunc(...args).then(resolve).catch(reject);
});
const taskFetchJSON = Task.fromAsync(
(url) => fetch(url).then(data => data.json())
);
Благодаря этим вспомогательным функциям мы можем определить
notificationData
следующим образом:const notificationData = taskFetchJSON(urlForData);
Чтобы работать с Task, нам немного нужно изменить конвейер. Но изменение будет небольшим:
const dataForTemplate = pipe(
notificationData,
map(addReadableDate),
map(sanitizeMessage),
map(buildLinkToSender),
map(buildLinkToSource),
map(addIcon),
peekErr(console.warn),
);
Бо́льшая часть кода продолжает работать, за исключением функции
reduce()
. Но нам всё равно нужен какой-то способ добавить fallback-значение на случай сбоя сетевого запроса или парсинга. Чтобы сделать это, мы добавим метод .scan()
. Он будет похож на .reduce()
, но мы дадим ему другое имя, чтобы показать, что результат по-прежнему будет «внутри» Task
.const Task = (run) => {
map: (f) => Task((resolve, reject) => {
run(
(x) => (resolve(f(x))),
reject
);
}),
peekErr: (f) => Task((resolve, reject) => {
run(
resolve,
(err) => { f(err); reject(err); }
)
}),
run: (onResolve, onReject) => run(
onResolve,
onReject
);
scan: (f, x0) => Task((resolve, reject) => run(
x => resolve(f(x0, x)),
e => resolve(x0),
)),
}
И как обычно мы создадим соответствующую служебную функцию:
const scan = (f, x0) => (scannable) =>
scannable.scan(f, x0);
Сделав это, можно изменить конвейер следующим образом:
const taskForTemplateData = pipe(
notificationData,
map(addReadableDate),
map(sanitizeMessage),
map(buildLinkToSender),
map(buildLinkToSource),
map(addIcon),
peekErr(console.warn),
scan((_, val) => val, fallback)
);
А для этого мы делаем что-то подобное:
taskForTemplateData.run(
renderNotifications,
handleError
);
Почему бы не использовать промисы?
Возможно, кто-то задаётся вопросом: в JavaScript уже есть встроенная структура данных для асинхронного кода. Почему бы не использовать промисы? Зачем заморачиваться с этим Task? В чём смысл, если это будет кого-то сбивать с толку?
На то есть минимум три причины. Во-первых, у промисов нет метода
.run()
. Это значит, что они запускаются сразу после того, как мы их создаём. Использование Task
даёт нам точный контроль за тем, когда всё запускается.Нам не нужно, чтобы Task получал этот контроль. Если бы мы этого хотели, мы могли бы отложить промисы, вставив их внутрь функции. Тогда промисы не «запускались» бы, пока мы не вызовем функцию. Однако таким образом мы практически переизобрели бы Task, только с другим синтаксисом и меньшей гибкостью.
Вторая причина, по которой мы предпочли Task — это возможности, которых нет у промисов. Главная из них — это возможность встраивать Task. Мы можем запустить Task и получить другой Task. Затем мы можем подождать и решить, когда запускать этот следующий Task. С промисами это сделать невозможно. [По крайней мере. это невозможно с промисами, если вы только не возвращаете функцию, возвращающую промис. Но, как говорилось выше, возвращающая промис функция — это ещё один способ создания Task.] Промисы объединяют
.map()
и .flatMap()
в единый метод .then()
. Следовательно, мы снова теряем гибкость.И последняя причина, по которой мы предпочитаем Task, в том, что он согласован с остальными алгебраическими структурами. Если мы продолжим использовать эти структуры достаточно часто, то привыкнем к ним. И, в свою очередь, это упростит понимание того, что делает код. Или (что более важно) чего он не делает. Чуть позже мы поговорим об этом.
В конечном итоге, Task даёт нам больше мощи, гибкости и согласованности. Но я не хочу сказать, что в использовании Task нет минусов. Благодаря ключевым словам
async … await
JavaScript поддерживает промисы «из коробки». Возможно, кому-то не захочется терять эти удобства ради использования Task, и это нормально.То есть вы использовали полиморфизм. И что в этом такого?
Мы начали с вопроса «Что же прекрасного в функциональном программировании?». Но всё, что мы пока делали — это обсуждали несколько объектов, имеющих одинаковые имена методов. Это же старый добрый полиморфизм. Гуру ООП многие десятки лет твердили о полиморфизме. Мы не можем утверждать, что функциональное программирование замечательно, потому что оно использует полиморфизм.
Или можем?
Алгебраические структуры (и функциональное программирование) делает замечательными не полиморфизм сам по себе. Благодаря полиморфизму становится возможным реализовать алгебраические структуры на JavaScript. В примере с уведомлениями мы определили несколько методов с совпадающими именами и сигнатурами, например,
.map()
и .reduce()
. Затем мы написали служебные функции, работающие с методами, совпадающими с этими сигнатурами, например, map()
и reduce()
. Благодаря полиморфизму эти служебные функции работают.Эти определения методов (и служебные функции) не взяты произвольно. Они не являются паттернами проектирования, которые кто-то придумал, наблюдая за распространёнными архитектурными паттернами. Нет, алгебраические структуры пришли из математики, из таких её разделов, как теория множеств и теория категорий. Это означает, что наряду с конкретными сигнатурами методов эти структуры имеют законы.
Поначалу это не кажется каким-то чудом. Математика у нас ассоциируется с запутанностью и скукой. А законы у нас ассоциируются с ограничениями. Законы мешаются нам, не дают нам делать то, что мы хотим. Они неудобны. Но если вы уделите время прочтению этих законов, они могут вас удивить. Потому что они скучны. Невероятно скучны.
Наверно, вы думаете: «Непонятно, почему это должно быть удивительным. Это абсолютно ожидаемое заявление». Но эти законы скучны по-особенному. Они скучны в том смысле, что сообщают очевидное. То, что непонятно вообще зачем нужно записывать. Мы читаем их и обычно думаем: «Разумеется, так это и работает. В какой ситуации могло бы быть иначе?» И именно в этом заключается красота алгебраических структур.
Чтобы проиллюстрировать это, давайте вернёмся к примеру с уведомлениями. Мы воспользовались как минимум двумя алгебраическими структурами. Одну из них мы называем «функтор». Всё это значит, что в Maybe, Result и Task мы написали метод
.map()
. И в том, как мы написали эти методы .map()
, мы каждый раз следовали неким законам. Также мы использовали ещё одну алгебраическую структуру под названием Foldable. Мы называем структуру данных Foldable, если она имеет метод .reduce()
, и этот метод подчиняется определённым законам.Один из законов для функтора гласит, что следующие два фрагмента кода должны всегда давать одинаковый результат, что бы не происходило. Если у нас есть две чистые функции
f
и g
, то первый фрагмент кода будет выглядеть так:const resultA = a.map(f).map(g);
А второй фрагмент так:
const resultB = a.map(x => g(f(x)));
Эти два фрагмента кода должны давать одинаковый результат при одинаковых входных данных. То есть
resultA ≣ resultB
. Мы называем это правилом композиции и можем применить его к коду нашего конвейера, потому что x => g(f(x))
— это то же самое, что написать x => pipe(x, f, g)
. То есть наша функция pipe()
является разновидностью композиции. Таким образом, если мы вернёмся к самому началу, к версии конвейера на основе массивов, то получим:const dataForTemplate = pipe(
notificationData,
map(addReadableDate),
map(sanitizeMessage),
map(buildLinkToSender),
map(buildLinkToSource),
map(addIcon),
);
Мы можем переписать это так:
const dataForTemplate = map(x => pipe(x,
addReadableDate,
sanitizeMessage,
buildLinkToSender,
buildLinkToSource,
addIcon,
))(notificationData);
Благодаря закону композиции мы знаем, что эти два фрагмента кода эквивалентны. Неважно, работаем ли мы с Maybe, Result, Task или Array — эти два фрагмента кода всегда будут давать одинаковый результат.
Возможно, это покажется вам несущественным. И вы можете даже подумать, что вторая версия некрасивее и слишком переусложнена. Но для массивов эта вторая версия будет более эффективной. При передаче данных по конвейеру первая версия создаст не менее пяти промежуточных массивов. Вторая версия сделает всё за один проход. Мы получаем рост производительности, при котором гарантировано обеспечивается тот же результат, что и в коде, с которого мы начинали. Точнее, гарантированный, пока мы используем чистые функции.
И что с того?
Всё дело в уверенности. Эти законы сообщают мне, что если я использую алгебраическую структуру, она будет вести себя так, как я ожидаю. И у меня будет математическая гарантия, что она продолжит это делать. На 100%. Постоянно.
Как и обещалось, мы показали код, который можно использовать многократно. Наши служебные функции наподобие
map()
, reduce()
и pipe()
работают с разными структурами. Такими как Array, Maybe, Either и Task. И мы показали, как законы алгебраических структур помогли нам абсолютной безопасно модифицировать код. А ещё мы показали, что эта модификация обеспечила рост производительности, снова абсолютно безопасно.В свою очередь, это показывает нам основу красоты функционального программирования. В первую очередь дело не в алгебраических структурах. Это лишь набор инструментов из огромного ящика с инструментами. Главное в функциональном программировании — уверенность в нашем коде. В том, что мы знаем — код будет делать то, что мы ожидаем, и ничего кроме того, что мы ожидаем.
Когда мы поймём это, странности функционального программирования начнут казаться более логичными. Именно поэтому, например, функциональные программисты так внимательно относятся к побочным эффектам. Чем больше мы работаем с чистыми функциями, тем бо́льшую определённость получаем. Это также объясняет любовь некоторых программистов к сложным системам типов, например, как в Haskell. [Откровенно говоря, не все функциональные программисты влюбляются в сложные системы типов.] Они подсели на наркотик определённости.
Знание о том, что функциональное программирование — это об уверенности в своём коде, и есть потайной ключ. Он объясняет, почему функциональные программисты так одержимы якобы тривиальными вещами. Дело не в том, что им нравится педантичность. (Ну ладно, некоторым из них, похоже, сильно нравится педантичность). Чаще всего они стремятся сохранить уверенность и готовы пойти на всё, что для этого нужно. Даже если для этого нужно погрузиться в тёмные искусства математики.
Комментарии (9)
darkneeees
22.12.2022 11:05+2Интересная статья, спасибо за труд. Все мы знаем, что реализовать функциональщину можно на любом языке, тем не менее хотелось бы видеть какой-нибудь Haskell, а не JS. Ну и отмечу кое-что важное, что я не увидел (а может проглядел), дело не в надёжном коде. Одна из основных сутей функционального программирования (что вы делали на протяжении всего кода не явно) - неизменяемые переменные. Это позволяет избежать множества проблем - дедлоки, гонки и все т. п. В заточенных под ФП языках такого нет или имеет крайне ограниченный функционал.
F123456
22.12.2022 13:26+1«Вообще-то я люблю функциональное программирование, потому что я ленивый и компетентный. Благодаря нему мне не приходится думать о многих вещах»
Я уверен, что этот человек программирует на haskell.
murkin-kot
22.12.2022 13:02+1Если мы продолжим использовать эти структуры достаточно часто, то привыкнем к ним
В этом проблема. Достаточно часто нужно себя заставлять делать то, что занимает много времени. За это время можно много раз решить поставленную задачу. В реальном приложении можно, конечно, пытаться искать способы применения сложных преобразований, но чаще всего это ведёт опять к потере времени. Возможно в итоге, когда наизусть выучим все задействованные законы используемых преобразований, мы начнём видеть свет в ряде случаев, но опять же - это не массовые проблемы, это меньшая часть.
Итого - ради возможной выгоды в редких случаях нужно тратить много времени во многих случаях. И так, пока не наступит просветление. Возможно в бесконечном цикле. То есть без гарантий.
Хотя да, что-то в этом есть, но "что-то" есть и в альтернативных подходах. Может им и уделить то самое время, которое требуется на "использовать эти структуры достаточно часто"?
А с другой стороны над нами стоят сроки. Вот и выбираем локальный оптимум.
Hivemaster
23.12.2022 08:04+1Точно так же много лет назад программисты относились к ООП: "Нафига время терять на все эти заморочки с инкапсуляцией, наследованием и полиморфизмом? У меня сроки, нахерачу по-быстрому на старых добрых процедурах." Теперь ООП в коммерческих проектах - это непреложная данность. Когда-нибудь такой же будет ФП.
sAntee
23.12.2022 09:35+1В коммерческих проектах чаще всего не ООП непреложная данность, а фантазии на тему - типа папка Controllers и условные IOrderService. Так и сейчас от фп используют условные лямбды да вариации на тему Future ????♂️
gena1977
23.12.2022 19:53+1Хорошая статья. Однако мало кто задается вопросом, а почему ООП стал стандартом разработки? Можкт стоит упомянуть о недостатках функциональщины?... К вопросу о популярности ООП и ФП. В 2013 в ИТМО поиезжал с Страуструп. Я был на той встрече и один из студентов спросил его, насколько популярен язык Хаскел и каково будущее ФП. На что Страуструп слегка задумался и с улыбочкой ответил: "ну, я знаю пару ребят, которые пишут на Хаскел" :))))
vadimr
24.12.2022 21:21Справедливости ради, а что мог на такой вопрос ответить Страуструп? Нашли кого спросить про ФП :)
vadimr
Всё, конечно, встречается в природе, но посвятить функциональному программированию статью, все многочисленные примеры в которой состоят из присваиваний – всё-таки уже некоторый перебор.
Ежели кто думает, что использование функций высших порядков само по себе представляет собой функциональное программирование, тот человек ошибается.