Раздел 3 · JavaScript

ФП-стиль в JavaScript

middle~45 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

ФП-стиль в 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.