Функциональное программирование

Зачем нам это нужно?

Функциональное программирование (ФП) предлагает иной взгляд на построение приложений. Вместо описания как выполнять задачу шаг за шагом, мы описываем что хотим получить, комбинируя небольшие чистые функции.

Преимущества подхода

  1. Декларативность и читаемость Код превращается в описание потока данных. Вместо вложенных циклов и условных операторов мы видим линейную последовательность действий: pipe(getData, processData, saveResult).

  2. Ленивые вычисления Создание задачи не означает её немедленное выполнение. Мы можем конструировать сложные цепочки логики, передавать их как данные и запускать только тогда, когда это действительно необходимо.

  3. Чистота и Тестируемость Разбиение бизнес-логики на маленькие изолированные функции позволяет тестировать каждый “кирпичик” отдельно. Функции не зависят от глобального состояния, что делает их поведение предсказуемым.

  4. Railway Oriented Programming (ROP) Обработка ошибок становится частью потока. Ошибка на любом этапе автоматически “проскакивает” последующие шаги обработки и попадает в обработчик ошибок, избавляя нас от бесконечных try/catch блоков в каждой функции.

  5. Модульность и Tree Shaking Программы собираются из переиспользуемых блоков. Логика пишется один раз и используется везде. Это не только соблюдает принцип DRY, но и позволяет сборщикам (Webpack, Vite) эффективно удалять неиспользуемый код, уменьшая размер бандла, передаваемого по сети.

Базовые блоки

Для начала определим минимальный набор “атомов” — простейших функций. Именно из таких простых блоков получаются сложные программы путем их складывания в композицию.

Ничего

Иногда нам нужна функция, которая гарантированно ничего не делает (например, как значение по умолчанию для необязательного callback-а).

1
const noop = () => {};

Identity

Классическая функция идентичности (Identity). Она просто возвращает то, что ей дали. Это пригодится нам, чтобы “вытаскивать” значения из контейнеров или использовать как заглушку там, где требуется трансформация данных, но мы не хотим их менять.

1
const idFn = (a) => a;

Инверсия

Обычно мы вызываем функцию с аргументом: f(x). А эта функция делает наоборот: она принимает аргумент arg и функцию fn, а затем применяет эту функцию к аргументу. Это “Самая важная” функция, так как она позволяет отложить выполнение.

1
const cont = (arg, fn) => fn(arg);

Каррирование

Чтобы строить цепочки, функции должны принимать аргументы по одному. Вот простая реализация каррирования для функции двух аргументов. Она позволяет превратить f(a, b) в f(a)(b).

1
const curry2 = (fn) => firstArg => secondArg => fn(firstArg, secondArg);

Задача

Если мы каррируем нашу функцию инверсии (cont), мы получим нечто магическое. Мы получаем функцию, которая “запоминает” первый аргумент (значение), но не вычисляет результат, пока ей не передадут второй аргумент (функцию-обработчик).

Кот Шрёдингера: метафора неопределенности и отложенного вычисления
Кот Шрёдингера: метафора неопределенности и отложенного вычисления

Назовем эту конструкцию “Задачей” (task). Это контейнер для ленивого вычисления.

1
const task = curry2(cont);

Последовательное исполнение

Магия начинается, когда результат одной задачи становится входными данными для создания следующей. Здесь важно различать два типа функций, которые мы используем в цепочке:

  1. Трансформеры (например, plusOne). Они принимают результат предыдущего шага, модифицируют его и возвращают новую задачу (return task(...)). Благодаря этому цепочка не прерывается — следующая функция получит результат через механизм внутри task.

  2. Функции вывода (например, idFn). Они завершают процесс. Принимают значение и возвращают его напрямую, без обертки task. Это останавливает магию и выбрасывает готовый результат наружу.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
const plusOne = (previousResult) => {
  // Трансформер: возвращает новую задачу
  return task(previousResult + 1);
}

// 1. task(1)   -> создает задачу с 1.
// 2. (plusOne) -> Трансформер: получает 1, возвращает задачу с 2.
// 3. (plusOne) -> Трансформер: получает 2, возвращает задачу с 3.
// 4. (idFn)    -> Вывод: получает 3, возвращает 3.
const result = task(1)(plusOne)(plusOne)(idFn)

На выходе будет 3.

Хотя такой механизм работает, писать бесконечные скобки (plusOne)(plusOne)... неудобно и сложно читать.

Строим конвейер (Pipe)

Чтобы сделать код чище, нам нужен инструмент, который будет “склеивать” эти вызовы за нас. Напишем функцию pipe (труба/конвейер), которая берет список функций и прогоняет данные через них последовательно.

1
2
3
4
5
6
7
8
9
const pipe = (...fnList) => {
  return (...args) => {
    let newArgs = fnList.shift()(...args);
    for (let i = 0; i < fnList.length; i++) {
      newArgs = fnList[i](newArgs);
    }
    return newArgs;
  }
}

Казалось бы, теперь можно просто перечислить наши функции в порядке выполнения.

1
2
3
4
5
pipe(
  task,    // 1. Создаем задачу
  plusOne, // 2. Прибавляем единицу
  idFn     // 3. Забираем результат
)(1)

Но тут кроется ловушка. Если запустить этот код, результат будет некорректным.

Давайте разберемся почему. Функция pipe работает прямолинейно: она берет результат предыдущей функции и передает его как аргумент в следующую.

  1. Шаг 1: Создание задачи task(1) возвращает (fn) => fn(1) Мы получили функцию-контейнер вместо числа.

  2. Шаг 2: Передача в трубу pipe передает эту функцию как аргумент в plusOne. Труба просто перекладывает результат из одного места в другое.

  3. Шаг 3: Конфликт plusOne(Задача) пытается выполнить Задача + 1 Функция ожидает число, но получает другую функцию. Логика ломается.

Возникает конфликт типов: plusOne не умеет работать с функциями-контейнерами.

Нам нужен еще один слой преобразований. Нам нужна функция-обертка, которая примет Задачу как аргумент и применит plusOne к значению внутри неё.

Вспомним, как мы определяли task — это каррированная инверсия. Если вызвать task(plusOne), мы получим функцию, которая ожидает свой второй аргумент. Этим аргументом и станет Задача, пришедшая по конвейеру.

В итоге вызов task(plusOne)(Задача) превратится в Задача(plusOne), что и требовалось для запуска вычислений.

Здесь происходит настоящая магия симметрии. Мы используем одну и ту же функцию task для двух, казалось бы, разных целей. Вспомним формулу: arg1 => arg2 => arg2(arg1). Ей абсолютно всё равно, что мы передаем первым.

  1. Конструктор (сначала данные): task(Data) возвращает (fn) => fn(Data) Создает контейнер, который ждет обработчик.

  2. Адаптер (сначала функция): task(Function) возвращает (Task) => Task(Function) Создает обертку, которая ждет контейнер.

Если “схлопнуть” их вместе, получается идеальная формула запуска:

1
2
// Адаптер( Контейнер )
task(Function)( task(Data) ) === Function(Data)

Для любознательных: Птицы и комбинаторы

То, что мы использовали, в комбинаторной логике известно как Комбинатор T (или Thrush — Дрозд, в терминологии логика Рэймонда Смаллиана). Его суть проста: T x f = f x.

Это “переворачиватель”: он берет значение x, потом функцию f, и применяет функцию к значению. Именно это свойство позволяет нам писать код в стиле data |> function (передача данных в трубу), что является основой многих функциональных языков (F#, Elixir). В нашем случае task — это и есть этот самый Дрозд.

Один маленький “кирпичик” решает сразу все проблемы композиции.

Поэтому мы оборачиваем каждый шаг:

1
2
3
4
5
6
const sameResult = pipe(
  task,
  task(plusOne),
  task(plusOne),
  task(idFn)
)(1)

Инструменты для работы с синхронным кодом

Ранее использованная функция вывода idFn не предусматривала механизма обработки ошибок. Для реализации паттерна Railway Oriented Programming (ROP) необходимы инструменты, поддерживающие разделение потока выполнения на “успех” (resolve) и “ошибку” (reject).

Критической задачей также является обработка исключений. В синхронном коде любая функция может выбросить исключение (throw). Для сохранения целостности конвейера такие исключения должны быть автоматически перехвачены и преобразованы в сигнал reject. Это обеспечивает передачу ошибки напрямую в конечный обработчик, минуя последующие этапы трансформации, что предотвращает аварийную остановку программы.

Запуск (Fork)

taskFork выполняет две ключевые роли:

  1. Инициализация: Запускает ленивую цепочку вычислений.
  2. Определение контракта: Именно эта функция задает сигнатуру, с которой будут работать все внутренние функции цепочки.

Важно понимать, что формат аргументов (resolve, reject) не является жестким стандартом ФП. Мы могли бы передавать единый объект контекста или три аргумента (например, добавив onProgress). Выбор текущей сигнатуры (два позиционных аргумента) продиктован исключительно стремлением к минимизации размера кода и схожестью с паттерном Promise. Архитектура позволяет определить произвольный интерфейс взаимодействия, изменив только функцию вывода.

Реализация также включает блок try/catch, который перехватывает синхронные исключения в цепочке и перенаправляет их в канал reject.

1
2
3
4
5
6
7
const taskFork = (resolve, reject = noop) => fn => {
  try {
    return fn(resolve, reject);
  } catch (e) {
    return reject(e);
  }
};

Инициализация (Of)

Функция taskOf приводит произвольное значение или результат выполнения функции к интерфейсу Task. Она создает функцию-обертку, принимающую resolve и reject. Это позволяет использовать синхронные значения в едином потоке композиции.

1
2
const taskOf = (fn) => (...args) => 
  task((resolve, reject) => resolve(fn(...args)));

Трансформация (Map)

taskMap применяет функцию трансформации к значению, находящемуся в канале resolve.

Отличие от taskOf: Хотя обе функции возвращают Task, они различаются входными данными и назначением:

  1. taskOf (Lifting/Поднятие): Принимает “сырое” значение (или функцию, возвращающую его) и оборачивает в контекст Task. Является источником цепочки.

    • Вход: Data
    • Выход: Task(Data)
  2. taskMap (Mapping/Отображение): Принимает функцию трансформации и ожидает на вход существующий Task. Распаковывает контейнер, применяет функцию к значению и запаковывает результат обратно. Является звеном цепочки.

    • Вход: (Data -> NewData) и Task(Data)
    • Выход: Task(NewData)

Логика выполнения:

  1. Если предыдущая задача (prevTask) завершилась успешно (resolve), значение передается в mapFn, результат оборачивается в новую задачу.

  2. Если предыдущая задача вернула ошибку (reject), mapFn игнорируется, ошибка передается дальше без изменений.

1
2
3
const taskMap = curry2((mapFn, fn) => task((resolve, reject) =>
  fn((result) => resolve(mapFn(result)), reject)
))

Пример: FizzBuzz с обработкой ошибок

Демонстрация прохождения данных через цепочку, трансформации и прерывания потока при ошибке.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const returnFizz = idFn.bind(null, 'Fizz');

// 1. Инициализация (taskOf)
// Создание задачи с начальным значением
const getFizzTask = taskOf(returnFizz);

// 2. Трансформация (taskMap)
// Модификация успешного результата
const addBuzz = taskMap((prev) => prev + 'Buzz');

// 3. Симуляция ошибки (taskMap)
// Выброс исключения прерывает цепочку успешных вычислений
const throwTask = taskMap(() => {
  throw new Error('Сбой системы');
});

// Композиция
const result = pipe(
  getFizzTask,     // -> Task("Fizz")
  task(throwTask), // -> Error("Сбой системы")
  task(addBuzz),   // -> Пропущено (так как предыдущий шаг вернул ошибку)
  
  // 4. Запуск (taskFork)
  // Обработка конечного результата или перехваченной ошибки
  task(taskFork(
    (res) => console.log('Result:', res), 
    (err) => console.log('Error:', err.message)
  ))
)();

// Output: "Error: Сбой системы"

Инструменты для работы с асинхронным кодом

В реальных приложениях операции не ограничиваются синхронными вычислениями. Часто требуется взаимодействие с внешними системами: запросы к базам данных, вызовы API или работа с таймерами.

Если результат выполнения задачи представляет собой Promise (или иную асинхронную абстракцию), прямая передача результата в следующую функцию невозможна. Необходимо дождаться разрешения (resolution) асинхронной операции, извлечь полученное значение и лишь затем передать его далее по цепочке.

Для решения этой задачи используется функция Chain (цепь).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
const taskChain = curry2((chainFn, fn) => 
  task((resolve, reject) =>
    fn((result) => {
      try {
        chainFn(result)(taskFork(resolve, reject));
      } catch (e) {
        reject(e);
      }
    }, reject)
  )
);

В отличие от taskMap, которая применяет трансформацию к значению ((a -> b)), taskChain ожидает функцию, возвращающую новую Задачу ((a -> Task(b))). Это позволяет выстраивать последовательности, где каждый этап может содержать асинхронную логику.

Пример: Таймер

Рассмотрим функцию, которая выполняет задержку выполнения на 500 мс перед передачей управления. Она принимает значение и возвращает новую задачу (Task).

1
2
3
4
5
6
const delay500 = (previousResult) => task((resolve, reject) => {
    const timerId = setTimeout(() => {
      resolve(previousResult);
    }, 500);
    return timerId; 
});

Обратите внимание, что мы возвращаем timerId. Это значение может быть использовано в дальнейшем для реализации механики отмены (cancellation), хотя в текущем примере оно не задействовано.

Теперь функцию delay500 можно интегрировать в общий конвейер обработки. Поскольку она возвращает Task, для композиции необходимо использовать taskChain. Предварительно мы применяем каррирование к taskChain, чтобы превратить нашу функцию в совместимый трансформер.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
const wait500ms = taskChain(delay500);

const program = pipe(
  getFizzTask,
  wait500ms,
  task(addBuzz),
  
  task(taskFork(
    (res) => console.log('Final:', res),
    (err) => console.error('Error:', err)
  ))
);

program(); 

После запуска program() выполнение приостановится на 500 мс, после чего в консоль будет выведено сообщение Final: FizzBuzz.

Проблематика асинхронных ошибок

При переходе к асинхронной модели выполнения мы сталкиваемся с фундаментальным ограничением механизма обработки исключений в JavaScript.

Стандартная конструкция try/catch работает синхронно и базируется на текущем стеке вызовов (Call Stack). Когда мы инициируем асинхронную операцию (например, через setTimeout), выполнение функции taskFork завершается, и стек очищается.

1
2
3
4
5
const asyncDangerous = task((resolve, reject) => {
  setTimeout(() => {
    throw new Error('Ошибка в будущем');
  }, 100);
});

В момент срабатывания таймера браузер помещает колбэк в очередь событий (Event Queue), откуда он попадает в новый, чистый стек вызовов. Контекст try/catch, который был создан в момент инициализации задачи, к этому времени уже уничтожен.

В результате выброс исключения (throw) внутри асинхронного колбэка приводит к Uncaught Exception, так как ошибка всплывает до глобального объекта window/process, не встречая на своем пути обработчиков. Это архитектурное ограничение, которое невозможно обойти средствами синхронного перехвата.

Решение: Для сохранения целостности потока управления необходимо отказаться от throw в пользу явной передачи сигнала ошибки через канал reject. Это позволяет пробросить ошибку в следующую функцию в цепочке (или в catch блок Promise), восстанавливая связность логики.

1
2
3
4
5
const asyncSafe = task((resolve, reject) => {
  setTimeout(() => {
    reject(new Error('Контролируемый сбой'));
  }, 100);
});

Декомпозиция против Сложности

Попытка решить проблему «в лоб», добавляя проверки и try/catch внутрь каждого асинхронного вызова, ведет к разрастанию кода. Бизнес-логика, управление временем и обработка ошибок смешиваются в единый монолит, который сложно поддерживать и тестировать.

В функциональном программировании принят другой подход: декомпозиция. Сложная проблема разбивается на простые, независимые примитивы.

Пример: безопасный Таймер

Дзен-кот: ожидание как спокойная, контролируемая операция
Дзен-кот: ожидание как спокойная, контролируемая операция

Управление временем выносится в отдельную утилиту:

1
2
const timer = curry2((ms, arg) => 
    task((resolve) => setTimeout(() => resolve(arg), ms)));

Важно понимать: timer здесь искуственный пример. В реальном приложении на его месте будет запрос к базе данных, обращение к API, чтение файла или другая операция ввода-вывода.

Часто такой код приходит из сторонних библиотек, поведение которых мы не контролируем. Мы не можем заставить авторов библиотеки не использовать throw. Именно поэтому умение безопасно “оборачивать” и обрабатывать такие неконтролируемые исключения — критически важный навык.

Эта функция timer делает только одну вещь: ждет, пробрасывая полученные данные дальше.

Теперь ожидание и опасное вычисление комбинируются через taskChain:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
const dangerousAction = (arg) => task(() => {
  throw new Error('Ошибка внутри цепочки');
});

const program = pipe(
  getFizzTask,
  taskChain(timer(100)),
  taskChain(dangerousAction),
  task(taskFork(console.log, console.error))
);

program();

Такой подход дает ряд преимуществ:

  1. Чистота: Код не загрязняется ручными проверками.

  2. Безопасность: taskChain автоматически запускает следующий шаг через taskFork, который уже содержит защиту от исключений.

  3. Переиспользуемость: Функция timer теперь доступна для любой части приложения, а не зашита внутрь конкретной бизнес-задачи.

Сложное и безопасное поведение возникает как побочный эффект правильной композиции простых функций.

Мост к Promise

Для интеграции с существующим кодом, использующим Promise (например, fetch), удобно иметь утилиту-переходник:

1
2
3
4
const fromPromise = (promise) =>
    task((resolve, reject) => {
        promise.then(resolve, reject);
    });

Недостатки и сложности

Несмотря на красоту подхода, у него есть и обратная сторона, особенно в экосистеме JavaScript.

  1. Высокий порог входа Для разработчиков, привыкших к императивному стилю, понятия каррирования, монад и композиции могут показаться сложными и чужеродными.

  2. Сложность отладки Стек вызовов (Stack Trace) часто забивается служебными функциями (compose, curry), скрывая реальное место ошибки. Пошаговая отладка также усложняется необходимостью проходить через множество функций-оберток.

  3. Производительность Создание большого количества замыканий и функций-оберток создает дополнительную нагрузку на память и сборщик мусора по сравнению с простым императивным кодом.

  4. Сложность типизации Написать строгие типы для функций композиции (pipe) и каррированных функций в TypeScript — нетривиальная задача, часто требующая сложной перегрузки функций.

  5. Нативные альтернативы Современный JavaScript (ES6+) предоставляет мощные встроенные инструменты (Promise, async/await), которые решают большинство задач асинхронности проще и эффективнее, чем самописные структуры Task.