Учебник веб-разработки
Разделы учебника
На этой странице

II–III. JS, TypeScript и модели данных

FP: преобразования и граница эффектов

Оглавление · TypeScript

Задача: отделить преобразование расписания от его загрузки и проверить результат без CMS. Нужны функции, ссылки и типы. FP — функциональное программирование: строим вычисления из функций, явно передавая зависимости. В этой главе изучаем чистые преобразования; FRP и реактивность разобраны позже, в отдельной главе.

Когда функция похожа на отображение

Математическое f: A → B связывает вход с результатом. Для программы этого описания мало: вызов может прочитать часы, изменить чужой объект или отправить запрос. Назовём функцию чистой, если результат определяется явным входом, а вызов не создаёт наблюдаемых изменений за пределами вычисления.

type LessonEvent = { title: string; location: string };

function titlesAt(events: readonly LessonEvent[], location: string): string[] {
  return events.filter((event) => event.location === location).map((event) => event.title);
}

Функция не получает события из глобального хранилища и не меняет объекты входа. Повторный вызов на неизменившихся данных даёт тот же список строк. Сам объект результата каждый раз новый: речь о равенстве содержимого, а не о === массивов. Предполагаем обычные объекты данных без getters и Proxy.

const events: LessonEvent[] = [{ title: "Открытие", location: "Площадь" }];
function hiddenInput(): string[] {
  return events.map((event) => event.title);
}
events[0].title = "Начало";

У hiddenInput нет параметров, но результат зависит от изменяемого внешнего массива. Это скрытый вход. Время, случайность, переменные окружения и сеть — другие зависимости, которые важно увидеть. Иногда достаточно передать дату или настройки аргументом; сетевой запрос всё равно остаётся эффектом.

Композиция

Для f: A → B и g: B → C композиция g ∘ f переводит A в C. Промежуточное значение связывает этапы:

const trimTitle = (title: string): string => title.trim();
const makeLabel = (title: string): string => `Событие: ${title}`;
const label = (title: string): string => makeLabel(trimTitle(title));

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

Исходник схемы
flowchart LR
  Input["События и площадка"] --> Filter["filter: выбрать"]
  Filter --> Map["map: получить заголовки"]
  Map --> Output["Массив строк"]

Здесь стрелки означают передачу значений. filter меняет число элементов, map — их представление. Каждый этап можно проверить отдельно; совместная проверка подтверждает нужный результат всей цепочки.

Локальная мутация и наблюдаемое поведение

Чистота не требует создавать новый аккумулятор на каждом шаге. Можно изменять структуру, которая создана внутри вызова и не разделяется с внешним кодом:

function countByLocation(events: readonly LessonEvent[]): Map<string, number> {
  const counts = new Map<string, number>();
  for (const event of events) {
    counts.set(event.location, (counts.get(event.location) ?? 0) + 1);
  }
  return counts;
}

counts новый при каждом вызове. Функция не сохраняет его в глобальной переменной и не меняет вход. Возвращённый Map изменяем, но владение им передаётся вызывающему коду. Если сохранить и повторно вернуть один общий Map, изменение результата снаружи повлияет на последующие вызовы.

Реальная группировка расписания похожа на этот подход: создаёт Map дней, локальные группы и массивы элементов. Сортируется копия входного массива. Время форматируется в явно заданной Europe/Moscow, но конкретные строки зависят также от Intl-данных окружения. Поэтому здесь полезно отдельно проверять структуру и отдельно форматирование, а не утверждать полную независимость результата от среды.

Свёртка и законы имеют условия

reduce последовательно обновляет аккумулятор по каждому элементу. Это способ записать свёртку списка; обычный цикл иногда читается проще.

const count = [1, 2, 3].reduce((sum, value) => sum + value, 0); // 6

Если хотим независимо обработать части списка и объединить результаты, операции объединения нужны ассоциативность и нейтральный элемент. Для математических целых сложение подходит, нейтральный элемент — ноль. В JavaScript number округляется:

console.log(1e16 + -1e16 + 1); // 1
console.log(1e16 + (-1e16 + 1)); // 0

Это контрпример к свободной перегруппировке суммы number. Для перестановки частей дополнительно нужна коммутативность: конкатенация списков ассоциативна, но перестановка частей меняет порядок. Эти условия понадобятся для MapReduce; название метода reduce само по себе не делает вычисление пригодным для распределённого исполнения.

Эффекты на границе

Результат чтения CMS передаётся преобразованию, а не запрашивается из него:

Исходник схемы
flowchart LR
  Load["Эффект: загрузить контент"] --> Events["Проверенные события"]
  Events --> Core["Преобразовать расписание"]
  Core --> Model["Данные интерфейса"]
  Load --> Error["Ошибка источника"]

Схема показывает значения и исходы, не последовательность сетевых пакетов. В Atmanki загрузкой и кешированием занимается CMS-клиент, разбором — адаптер, представлением расписания — groupSchedule. Эти границы позволяют проверять преобразование на локальных данных и отдельно моделировать отказ CMS.

Эффекты необходимы приложению. Важно знать место их выполнения и политику ошибки. Заменять каждый цикл на map или запрещать любой push для этого не нужно.

Практика и самопроверка

Реализуйте учебную countByLocation. Проверьте пустой вход, две площадки и повторяющиеся события. Для входа из трёх событий с площадками Площадь, Шатёр, Площадь ожидаются пары Площадь → 2, Шатёр → 1.

Проверьте две дополнительные границы: после вызова вход не изменился; изменение возвращённого Map не меняет результат следующего вызова. Затем намеренно вынесите Map за функцию и покажите нарушение второй границы. Верните локальный аккумулятор.

Объясните, что надо сделать явным входом функции, которая выбирает «предстоящие события» через Date.now(). Почему отсутствие мутации ещё не доказывает чистоту?