ФП-стиль в JavaScript
Функциональное программирование это не язык и не библиотека. Это дисциплина: писать программу как композицию маленьких чистых функций, минимизировать мутации, делать данные иммутабельными по умолчанию, обрабатывать побочные эффекты на границе модуля, а не в середине логики. JavaScript позволяет всё это с первой строки именно потому, что функции в нём первоклассные значения, а объекты и массивы спред-копируются одной строкой.
В предыдущих уроках мы накопили инструментарий: типы и приведения, функции и замыкания, объекты и прототипы. Теперь пора собрать из этого стиль, в котором программа становится похожа на конвейер. Данные входят, проходят через цепочку преобразований, выходят. Мутации, циклы и временные переменные исчезают, остаются выражения, которые читаются слева направо.
Этот урок про конкретные техники такого стиля. HOF и почему любой
декоратор или middleware это HOF. Callbacks как контракт между двумя
функциями. Частичное применение и каррирование. pipe и compose.
Мемоизация. Обёртки once, throttle, debounce, retry. Миксины.
Каждая техника это применение четырёх столпов ФП из урока
02-cs/04-fp-foundations на конкретной
задаче, а не абстрактная игра с синтаксисом.
1. Higher-order functions
HOF,
функция, принимающая функцию или возвращающая функцию. Это техническое
определение, а суть, абстрагировать поведение. Пример, своя map:
const map = (fn, xs) => {
const out = [];
for (const x of xs) out.push(fn(x));
return out;
};
map((x) => x * 2, [1, 2, 3]); // [2, 4, 6]
Array.prototype.map, ровно то же самое. Посмотрим на свой filter и
reduce:
const filter = (pred, xs) => {
const out = [];
for (const x of xs) if (pred(x)) out.push(x);
return out;
};
const reduce = (fn, init, xs) => {
let acc = init;
for (const x of xs) acc = fn(acc, x);
return acc;
};
filter((x) => x > 1, [1, 2, 3]); // [2, 3]
reduce((acc, x) => acc + x, 0, [1, 2, 3]); // 6
reduce, родитель остальных. Через него выражается и map, и
filter:
const map = (fn, xs) => xs.reduce((acc, x) => [...acc, fn(x)], []);
const filter = (pred, xs) => xs.reduce((acc, x) => (pred(x) ? [...acc, x] : acc), []);
Это академическое доказательство; в проде используем встроенные методы массива, они оптимизированы.
2. Callbacks
Callback, любая функция, переданная в HOF для вызова позже. Бывают два контракта.
2.1. Sync callback
Просто функция. Вызывается синхронно. Не имеет специальных правил:
[1, 2, 3].map((x) => x * 2); // sync callback
sort((a, b) => a - b); // sync callback
2.2. Async callback с error-first контрактом
В Node до Promise/async-await принят error-first контракт:
fs.readFile('config.json', (err, data) => {
if (err) {
console.error('failed:', err);
return;
}
console.log('got data:', data.toString());
});
Первый аргумент, ошибка. Второй и далее, результат. Это позволяет конвертировать в Promise:
import { promisify } from 'node:util';
import { readFile } from 'node:fs';
const readFileP = promisify(readFile);
const data = await readFileP('config.json');
В новом коде сразу пиши node:fs/promises или node:fs.readFile через
util.promisify. Контракт error-first, важно знать, чтобы понимать,
почему сторонняя библиотека на колбеках работает так, как работает.
2.3. Callback hell
Цепочки колбеков превращаются в “пирамиду рока”:
fs.readFile('a.json', (err, a) => {
if (err) return done(err);
fs.readFile('b.json', (err, b) => {
if (err) return done(err);
fs.readFile('c.json', (err, c) => {
if (err) return done(err);
done(null, [a, b, c]);
});
});
});
С async/await это:
const [a, b, c] = await Promise.all([
readFile('a.json'),
readFile('b.json'),
readFile('c.json'),
]);
Подробно про асинхронность в разделе 05-async. В этом уроке callbacks нужны как контракт для HOF и wrapper-функций.
3. Partial application и currying
Часто путают, важно различать.
3.1. Partial application
Зафиксировать часть аргументов, получить функцию с меньшим числом
параметров. В JS это делает Function.prototype.bind:
const log = (level, tag, message) => console.log(`[${level}][${tag}] ${message}`);
const errorLog = log.bind(null, 'ERROR'); // partial: level фиксирован
errorLog('auth', 'login failed'); // [ERROR][auth] login failed
const errorAuthLog = log.bind(null, 'ERROR', 'auth'); // partial: ещё фиксируем tag
errorAuthLog('login failed'); // [ERROR][auth] login failed
Своя partial:
const partial = (fn, ...args) => (...rest) => fn(...args, ...rest);
const errorLog = partial(log, 'ERROR');
errorLog('auth', 'login failed');
3.2. Currying
Превратить функцию (a, b, c) => result в цепочку
a => b => c => result. То есть после каждого аргумента возвращается
новая функция, ждущая следующий.
// классический curry
const curry = (fn) => {
const arity = fn.length;
return function curried(...args) {
if (args.length >= arity) return fn(...args);
return (...rest) => curried(...args, ...rest);
};
};
const add3 = curry((a, b, c) => a + b + c);
add3(1)(2)(3); // 6
add3(1, 2)(3); // 6
add3(1)(2, 3); // 6
add3(1, 2, 3); // 6
Разница между partial и curry: partial фиксирует фиксированный набор аргументов слева, curry создаёт цепочку, которую можно прерывать в любом месте.
В реальном JS-коде curry используют редко: чистый partial или просто
arrow-фабрика часто читаются понятнее. Curry начинает выигрывать в
сочетании с pipe (см. ниже): функция в pipe должна принимать ровно
один аргумент, и curry приводит сигнатуры к этому виду.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.