Основные определения
Функциональное программирование (ФП) - это такая же, как и ООП, парадигма разработки. Она предписывает концепции, которыми мы должны пользоваться во время разработки.
ФП не построено поверх функций - мы пользуемся программным кодом, как математическими функциями
Распространённость
Есть языки, которые находятся только в рамках одной парадигмы:
- ООП
- Java
- C#
- ФП
- Haskell
- Lisp
- F#
Но так же есть и мультипарадигменные языки, как JS
JS - это мультипарадигменный ЯП, кооторый поддерживает как императивный, так и функциональный стили.
Базовые концепции ФП
Процедура - это блок кода, который выполняет набор заданных действий.
Функция - это блок кода, который возвращает результат. Обычно, это результат каких-то вычислений. Внутрь мы передаём данные и получаем неизменный от раза к разу результат.
Декларативность
Есть в программировании глобально два подхода:
- Императивный → Как хотим получить? → Описываем действия
- Декларативный → Что хотим получить? → Описываем результат
В основе всего всегда стоит императивный подход, на базе которого создаются декларативные
Императивная функция
const arr = [1, 2, 3, 4, 5, 6];
function getEvens(arr) {
const evens = [];
for (let i = 0; i < arr.length; i++) {
if (arr[i] % 2 === 0) {
evens.push(arr[i]);
}
}
return evens;
}
console.log(getEvens(arr)); // [2, 4, 6]А уже поверх императивных функций, мы можем строить декларативные, в которых мы просто будем описывать действия, которые должен выполнять код
const arr = [1, 2, 3, 4, 5, 6];
const evens = getEvens(arr)
console.log(evens); // [2, 4, 6]Декларативность - это свойство любого хорошего кода. Нужно стремиться реализовывать максимум абстракций над низкоуровневыми операциями.
Чистые функции
Чистая функция — это такая функция, которая всегда ведет себя предсказуемо и не влияет на внешнее окружение.
Основные признаки чистых функций:
- детерминированность - при одних и тех же входных аргументах всегда возвращает один и тот же результат
- отсутствие сайд-эффектов - не изменяет внешние переменные, не работает с сетью/файлами, не пишет в консоль, не влияет на состояние вне самой функции
- не использует и не изменяет состояния вне своих аргументов - не опирается на глобальные переменные, не мутирует их
Эти функции при одних и тех же значениях всегда будут возвращать одинаковый результат
function add(a, b) {
return a + b;
}
function multiplyBy2(arr) {
return arr.map(x => x * 2);
}
function isEven(n) {
return n % 2 === 0;
}А уже эта функция зависит от внешнего состояния и не является чистой
let counter = 0;
function increment() {
counter += 1;
return counter;
}Иммутабельность
Иммутабельность (неизменяемость) — концепция, согласно которой данные после создания не изменяются.
Вместо изменения оригинального объекта создается его новая копия с нужными изменениями.
Преимущества такого подхода:
- Предсказуемость - нет скрытых изменений в других частях программы
- Упрощает отладку и тестирование
- Легче работать с историей изменений (например, undo)
Проблемы мутабельности:
- данные будут несогласованными в разных местах кода
- будут происходить неявные побочные эффекты
- усложнится дебаг и тестирование кода
Примеры
Если нам нужно изменить массив по своим критериям, то лучшим вариантом будет создать новый массив
const arr = [1, 2, 3];
const plusOne = arr.map(x => x + 1); // [2, 3, 4], arr не изменилсяЕсли нам нужно добавить значение в массив, то так же лучшим вариантом будет создать копию прошлого и добавить уже в него новое значение
const arr = [1, 2, 3];
arr.push(4); // arr теперь [1, 2, 3, 4] — оригинальный массив изменился!
const arr = [1, 2, 3];
const newArr = [...arr, 4]; // arr остался прежним, newArr — [1, 2, 3, 4]Проблемы
В JS мы используем ссылки на объекты и массивы, поэтому при обращении к ним, мы воздействуем с корневым объектом, а не с его копией
Опасно использовать методы:
sortsplice
Нужно использовать:
toSorted, копировать старый массив и сортировать его[...arr].sortimmer/immutable js- map, filter, reduce
Функции первого класса и высшего порядка
В JS всё является объектом: массивы, функции и сами объекты. Примитивные типы данных имеют враппер-объект, который предоставляет доступ к методам связанным с этим типом данных.
Мы можем:
- Передавать функции аргументами в функцию
- Возвращать другую функцию из функции
- Присвоить функцию в переменную
Мы можем пользоваться функцией, как обычным объектом, но с возможностью вызывать его.
Таким образом получается, что когда мы работаем с функциями таким образом, то они будут являться функциями первого класса
Функции высшего порядка - это функции, которые каким-либо образом работают с другими функциями:
- принимают их аргументом
- возвращают другую функцию
Примеры:
map,reduce,filter, замыкания, мемоизация, debounce/throttle
Частные случаи
Композиция функций
Композиция функций — это применение одной функции к результату другой, в результате чего создаётся новая функция
Композиция нам нужны для поддержания декларативности системы
В обычной жизни, мы можем применять дефолтно операции последовательно друг за другом
fn1(fn2(fn3(fn4('hello'))));Но такой подход будет крайне неудобным.
Для решения этой проблемы со скрытием операций, существует функция compose, которая позволяет последовательно выполнять операции и получить результат
// функция-композитор
const compose = (...funcs) => (initialValue) =>
funcs.reduceRight((acc, fn) => fn(acc), initialValue);
// пример
const upperCase = str => str.toUpperCase();
const exclaim = str => str + '!';
const repeat = str => `${str} `.repeat(2);
const getResult = compose(repeat, exclaim, upperCase);
console.log(getResult('hello')); // HELLO! HELLO! Такой подход используется в декораторах
Конвейер
Функция-конвейер (pipe) — это аналог композиции, но она выполняет функции слева направо: результат первой функции передаётся второй, результат второй в третью и так далее.
const pipe =
(...functions) =>
(x) => functions.reduce((value, func) => func(value), x);Переданные функции будут вызываться последовательно
const double = x => x * 2;
const increment = x => x + 1;
const square = x => x * x;
const getResult = pipe(double, increment, square);
console.log(getResult(3)); // ((3 * 2) + 1) ^ 2 = 49Частичное применение
Частичное применение — это техника, позволяющая фиксировать часть аргументов функции и создавать новую функцию, которая ожидает оставшиеся аргументы.
Такой подход позволит нам не искать, не импортировать и не переписывать аргументы заново при каждом вызове функции. Тут мы скрываем определённую часть повторяемых аргументов
Исходная функция
const userHasRole = (user, role) => user.roles.includes(role);
const operator = { name: 'Анна', roles: ['USER', 'ADMIN'] };
userHasRole(operator, 'ADMIN'); // trueФиксируем пользователя
const clientHasRole = role => userHasRole(operator, role);
clientHasRole('ADMIN'); // true
clientHasRole('USER'); // true
clientHasRole('MANAGER'); // falseНо так же мы можем фиксировать аргументы через bind, что является более нативной реализацией
const clientHasRole = userHasRole.bind(null, operator);
console.log(clientHasRole('ADMIN')); // true
console.log(clientHasRole('USER')); // true
console.log(clientHasRole('MANAGER')); // falseФиксируем пользователя и роль
const isClientAdmin = () => clientHasRole('ADMIN');
isClientAdmin(); // trueВ итоге, мы сократили операцию с двух до нуля аргументов
userHasRole(operator, 'ADMIN');
// >
clientHasRole('ADMIN');
// >
isClientAdmin();Каррирование
Каррирование — это преобразование функции с несколькими аргументами в последовательность функций, каждая из которых принимает свои аргументы
const userHasRoleCurried = user => role => user.roles.includes(role);const operator = { name: 'Анна', roles: ['USER', 'ADMIN'] };
const clientHasRole = userHasRoleCurried(operator);
console.log(clientHasRole('ADMIN')); // true
console.log(clientHasRole('MANAGER')); // falseКаррирование часто может пригодиться, когда нам нужно обрабатывать поступившие аргументы и передавать их в следующую функцию. Это так же помогает сохранять декларативность кода за счёт более простого создания производных функций
Автокаррирование
Так же мы можем создать функцию, которая будет сама реализовывать каррированные функции
function curry(fn) {
return function curried(...args) {
// Если передано достаточно аргументов — вызываем функцию
if (args.length >= fn.length) {
return fn.apply(this, args);
} else {
// Иначе возвращаем функцию, ожидающую оставшиеся аргументы
return function (...nextArgs) {
return curried.apply(this, args.concat(nextArgs));
};
}
};
}Реализация каррирования с этой функцией-обёрткой
const curriedUserHasRole = curry(userHasRole);
const operator = { name: 'Анна', roles: ['USER', 'ADMIN'] };
const clientHasRole = curriedUserHasRole(operator);
console.log(clientHasRole('ADMIN')); // true
console.log(clientHasRole('MANAGER')); // false
// или так:
console.log(curriedUserHasRole(operator, 'USER')); // trueChaining
Chaining — это последовательное вызовы методов объекта (цепочка вызовов), при которой каждый метод возвращает объект, позволяющий продолжить цепочку
const result = new Array(10)
.fill(1) // [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
.map((x, i) => x + i) // [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
.filter(x => x % 2 === 0) // [2, 4, 6, 8, 10]
.join(', '); // "2, 4, 6, 8, 10"
console.log(result); // "2, 4, 6, 8, 10"fetch('https://jsonplaceholder.typicode.com/users/1')
.then(response => response.json())
.then(data => data.name)
.then(name => name.toUpperCase())
.then(console.log) // например: "LEANNE GRAHAM"
.catch(console.error);В таком случае, функция должна каждый возвращать this или объект, который сможет продолжить чейн
Контейнеры
Контейнер — это объект-обёртка, который “обеспечивает” значение и позволяет безопасно и декларативно работать с ним через специальные методы (например, map, flatMap)
class Box<T> {
// Приватное свойство с типом T
private value: T;
// Приватный конструктор
private constructor(value: T) {
this.value = value;
}
// Статический фабричный метод
static of<T>(value: T): Box<T> {
return new Box(value);
}
// map с возвращением нового контейнера
map<U>(fn: (x: T) => U): Box<U> {
return Box.of(fn(this.value));
}
// fold для раскрытия значения
fold<U>(fn: (x: T) => U): U {
return fn(this.value);
}
}
// Пример использования:
const result = Box
.of(10)
.map(x => x + 5)
.map(x => x * 2)
.fold(x => `Результат: ${x}`);
console.log(result); // 'Результат: 30'Это позволяет нам сделать значение иммутабельным и изменять его декларативно через доступные методы.
Такой подход реализуем так же и через прототипы, но проще сделать через классы.
Так же мы избавляемся от луковицы вызовов функций fn1(fn2(fn3(val)))
Функторы
Функтор — это структура-обёртка, у которой есть метод map(fn), позволяющий применить функцию к хранимому значению и получить новую обёртку такого же типа.
// Функтор: map возвращает новый Box с результатом fn(value)
const result = Box.of(2).map(x => x + 3).map(x => x * 10); // Box(50)Функтор гарантирует сохранение структуры и позволяет безопасно “транслировать” внутри неё значения с помощью map, что обычно называется: законом композиции и законом идентичности.
Функторами в JS можно назвать:
- Array
- Promise
Аппликативные функторы
Аппликативный функтор — расширяет идею функтора: в нём можно хранить не только значения, но и функции, и применять их к значениям внутри других контейнеров с помощью метода наподобие ap (apply).
class Box<T> {
// ...
ap<U>(b: Box<(x: T) => U>): Box<U> {
return b.map(fn => fn(this.value));
}
}
// 1 - box от value
const valueBox = Box.of(5);
// box от func
const fnBox = Box.of((n: number) => n * 3);
// применяет функцию
const result2 = valueBox.ap(fnBox); // Box(15)Если map работает с функциями, то ap работает с другими контейнерами
Монады
Монада — это структура, в которой есть метод flatMap (или chain, иногда называют bind): только он позволяет “разворачивать” вложенные контейнеры. То есть внутри map мы получаем не просто значение, а целый новый контейнер - и этот контейнер не будет вкладываться друг в друга бесконечно, а аккуратно свёрнут обратно в один слой.
class Box<T> {
// ..
flatMap<U>(fn: (x: T) => Box<U>): Box<U> {
return fn(this.value);
}
}
const result3 = Box.of(8)
.flatMap(x => Box.of(x * 2)) // Box(16)
.flatMap(x => Box.of(`box:${x}`)); // Box('box:16')- Основное отличие от
map: внутриflatMapфункция возвращает уже Box, а не просто значение, и обёртывание не происходит повторно. - Монады очень удобны для построения “цепочек” асинхронных, побочных или условных вычислений без вложенных структур.
Функторы - это интерфейс функции, а монады - это реализация интерфейса функторов
Спецификация Fantasy Land
Fantasy Land — это спецификация для алгебраических структур в JS, которая описывает набор абстракций и требований к ним, чтобы разные библиотеки могли работать совместно.
Вот краткий список основных сущностей Fantasy Land и их ключевые идеи:
- Setoid
- equals (fantasy-land/equals): определяет эквивалентность; законы рефлексивности, симметрии, транзитивности.
- Ord (требует Setoid)
- lte (fantasy-land/lte): тотальный порядок; антисимметрия, транзитивность.
- Semigroup
- concat (fantasy-land/concat): ассоциативное объединение значений одного типа.
- Monoid (требует Semigroup)
- empty (fantasy-land/empty): нейтральный элемент; левые/правые единицы.
- Group (требует Monoid)
- invert (fantasy-land/invert): обратный элемент; левые/правые инверсии.
- Semigroupoid
- compose (fantasy-land/compose): ассоциативная композиция морфизмов.
- Category (требует Semigroupoid)
- id (fantasy-land/id): тождественный морфизм; левые/правые единицы.
- Functor
- map (fantasy-land/map): отображение значения, соблюдая identity и composition.
- Apply (требует Functor)
- ap (fantasy-land/ap): применение функции внутри контейнера к значению в контейнере; композиционный закон.
- Applicative (требует Apply)
- of (fantasy-land/of): подъём значения в контейнер; законы identity, homomorphism, interchange.
- Chain (требует Apply)
- chain (fantasy-land/chain): монадическая связка; ассоциативность.
- ChainRec (требует Chain)
- chainRec (fantasy-land/chainRec): стек-безопасная рекурсия в терминах Chain.
- Monad (требует Applicative и Chain)
- of (fantasy-land/of): как в Applicative.
- chain (fantasy-land/chain): законы левой/правой единицы.
- Foldable
- reduce (fantasy-land/reduce): свёртка структуры; эквивалентность специфицированной свёртке.
- Traversable (требует Functor и Foldable)
- traverse (fantasy-land/traverse): проход со сборкой эффектов; naturality, identity, composition.
- Contravariant
- contramap (fantasy-land/contramap): преобразование входного типа; законы identity и composition.
- Bifunctor (требует Functor)
- bimap (fantasy-land/bimap): отображение по двум измерениям; identity и composition.
- Profunctor (требует Functor)
- promap (fantasy-land/promap): контра-/ковариантное отображение входа/выхода; identity и composition.
- Alt (требует Functor)
- alt (fantasy-land/alt): ассоциативная альтернатива; дистрибутивность относительно map.
- Plus (требует Alt)
- zero (fantasy-land/zero): нейтральный «пустой» элемент для alt; законы left/right identity и annihilation.
- Alternative (требует Applicative и Plus)
- alt (fantasy-land/alt)
- zero (fantasy-land/zero)
- of (fantasy-land/of): законы дистрибутивности и уничтожения для ap с zero.
- Extend (требует Functor)
- extend (fantasy-land/extend): построение значения из контейнера; закон квазимонадной ассоциативности.
- Comonad (требует Extend)
- extract (fantasy-land/extract): извлечение значения; левые/правые единицы для extend/extract.
Итоги
- Декларативность, чистота функций, иммутабельность и функции высшего порядка — основа гибкости ФП.
- В JS доступны и базовые, и продвинутые ФП-практики.