FP: преобразования и граница эффектов
Задача: отделить преобразование расписания от его загрузки и проверить результат без 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(). Почему отсутствие мутации ещё не доказывает чистоту?