Раздел 2 · Программирование / CS

Композиция и итерация без циклов

middle~40 мин

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

Композиция и итерация без циклов

Урок 04 закончил тем, что у нас есть чистые функции и привычка возвращать новые значения, а не мутировать. Хорошо, но изолированная чистая функция, ещё не программа. Программа собирается, когда мы начинаем их склеивать.

Сегодня мы возьмём g(f(x)) из первой секции прошлого урока и развернём в читаемую форму. Покажем pipe и compose, разберём каррирование, рассмотрим point-free стиль и equational reasoning. В финале применим всё к итерации, увидим, что map, filter и reduce, та же композиция, только над списком.

От вложенных вызовов к pipe

В прошлом уроке встречалась запись:

const result = transform(parse(read(input)));

Композиция уже здесь, но читается она “наизнанку”: сначала смотрим в центр, потом разворачиваемся наружу. Удобнее перевернуть слева направо.

Стандартная функция pipe в fp-ts принимает значение и набор функций, и пропускает значение через них:

import { pipe } from 'fp-ts/function';

const result = pipe(input, read, parse, transform);

Та же программа, читается так же, как читается логика в голове: “взять input, прочитать, распарсить, преобразовать”. pipe свою работу делает за пять строк. Можно написать самому, без библиотеки:

const pipe2 = <A, B, C>(a: A, f: (a: A) => B, g: (b: B) => C): C =>
  g(f(a));

// общий вариант через reduce
const pipeN = <A>(a: A, ...fns: Array<(x: A) => A>): A =>
  fns.reduce((acc, fn) => fn(acc), a);

Сестра-функция compose отличается порядком: compose(g, f)(x) это g(f(x)), тот же самый вложенный порядок, только без скобок.

const compose =
  <A, B, C>(g: (b: B) => C, f: (a: A) => B) =>
  (a: A): C =>
    g(f(a));

const cleaned = compose(toLower, trim);
cleaned('  Hello  '); // 'hello'

pipe подаёт значение слева, compose собирает функцию, в которую значение можно подать потом. Обе делают одно и то же, выбирай по эргономике текущего кода.

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

pipe ждёт от каждой функции одну сигнатуру: (x: T) => U. Один аргумент на вход, один результат. Реальные функции часто принимают два или больше. Чтобы они стали “pipe-friendly”, применяют каррирование.

Из (a, b) -> c получаем a -> b -> c. Из (a, b, c) -> d, a -> b -> c -> d.

// обычная сигнатура
const add = (a: number, b: number) => a + b;

// каррированная
const addC = (a: number) => (b: number) => a + b;

addC(2)(3); // 5

// частичное применение
const inc = addC(1);
inc(41); // 42

addC(1), уже не число, а функция (b: number) => number. Это маленький, но важный сдвиг: функция, которой не хватает аргумента, всё ещё значение. Её можно класть в переменные, передавать в pipe, комбинировать.

const greet = (greeting: string) => (name: string) =>
  `${greeting}, ${name}!`;

const ru = greet('Привет');
const en = greet('Hello');

ru('Линус');  // 'Привет, Линус!'
en('Linus');  // 'Hello, Linus!'

Каррирование ради pipe: мы хотим заранее зафиксировать greeting, получить функцию name -> string, и положить её в цепочку. Без каррирования, пришлось бы оборачивать вручную.

Partial application и point-free

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

const log = (level: string, message: string) => `[${level}] ${message}`;

// каррированная форма, удобно
const logC = (level: string) => (message: string) => `[${level}] ${message}`;
const warn = logC('WARN');

// обычная форма, неудобно, но можно
const warn2 = log.bind(null, 'WARN');

Когда функции каррированы, появляется point-free стиль: функцию-обработчик собираем как композицию, не называя промежуточное значение.

import { pipe } from 'fp-ts/function';

const trim = (s: string) => s.trim();
const lower = (s: string) => s.toLowerCase();

// с явным аргументом
const normalize1 = (s: string) => lower(trim(s));

// point-free
const normalize2 = (s: string) => pipe(s, trim, lower);

Антипаттерн: пихать point-free туда, где он мешает читать. Если каждая функция в цепочке требует мысленной расшифровки, явный аргумент с именем работает лучше. Как и любой стиль, point-free, инструмент, не догма.

Equational reasoning

Чистые функции дают одно мощное свойство, которое мы называли в уроке 04, ссылочную прозрачность. На её основе работает equational reasoning: программу можно переписывать как алгебраическое выражение, подставляя значения вместо вызовов и обратно.

Дано:

const double = (n: number) => n * 2;
const addOne = (n: number) => n + 1;

const f = (n: number) => addOne(double(n));

f(3) мы можем разложить:

f(3)
= addOne(double(3))   подстановка тела f
= addOne(6)           вычисление double(3)
= 7                   вычисление addOne(6)

Это не магия, это школьная алгебра. Важно то, что мы можем идти в обе стороны. Если у нас в коде встречается addOne(double(x)) много раз, мы спокойно вытаскиваем это в f и подставляем f(x) обратно. Знаем, что поведение программы не изменится.

С нечистыми функциями этот фокус не проходит. Если double пишет в лог, заменить вызов значением мы уже не можем, потеряем запись в лог. Вот почему первые три столпа из урока 04 не косметика. Без чистоты теряется equational reasoning, а вместе с ним, спокойный рефакторинг.

дальше после входа

Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.

Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.