Привет, меня зовут Григорий Бизюкин, я преподаватель Школы разработки интерфейсов и фронтенд-разработчик в Яндексе. Давайте поговорим о функциональном программировании в мире JavaScript. Мы все про ФП что-то слышали, нам всем оно интересно, но у меня, когда я искал полезные материалы для подготовки к лекциям, сложилось такое впечатление: есть куча статей, каждая из которых либо говорит об ФП общими словами, либо раскрывает отдельный маленький кусочек темы, чего, конечно, недостаточно.
Добавим функционального света
Впервые я попробовал обобщить в одном месте самые популярные и, как мне кажется, применимые приёмы функционального программирования в лекции для ШРИ. Потом захотелось расширить материал и рассмотреть ещё больше концепций. В результате получилась эта статья. В ней мы разберём всё самое сложное простым языком с понятными примерами. Надеюсь, вам будет интересно!
- Функциональное программирование
- За и против
- Императивный vs декларативный
- Функции и процедуры
?Параметры и аргументы
?Сигнатура
?Арность
?Рекурсия
?Функция первого класса
?Функция высшего порядка
?Предикат
?Замыкание
?Мемоизация - Конвейер и композиция
?Конвейер
?Композиция
?Преимущества
?Создание новых абстракций
?Бесточечный стиль
?Ограничения
?Пишем сами
?Как на практике? - Частичное применение и каррирование
?Частичное применение
?Каррирование
?В чём разница?
?Решение задачи с композицией
?Порядок данных
?Специализация
?Пишем сами
?Как на практике? - Неизменяемые данные
?Нечаянное мутирование данных
?Затраты на копирование
?Неизменяемые структуры данных (persistent data structures)
?Как на практике? - Чистые функции (pure functions)
?Побочные эффекты (side effects)
?Зависимость от параметров
?Непредсказуемый результат
?Преимущества чистых функций
?Абсолютная и относительная чистота - Заключение
Функциональное программирование
Когда кто-то рассуждает о функциональном программировании, речь может идти как о парадигме целиком, так и об отдельных подходах, таких как чистые функции, неизменяемые данные и другие.
Важно понимать, что когда на глаза попадается очередная статья «Почему разработчик обязательно должен знать ФП», то автор, вероятно, говорит именно о нескольких подходах из мира ФП, которые можно применить у себя на проекте, чем о том, что вам пора пересесть за Haskell.
Функциональное программирование — штука интересная, но вряд ли вы захотите переписать весь проект на функциональном языке. На практике именно отдельные подходы оказываются самыми полезными и применимыми. На них и сконцентрируемся. В контексте ФП часто можно встретить термины вроде линз и монад. Здесь они останутся за скобками, потому что уж слишком специфичны.
Если ваша задача — изучить подходы ФП, чтобы наконец-то разобраться с композицией, частичным применением, каррированием, неизменяемыми данными и чистыми функциями, скорее всего, эта статья ответит на большинство ваших вопросов. Но если вам интересно функциональное программирование как отдельная дисциплина, то статью можно рассматривать как плавное введение. В конце будут ссылки на материалы, которые помогут продолжить изучение.
За и против
Единого мнения, разумеется, нет, но во фронтенде есть тенденция к разумному применению некоторых подходов из мира ФП. Именно разумному использованию: всегда важно понимать, какая задача решается и какие способы решения будут наиболее эффективны.
В целом считается, что ФП делает код понятнее, потому что является более декларативным. Остальные рассуждения оставим за скобками, так как на Хабре уже достаточно статей, где рассмотрены разные аргументы как за ФП, так и против. При желании можно обратиться к ним, чтобы решить для себя, когда вы хотите использовать ФП, а когда нет. Здесь мы сосредоточимся на объяснении терминов и подходов.
Императивный vs декларативный
Императивный подход говорит о том, как решать задачу, декларативный — что хотим получить в результате.
В жизни мы, как правило, думаем о результате. Например, когда просим маму приготовить пиццу, делаем это декларативно: «Мам, хочу пиццу!» Если бы мы делали это императивно, то получилось бы что-то вроде: «Мама, открой холодильник, достань тесто, возьми помидоры и т. д.»
В разработке та же история. Когда мы пишем декларативно, код выглядит гораздо проще:
const array = [4, 8, 15, null, 23, undefined]
// императивный подход
const imperative = []
for (let i = 0, len = array.length; i < len; ++i) {
if (array[i]) {
imperative.push(array[i])
}
}
// декларативный подход
const declarative = array.filter(Boolean)
Для фильтрации массива чисел больше не нужно думать о деталях: о том, как инкрементировать переменную i и как не выйти за границы массива. Нам достаточно передать предикат Boolean в функцию filter, и дело сделано.
Причём если вам кажется, что декларативный стиль — нечто новенькое, то спешу вас заверить — это не так. Вы наверняка писали css-стили, где говорили, что именно хотите получить в результате:
/* css */
.button {
color: azure;
}
Нам неважно, как именно браузер будет парсить css, искать на странице элемент, соответствующий селектору, и перекрашивать его в определённый цвет. Мы говорим только о том, что хотим получить.
Такая же история и с SQL:
-- SQL
SELECT title
FROM films
WHERE rating > 9
GROUP BY director
Запрос говорит о результате, а не о том, как именно его выполнить.
Функции и процедуры
Функция — понятие, близкое к математическому. Она что-то получает на вход и всегда что-то возвращает.
const f = (x) => x * Math.sin(1 / x)
Процедура, в свою очередь, вызывается ради побочных эффектов:
const print = (...args) => {
const style = 'color: orange;'
console.log('%c' + args.join('\n'), style)
}
В данном случае код будет вызываться ради того, чтобы вывести в консоль свои аргументы оранжевым цветом и разделить их символом новой строки.
В JS не существует процедур, потому что то, что мы считаем процедурой, на самом деле является функцией без return. Если опустить return, функция всё равно неявно возвращает undefined и остаётся функцией.
Но в функциональном программировании мы стремимся как можно больше использовать функции, которые явно что-то возвращают.
Параметры и аргументы
Параметры — это переменные, созданные в объявлении функции. Аргументы — конкретные значения, переданные при вызове.
// x — параметр (почти любое число)
const f = (x) => x * Math.sin(1 / x)
// 0.17 — аргумент (конкретное число)
f(0.17)
Сигнатура
Количество, тип и порядок параметров. Объявление функции в JS не содержит информации о типе параметров из-за динамической типизации. Если не используется TypeScript, эту информацию можно указать через JSDoc.
/**
* @param {*} value
* @param {Function|Array<string>|null} [replacer]
* @param {number|string|null} [space]
* @returns {string}
*/
function toJSON (value, replacer, space) {
return JSON.stringify(value, replacer, space)
}
Арность
Арность — количество параметров, которые принимает функция. В JavaScript арность функции можно определить при помощи свойства length.
const awesome = (good, better, theBest) => {}
awesome.length // 3
У свойства length есть особенности, которые следует учитывать:
// аргументы по умолчанию
const defaultParams = (answer = 42) => {}
defaultParams.length // 0
// остаточные параметры
const restParams = (...args) => {}
restParams.length // 0
// деструктуризация
const destructuring = ({target}) => {}
destructuring.length // 1
Рекурсия
Когда функция вызывает саму себя, происходит рекурсивный вызов. Для его корректной работы необходимо, чтобы внутри функции было хотя бы одно рекурсивное условие, на которое мы обязательно рано или поздно выйдем. Если этого не произойдёт, программа зациклится.
function factorial (n) {
if (n <= 1) {
return 1
}
return n * factorial(n - 1)
}
Проблема в том, что в случае рекурсии с очень большой глубиной может произойти переполнение стека. Это можно исправить при помощи хвостовой рекурсии. Тогда каждый последующий рекурсивный вызов будет замещать в стеке текущий. Чтобы хвостовая рекурсия стала возможной, необходимо, чтобы функция не использовала замыкание и явно возвращала рекурсивный вызов в качестве самой последней операции. Пример про факториал можно было бы переписать так:
function factorial (n, total = 1) {
if (n <= 1) {
return total
}
return factorial(n - 1, n * total)
}
Несмотря на заманчивые возможности, поддержка хвостовой рекурсии до сих пор отсутствует и вряд ли появится в будущем, поэтому сведения о ней носят чисто теоретический характер. Если добавить в самое начало функции console.trace, можно убедиться, что каждый новый вызов создаёт новый кадр в стеке, несмотря на то, что условия рекурсии выполняются. Более подробно об оптимизации хвостовых вызовов можно почитать здесь.
Функция первого класса
Функции, которые мы можем использовать как обычные объекты, называются функциями первого класса. Их можно присваивать, передавать в другие функции и возвращать.
// присваивать
const assign = () => {}
// передавать
const passFn = (fn) => fn()
// возвращать
const returnFn = () => () => {}
Функция высшего порядка
Функции, которые принимают или возвращают другие функции. С ними мы работаем каждый день.
// map, filter, reduce и т.д.
[0, NaN, Infinity].filter(Boolean)
// обещания
new Promise((res) => setTimeout(res, 300))
// обработчики событий
document.addEventListener('keydown', ({code, key}) => {
console.log(code, key)
})
При этом высшим порядком могут быть не только функции, но и, например, компоненты в React, принимающие или возвращающие другие компоненты. Они, соответственно, называются компонентами высшего порядка.
Предикат
Это функция, которая возвращает логическое значение. Самый распространённый пример — использование предиката внутри функций filter, some, every.
const array = [4, 8, 15, 16, 23, 42]
// isEven — это предикат
const isEven = (x) => x % 2 === 0
const even = array.filter(isEven)
Замыкание
Замыкание — это функция плюс её область видимости. Замыкание создаётся заново каждый раз при вызове функции и позволяет получить значение к переменным, объявленным во внешней функции.
const createCounter = tag => count => ({
inc () { ++count },
dec () { --count },
val () {
console.log(`${tag}: ${count}`)
}
})
const pomoCounter = createCounter('pomo')
const work = pomoCounter(0)
work.inc()
work.val() // pomo: 1
const rest = pomoCounter(4)
rest.dec()
rest.val() // pomo: 3
В примере внутри замыкания хранятся две переменные: tag и count. Каждый раз, когда мы создаём новую переменную внутри другой функции и возвращаем её наружу, функция находит переменную, объявленную во внешней функции, через замыкание. Если тема замыканий кажется чем-то загадочным, почитайте о них подробнее в блоге HTML Academy.
Мемоизация
Полезный приём — функция кеширует результаты своего вызова:
const memo = (fn, cache = new Map) => param => {
if (!cache.has(param)) {
cache.set(param, fn(param))
}
return cache.get(param)
}
const f = memo((x) => x * Math.sin(1 / x))
f(0.314) // вычислить
f(0.314) // взять из кеша
Можно заметить, как одни возможности становятся базой для других, более сложных. Благодаря функциям первого класса становятся возможны функции высших порядков, благодаря которым становятся возможны замыкания. А благодаря замыканиям становится возможной мемоизация.
Конвейер и композиция
Конвейер и композиция — это вызов следующей функции с результатами предыдущей. В зависимости от того, в какую сторону мы передаём данные: слева направо или справо налево, получается либо конвейер, либо композиция.
Забавно, что в ООП тоже есть композиция, но она имеет там совершенно другой смысл.
Конвейер
Наверное, в жизни разработчиков конвейеры чаще всего встречаются при работе в командной строке, когда результат работы программы передаётся дальше при помощи конвейерного оператора.
# вывести идентификаторы процессов с подстрокой «kernel»
ps aux | grep 'kernel' | awk '{ print $2 }'
Возможно, в JavaScript тоже появится нечто похожее. В одном из предложений для ESNext описывается конвейерный оператор, при помощи которого можно будет делать вот такие штуки:
const double = (n) => n * 2
const increment = (n) => n + 1
// без конвейерного оператора
double(increment(double(double(5)))) // 42
// с конвейерным оператором
5 |> double |> double |> increment |> double // 42
Если бы у нас была функция pipe, которая аналогичным образом организовывает поток данных, через неё можно было бы записать так:
pipe(double, double, increment, double)(5) // 42
Аргумент, переданный в конвейер, последовательно проходит слева направо:
// 5 -> 10 -> 20 -> 21 -> 42
Хм, а что если запустить конвейер в другую сторону?
Композиция
Если запустить конвейер в обратную сторону, получится композиция. Композицию функций можно создать без операторов, просто вызывая каждую следующую функцию с результатами предыдущей.
// композиция функций в чистом виде
double(increment(double(double(5)))) // 42
Если записать то же самое через вспомогательную функцию compose, получится:
compose(double, increment, double, double)(5)
Внешне всё осталось почти так же, но место вызова функции increment изменилось, потому что теперь цепочка вычислений стала работать справа налево:
// 42 <- 21 <- 20 <- 10 <- 5
Если рассмотреть композицию и конвейер ближе, станет понятно, почему в функциональном программировании предпочитают композицию. Композиция описывает более естественный порядок вызова функций.
// оригинальная цепочка вызовов
three(two(one(x)))
// более естественно с точки зрения чтения
pipe(one, two, three)(x)
// более естественно с точки зрения записи
compose(three, two, one)(x)
Таким образом, конвейер и композиция — это два направления одного потока данных.
Преимущества
Когда поток данных организован через вспомогательные функции pipe или compose, больше не нужно писать много скобок, кроме того, код выглядит более декларативным, то есть более читаемым. Но есть ещё два момента, которые можно легко упустить.
Создание новых абстракций
Функции чем-то похожи на кубики Лего. Когда мы строим программу, она состоит из отдельных кубиков, причём каждый из них решает свою задачу.
Часть кубиков есть изначально — это встроенные функции, готовые библиотеки и код, написанный ранее. Когда мы добавляем в программу что-то ещё, то для создания новых кубиков обычно используем уже существующие.
Например, у нас есть два готовых кубика: получить слова из текста, оставить только уникальные слова:
// готовые кубики
const words = str => str
.toLowerCase().match(/[а-яё]+/g)
const unique = iter => [...new Set(iter)]
const text = `Съешь ещё этих мягких
французских булок, да выпей же чаю`
const foundWords = words(text)
const uniqueWords = unique(foundWords)
Затем мы замечаем, что хотим переиспользовать возможности двух кубиков, и создаём новую деталь. Для этого мы строим новую абстракцию — оборачиваем последовательный вызов двух функций в новую функцию, которая и станет нашей новой деталью.
function getUniqueWords (text) {
return unique(words(text))
}
const uniqueWords = getUniqueWords(text)
Сила композиции в том, что с её помощью можно создавать новые абстракции гораздо проще и удобнее:
// создаём новую деталь через композицию
const getUniqueWords = compose(unique, words)
const uniqueWords = getUniqueWords(text)
Когда мы решим переиспользовать эту деталь и создать на её основе ещё одну более сложную сущность, композиция запросто с этим справится.
В примере ниже мы берём ранее созданную деталь и делаем новую функцию, которая будет не только находить уникальные слова, но ещё и сортировать их по алфавиту.
const sort = iter => [...iter].sort()
// новая деталь, которая пригодится для новых построек
const getSortedUniqueWords = compose(sort, getUniqueWords)
const sortedUniqueWords = getSortedUniqueWords(text)
Если речь идёт о конструировании сложных деталей, вложенную композицию можно заменить на линейную:
// вложенная композиция
compose(sort, compose(unique, words))
// линейная композиция
compose(sort, unique, words)
Таким образом, композиция — это не просто шаблон для организации потока вычислений, но и фабрика по производству новых деталей.
Бесточечный стиль
Его следовало бы назвать стилем без параметров, потому что когда говорят о бесточечном стиле, то под точкой подразумевается параметр функции.
Когда новая функция создаётся путём оборачивания другой функции, для передачи данных из внешней функции во внутреннюю требуется один или несколько параметров. Когда же мы используем композицию, необходимость в этом отпадает, потому что результат одной функции передаётся дальше по цепочке.
// стиль с параметрами
function getUniqueWords (text) {
return unique(words(text))
}
// стиль без параметров (бесточечный стиль)
const getUniqueWords = compose(unique, words)
При работе со стилем без параметров функция не упоминает данные, которые мы обрабатываем.
В разработке есть две по-настоящему сложные проблемы: инвалидировать кеш и придумать названия для переменных. Композия не поможет решить задачу с кешем, но проблем с именованием параметров точно станет меньше.
Ограничения
Функция возвращает одно значение, следовательно, внутри конвейера или композиции мы можем передать дальше только один аргумент. Но как быть, если функция определена с несколькими параметрами, необходимыми для работы?
const translate => (lang, text) => magicSyncApi(lang, text)
const getTranslatedWords = compose(translate, unique, words)
getTranslatedWords(text) // упс... что-то сломалось
Здесь на помощь приходит частичное применение и каррирование, о которых мы поговорим позже.
Пишем сами
Реализовать конвейер можно было бы так:
function pipe (...fns) {
return (x) => fns.reduce((v, f) => f(v), x)
}
Чтобы реализовать композицию, достаточно заменить reduce на reduceRight:
function compose (...fns) {
return (x) => fns.reduceRight((v, f) => f(v), x)
}
Как на практике?
На практике не так много случаев, где можно применить композицию. Кроме того, применимость ограничена отсутствием в JS встроенных механизмов — нужно использовать библиотеки или самостоятельно реализовывать у себя необходимые функции. Но при этом, как ни странно, если вы используете на проекте Redux, ничего подключать не придётся, потому что композиция входит в состав библиотеки.
На проекте с Redux композиция наверняка будет использоваться для middleware, потому что createStore принимает только один усилитель (enhancer), а их, как правило, требуется хотя бы несколько.
// композиция в redux
const store = createStore(
reducer,
compose(
applyMiddleware(...middleware),
DevTools.instrument(),
)
)
Мы помним, что композиция направлена справа налево. Промежуточные слои, которые должны быть вызваны раньше других, помещаются правее или ниже. В примере выше DevTools добавляются до применения middleware, чтобы можно было корректно дебажить асинхронный код.
Другой кейс, где может пригодиться композиция — фильтрация или преобразование потока данных:
const notifications = [
{ text: 'Warning!', lang: 'en', closed: true },
{ text: 'Внимание!', lang: 'ru', closed: false },
{ text: 'Attention!', lang: 'en', closed: false }
]
// good
notifications.filter((notification) => {
// ...проверить все условия
})
// better
notifications
.filter(isOpen)
.filter(isLang)
// the best
notifications.filter(compose(
isLang,
isOpen,
))
Частичное применение и каррирование
Преобразуют функцию с исходным набором параметров в другую функцию с меньшим числом параметров, но делают это по-разному.
Для демонстрации работы частичного применения и каррирования будем использовать такую незамысловатую функцию:
const sum = (x, y, z) =>
console.log(x + y + z)
Частичное применение
Преобразует функцию в одну функцию с меньшим числом параметров.
const partialSum = partial(sum, 8)
partialSum(13, 21) // 42
Каррирование
Преобразует функцию в набор функций с единственным параметром.
const curriedSum = curry(sum)
curriedSum(8)(13)(21) // 42
Несмотря на то, что в классическом понимании каррирование преобразует функцию в набор функций с единственным параметром, на практике реализации каррирования могут принимать несколько аргументов за раз:
curriedSum(8, 13)(21) // 42
curriedSum(8, 13, 21) // 42
В чём разница?
Как мы уже выяснили, частичное применение преобразовывает функцию в одну единственную, в то время как каррирование преобразовывает её в набор функций. Это означает, что когда мы передаём аргументы в количестве меньшем, чем арность функции, при частичном применении происходит вызов функции:
const partialSum = partial(sum, 42)
partialSum() // NaN, потому что 42 + undefined + undefined
В то время как каррирование будет возвращать новые функции до тех пор, пока не наберётся достаточное число аргументов.
const curriedSum = curry(sum)
curriedSum(8) // новая функция — sum(8)
curriedSum(8)(13) // ещё одна новая функция — sum(8, 13)
curriedSum(8)(13)(21) // 42, потому что набралось нужное число аргументов
Можно провести аналогию: вы делаете заказ в ресторане. Если использовать частичное применение, официант задаст вам только один вопрос о том, что вы хотите заказать, и если вы ответите «хочу пиццу», то остальное он додумает сам и принесёт ту пиццу, которую посчитает нужной.
В случае с каррированием официант, наоборот, будет задавать наводящие вопросы: какую именно пиццу вы хотите, на каком тесте, какого размера и т. д. То есть будет спрашивать до тех пор, пока не убедится, что вы сообщили всю необходимую информацию.
Решение задачи с композицией
Проблему с композицией из предыдущего примера при помощи частичного применения или каррирования можно решить вот так:
const translate => (lang, text) => magicSyncApi(lang, text)
// через частичное применение
const english = partial(translate, 'en')
// через каррирование
const english = curry(translate)('en')
// создать новую деталь с возможностью перевода
const getTranslatedWords = compose(english, unique, words)
getTranslatedWords(text) // теперь всё работает
Порядок данных
Частичное применение и каррирование чувствительны к порядку данных. Существует два подхода к порядку объявления параметров.
// сперва итерация, затем данные (iterate-first, data-last)
const translate => (lang, text) => /* */
// сперва данные, затем итерация (data-first, iterate-last)
const translate => (text, lang) => /* */
В обычном проекте вы вряд ли будете писать функции, где более специфические данные следует передавать в первую очередь, поэтому полезно держать под рукой хелпер для преобразования iterate-last в iterate-first. Его можно написать и применить вот так:
function flip (fn) {
return (...args) => fn(...args.reverse())
}
const curryRight = compose(curry, flip)
const partialRight = compose(partial, flip)
При помощи композиции на основе каррирования и частичного применения мы сделали две новые детали, которые можно использовать для функций с другим порядком данных.
Специализация
Кроме применения в композиции для настройки сигнатуры функции, у частичного применения и каррирования есть другая полезная особенность. С их помощью можно делать функции более специфичными. Например, мы хотим сделать запрос API:
const fetchApi = (baseUrl, path) =>
fetch(`${baseUrl}${path}`)
.then(res => res.json())
Затем понимаем, что хотим переиспользовать функцию для запроса данных с определённого адреса. В этом случае мы точно так же, как создавали детали через композицию, можем создать новую, но на этот раз более специфическую деталь при помощи каррирования или частичного применения.
// каррирование
const fetchCurry = curry(fetchApi)
const fetchUnsplash = fetchCurry('https://api.unsplash.com')
const fetchRandomPhoto = fetchUnsplash(fetchApi, '/photos/random')
// частичное применение
const fetchUnsplash = partial(fetchApi, 'https://api.unsplash.com')
const fetchRandomPhoto = partial(fetchUnsplash, '/photos/random')
Пишем сами
Свою версию частичного применения можно написать примерно так:
function partial (fn, ...apply) {
return (...args) => fn(...apply, ...args)
}
Каррирование выглядит немного сложнее:
function curry (fn) {
return (...args) => args.length >= fn.length ?
fn(...args) : curry(fn.bind(null, ...args))
}
Как на практике?
Две основные возможности частичного применения и каррирования: настройка функций для реализации композиции и специализация. Композиция используется редко, поэтому специализация является гораздо более полезной.
А ещё в JavaScript у функций есть метод .bind, который реализует частичное применение из коробки, поэтому, если порядок параметров позволяет, то вуаля:
const fetchApi = (baseUrl, endpoint) =>
fetch(`${baseUrl}${endpoint}`)
.then(res => res.json())
const fetchUnsplash = fetchApi.bind(null, 'https://api.unsplash.com')
const fetchRandomPhoto = fetchUnsplash.bind(null, '/photos/random')
Неизменяемые данные
Неизменяемые или иммутабельные данные устойчивы к изменениям (мутациям). Каждый раз, когда в данных требуется что-то изменить, создаётся копия, а исходники остаются без изменений. Этот подход помогает избежать досадных ошибок, но важно не забывать всегда использовать неизменяемые данные, когда это необходимо.
Для иллюстрации принципа работы неизменяемых данных подойдёт пример со стаканом. Представим, что у нас есть стакан с водой, из которого мы немного выпиваем, а через некоторое время делаем ещё один глоток. Стакан опустеет ровно настолько, сколько мы из него выпили. Это — изменяемые данные.
// mutable glass
const takeGlass = (volume) => ({
look () { console.log(volume) },
drink (amount) {
volume = Math.max(volume - amount, 0)
return this
}
})
const mutable = takeGlass(100)
mutable.drink(20).drink(30).look() // 50
mutable.look() // 50
С неизменяемыми структурами данных совершенно другая история. Перед тем, как сделать глоток, создаётся точная копия стакана, и мы пьём уже из копии. Таким образом, после первого глотка у нас будет два стакана: один полный, из другого мы немного отпили. Исходный стакан останется без изменений. Это и есть неизменяемые данные.
// immutable glass
const takeGlass = (volume) => ({
look () { console.log(volume) },
drink (amount) {
return takeGlass(Math.max(volume - amount, 0))
}
})
const immutable = takeGlass(100)
immutable.drink(20).drink(30).look() // 50
immutable.look() // 100
Преимущества неизменяемых структур данных:
— предсказуемое изменение состояния,
— быстрое сравнение по ссылке,
— кешируемость,
— легко распараллеливать,
— легко тестировать.
Но у неизменяемых структур есть два больших недостатка: нужно помнить о том, что данные надо копировать, когда это необходимо, и, соответственно, появляются затраты на копирование. Рассмотрим их подробнее.
Нечаянное мутирование данных
В JavaScript запросто можно нечаянно мутировать массив или любой другой объект:
function sortArray (array) {
return array.sort()
}
const fruits = ['orange', 'pineapple', 'apple']
const sorted = sortArray(fruits)
// упс... исходный массив тоже изменился
console.log(fruits) // ['apple', 'orange', 'pineapple']
console.log(sorted) // ['apple', 'orange', 'pineapple']
Мы можем попробовать защититься от этого, но есть проблема. Вещи, которые кажутся неизменяемыми, на самом деле таковыми не являются. Объявление через const защищает от изменений только ссылку, а сам объект остаётся открыт для мутаций.
const object = {}
// const означает константную ссылку
object = {} // TypeError: Assignment to constant variable
// но сам объект можно беспрепятственно изменять
object.value = 42 // мутация объекта
Все ссылочные типы — объекты, массивы и другие — всегда передаются по ссылке. Во время присваивания или передачи параметра происходит копирование ссылки, но не самих данных.
const array = []
// копия ссылки
const ref = array
ref.push('apple')
// ещё одна копия ссылки
const append = (ref) => {
ref.push('orange')
}
append(array)
// массив дважды мутирован через ссылку
console.log(array) // [ 'apple', 'orange' ]
А что если применить средства метапрограммирования и, например, заморозить объект? В этом случае мы всё равно сможем изменить вложенные объекты по ссылке.
const object = { val: 42, ref: {} }
const frozen = Object.freeze(object)
// игнорирование ошибки без 'use strict'
// или же TypeError: Cannot assign to read only property...
frozen.val = 23
// мутирование вложенных данных по ссылке
frozen.ref.boom = 'woops'
console.log(frozen) // { val: 42, ref: { boom: 'woops' }
Вместо заморозки можно воспользоваться Proxy API, но в этом случае тоже придётся дополнительно обрабатывать вложенные структуры, потому что они всё ещё открыты для изменений.
const object = { val: 42, ref: {} }
const proxy = new Proxy(object, {
set () { return true },
deleteProperty () { return true }
})
// изменение или удаление свойства не сработает
proxy.val = 19
delete proxy.val
// точно так же, как и добавление нового
proxy.newProp = 23
// но вложенные объекты всё ещё мутабельны
proxy.object.boom = 'woops'
console.log(proxy) // { val: 42, ref: { boom: 'woops' } }
В общем, в JavaScript нельзя просто так взять и защитить данные от непреднамеренного изменения.
Затраты на копирование
С копированием данных тоже не всё так просто. В большинстве случаев работает копирование массивов и объектов встроенными средствами JavaScript:
const array = [4, 8, 15, 16, 23]
const object = { val: 42 }
// создать новый объект или массив
[].concat(array)
Object.assign({}, object)
// но через деструктуризацию удобнее
[...array]
{...object}
К сожалению, в этом случае создаётся поверхностная копия, поэтому мы избавляемся от мутаций только до тех пор, пока отсутствуют другие вложенные объекты:
const object = { val: 42, ref: {} }
const copy = { ...object }
copy.val = 23
copy.ref.boom = 'woops'
console.log(object) // { val: 42, ref: { boom: 'woops' }
Такая же история с функциональными методами массивов — map и filter создают поверхностную копию исходного массива.
const array = [null, 42, {}]
const copy = array.filter(Boolean)
copy[0] = 23
copy[1].boom = 'woops'
console.log(array) // [ null, 42, { boom: 'woops' } ]
console.log(copy) // [ 23, { boom: 'woops' }
Поэтому для создания полноценной копии нужна встроенная функция глубокого копирования, которая потребует дополнительных затрат. Реализовать глубокое копирование можно несколькими способами, подробнее о возможных вариантах читайте здесь:
— The problems of shared mutable state and how to avoid them
— What is the most efficient way to deep clone an object in JavaScript?
Неизменяемые структуры данных (persistent data structures)
Итак, с неизменяемостью в JavaScript всё сложно, но мы можем обойти существующие ограничения при помощи специальных структур данных. Если взять библиотеку, которая реализовывает неизменяемые структуры, и воспользоваться ей у себя в проекте, мы получим два преимущества. Во-первых, будет гораздо сложнее нечаянно мутировать данные, потому что библиотека каждый раз самостоятельно создаёт копии. Во-вторых, под капотом, скорее всего, будут разного рода оптимизации для более эффективного копирования данных, как, например, копирование при записи, когда во время чтения данных используется общая копия, а в случае изменения создаётся новый объект.
Пожалуй, две самые популярные библиотеки в мире фронтенд-разработки — это Immutable и Immer. При помощи Immer мы можем сделать вот что:
import produce from 'immer';
const object = { ref: { data: {} } };
const immutable = produce(object, (draft) => {
draft.ref.boom = 'woops';
});
console.log(object) // { ref: { data: {} }
console.log(immutable) // { ref: { data: {}, boom: 'woops' }
// оптимизация через копирование при записи
// копия data не создавалась, т.к. объект не изменялся
console.log(object.ref.data === immutable.ref.data) // true
Да, нам всё равно приходится для изменения данных вызывать функцию produce, но это уже лучше, чем рассчитывать на отсутствие случайных мутаций. Кроме того, Immer замораживает все объекты, которые возвращает produce, чтобы защитить разработчика от возможных нечаянных мутаций.
Как на практике?
Следует помнить об изменчивой природе ссылочных типов данных и точно знать, какие методы мутирующие, а какие нет. Во многих случаях деструктуризации будет достаточно:
const addTodo = (state = initState, action) => {
switch (action.type) {
case ADD_TODO: {
return {
...state,
todos: [...state.todos, action.todo]
}
}
default: {
return state;
}
}
}
Но, как мне кажется, во многих других ситуациях неизменяемые структуры данных подойдут куда лучше:
import produce from 'immer'
const addTodo = (state = initState, action) =>
produce(state, draft => {
switch (action.type) {
case ADD_TODO: {
draft.todos.push(action.todo)
break
}
}
})
Чистые функции (pure functions)
Функции без побочных эффектов, которые зависят только от параметров и для одних и тех же аргументов всегда возвращают один и тот же результат.
Чистые функции могут вызывать другие чистые функции, но если внутри цепочки вызовов попадётся хотя бы одна функция, которая не отвечает условиям чистоты, вся цепочка перестаёт быть чистой. Рассмотрим подробно каждое из условий, которым должны отвечать чистые функции.
Побочные эффекты (side effects)
Побочными эффектами называется любое взаимодействие с внешним миром через операции ввода/вывода (логирование, запись в файл, запрос на сервер и т. д.), изменение глобальных переменных и мутация данных.
function impure () {
// логирование
console.log('side effects')
// запись в файл
fs.writeFileSync('log.txt', `${new Date}\n`, 'utf8')
// запрос на сервер и т. д.
fetch('/analytics/pixel')
}
Такие операции чем-то похожи на философский вопрос о звуке падающего дерева в лесу, когда рядом никого нет. Может показаться, что когда мы что-то логируем внутри функции, это никак не влияет на нашу программу. Если где-то падает дерево, но рядом никого нет, то и звука тоже не будет. Но если рассматривать звук как физическое явление колебаний воздуха, то оно произойдёт независимо от наличия наблюдателя. Точно так же вызов функции оставит логи на сервере или где-то ещё, даже если текущее состояние программы никак не изменится.
Работа с глобальными переменными — тоже побочный эффект.
function impure () {
// глобальная переменная
app.state.hasError = true
}
Как правило, изменение глобальных значений непосредственно влияет на текущее состояние приложения, в то время как операции ввода/вывода меняют что-то за пределами приложения. Но в веб-разработке всё вращается вокруг DOM. Это не только доступы и изменение глобальных переменных, но ещё и операции ввода/вывода. Получается, что фронтенд — один сплошной побочный эффект. Другими словами, фронтенд замечателен тем, что совмещает в себе всё «самое лучшее».
function impure () {
// модификация DOM
document.getElementById('menu').hidden = true
// установка обработчика
window.addEventListener('scroll', () => {})
// запись в локальное хранилище
localStorage.setItem('status', 'ok')
}
От побочных эффектов не получится избавиться полностью, но их можно вынести за пределы функции, сделав саму функцию чистой. Тогда она будет принимать данные через параметры.
Мутация данных внутри функции — ещё одна разновидность побочных эффектов. Функция, которая мутирует данные, как бы оставляет след в виде изменений после вызова. Сложность в том, что многие встроенные функции JS по умолчанию мутируют данные. Если об этом забыть, можно нечаянно оставить после вызова функции след из побочных эффектов.
function impure (o) {
return Object.defineProperty(o, 'mark', {
value: true,
enumerable: true,
})
}
const object = {}
const marked = impure(object)
// defineProperty мутировала исходный объект
console.log(object) // { mark: true }
Лучший способ избежать мутации данных — использовать неизменяемые структуры данных.
Зависимость от параметров
Чистые функции зависят только от своих параметров. Если функция обращается к глобальной переменной или получает данные через операцию чтения данных извне, она теряет свою чистоту.
function impure () {
// глобальная переменная
if (NODE_ENV === 'development') { /* */ }
// чтение данных из DOM
const { value } = document.querySelector('.email')
// обращение к локальному хранилищу
const id = localStorage.getItem('sessionId')
// чтение из файла и т. д.
const text = fs.readFileSync('file.txt', 'utf8')
}
Внешние зависимости можно заменить на зависимость от параметров.
Непредсказуемый результат
Чистые функции всегда возвращают один и тот же результат для одних и тех же параметров. Как только появляется непредсказуемость, функция теряет чистоту. Простой пример непредсказуемого результата — работа со случайностью.
function impure (min, max) {
return Math.floor(Math.random() * (max - min + 1) + min)
}
impure(1, 10) // 4
impure(1, 10) // 2
Чтобы сделать функцию чистой, достаточно вынести неопределённость за пределы функции и передать её в качестве параметра. Например, вот так:
function pure (min, max, random = Math.random()) {
return Math.floor(random * (max - min + 1) + min)
}
pure(1, 10, 0.42) // 5
pure(1, 10, 0.42) // 5
Теперь функция всегда возвращает один и тот же результат для одних и тех же параметров.
Преимущества чистых функций
Их плюсы:
— проще разобраться, как устроена функция,
— их можно запросто кешировать,
— легко тестировать,
— легко распараллеливать.
Кроме того, они обладают ссылочной прозрачностью. Это эффект, который позволяет вместо вызова функции без особых трудностей подставить результат её работы.
const refTransparency = () =>
Math.pow(2, 5) + Math.sqrt(100)
// вызов функции
refTransparency()
// можно раскрыть
Math.pow(2, 5) + Math.sqrt(100)
// и без особых трудностей понять результат
32 + 10 // 42
Так почему бы всё не написать на чистых функциях?
Абсолютная и относительная чистота
Если взять и написать программу только из чистых функций, то получится:
(() => {})() // абсолютная чистота
Такая программа не делает ничего. Программа без побочных эффектов — штука бесполезная. Мы пишем код ради побочных эффектов. Поэтому вместо того, чтобы полностью от них избавиться, нужно уменьшить их количество, изолировать оставшиеся в одном месте, а большинство функций сделать чистыми.
// побочные эффекты выносятся за пределы
const text = fs.readFileSync('file.txt', 'utf8')
// функция получает нужные данные только через параметры
function pure (text) {
// ... чистота
}
Кроме того, чистота относительна. Функция ниже — чистая или нет?
// pure или impure?
function circleArea (radius) {
return Math.PI * (radius ** 2)
}
Строго говоря, такая функция не является чистой, потому что зависит от глобальной переменной, но вряд ли кому-то захочется менять значение PI, поэтому не стоит доводить погоню за чистотой до абсурда.
Заключение
Мне кажется, чистые функции — одна из самых полезных и применимых методик, для которой не нужен ни функциональный язык, ни библиотеки. Достаточно по-новому взглянуть на свой код. Неизменяемые данные тоже хороши, но для работы с ними потребуются дополнительные библиотеки. Да и остальные концепции тоже можно использовать, но реже.
В статье мы рассмотрели базовые концепции ФП, однако на этом всё не заканчивается. Если у вас есть желание погружаться в тему дальше, советую почитать:
— Жаргон функционального программирования
— Functional-Light JavaScript
— Mostly adequate guide to Functional Programming
Кроме того, загляните в репозиторий Awesome FP JS, вдруг найдёте что-то интересное для себя. Если же захочется целиком погрузиться в функциональную парадигму, но при этом продолжать разрабатывать фронтенд, можно посмотреть в сторону Ramda или Elm.
Спасибо за внимание, жду вас в комментариях — будем обсуждать материал и делиться опытом.
steb
Интересно, мне одному кажется что всё с точностью до наоборот? С оговоркой на то, что названия функций говорит об очерёдности их выполнения.
gbiz Автор
Верное замечание, исправил так, чтобы названия были по порядку. Спасибо!
steb
Однако ниже по тексту у вас есть красивая композиция:
Она хорошо читаеться именно в таком порядке. Попытка выразить в виде:
Выглядит более императивно, т.е. мы больше акцентируем внимание на том что делать, чем на том какой результат мы хотим получить.
Это заставляет меня задуматься над вопросами:
Второй вопрос уточню комментарием ниже, который будет к пункту “Создание новых абстракций”