Как часто вам приходилось видеть что-то подобное в коде?
const result = fnD(fnC(fnB(fnA(...))));
Чтобы получить результат, нужно последовательно выполнить каждую функцию, начиная с самой внутренней. Это требует визуального "разворачивания" функций, что усложняет понимание логики кода. Когда мы сталкиваемся с таким кодом, то сразу осознаем, что его чтение и поддержка могут стать настоящим испытанием.
В этой статье мы рассмотрим, как можно значительно улучшить читаемость кода с помощью двух мощных техник: пайплайнинга (pipelining) и композиции функций (composing). Мы будем использовать TypeScript для демонстрации этих подходов.
Начнем с условного примера, который иллюстрирует, как вложенные вызовы могут затруднить восприятие кода:
const add = (a: number, b: number) => a + b;
const square = (x: number) => x * x;
const half = (x: number) => x / 2;
const result = half(square(add(2, 2))); // 8
В приведенном примере функции вложены друг в друга, что может быстро привести к сложностям в управлении кодом. Каждый новый шаг в обработке данных усложняет восприятие результата и последовательности функций.
Pipelining
Pipelining — это техника передачи данных через цепочку операций, имеющая корни в математической теории. Результат одной функции передаётся следующей функции, формируя непрерывный поток обработки данных.
Следует учесть, что при объединении нескольких функций в pipeline тип вывода одной функции должен совпадать с типом параметра следующей функции. Создадим вспомогательный тип FnsTypeCheck, который будет проверять, соответствуют ли два типа требуемому условию.
type FN<T = any> = (...args: T[]) => any;
type FnsTypeCheck<FNS extends FN[]> =
1 extends FNS["length"]
? boolean
: FNS extends [
infer FN1st extends FN,
infer FN2nd extends FN,
...infer FNRest extends FN[]
]
? Parameters<FN2nd> extends[ReturnType<FN1st>]
? FnsTypeCheck<[FN2nd, ...FNRest]>
: never
: never;
Выше приведенный код выполняется рекурсивно. Если в pipline передана только одна функция, то возвращается boolean, указывающий на успешную проверку типов. Если функций больше, сначала проверяется, совпадает ли тип параметра второй функции с типом возвращаемого значения первой функции. Затем рекурсивно проверяется соответствие типов для всех последующих функций. В случае несоответствия типов возвращается never, обозначающее ошибку.
Теперь приступим к написанию типа Pipeline:
type Pipeline<FNS extends FN[]> =
boolean extends FnsTypeCheck<FNS>
? 1 extends FNS["length"]
? FNS[0]
: FNS extends [
infer FNFIRST extends FN,
...FN[],
infer FNLAST extends FN
]
? (...args: Parameters<FNFIRST>) =>
ReturnType<FNLAST>
: never
: never;
Сначала мы проверяем корректность типов функций с помощью FnsTypeCheck. Если все типы совпадают, тип pipeline будет корректным. Он будет соответствовать функции, которая принимает аргументы того же типа, что и первая функция в pipeline. Возвращаемое значение будет иметь тот же тип, что и результат последней функции.
Что бы TypeScript смог правильно определить типы используем технику перегрузки.
Реализация c использованием замыкания:
function pipeline<FNS extends FN[]>(...fns: FNS): Pipeline<FNS>;
function pipeline<FNS extends FN[]>(...fns: FNS): FN {
return (...args: Parameters<FNS[0]>) => {
let result = fns[0](...args);
for (let i = 1; i < fns.length; i++) {
result = fns[i](result);
}
return result;
};
}
pipeline(
add,
square,
half
)(2, 2); // 8
Как мы видим, использование pipeline облегчает читаемость кода, т.к. создает линейный поток данных и благодаря этому, устраняет сложную вложенность вызовов функций.
Напишем pipeline с использованием метода reduce:
function pipeline<FNS extends FN[]>(...fns: FNS): Pipeline<FNS>;
function pipeline<FNS extends FN[]>(...fns: FNS) {
return fns.reduce(
(prevFn: FN, nextFn: FN) =>
(...args: Parameters<FNS[0]>) =>
nextFn(prevFn(...args))
);
}
Применение метода reduce позволяет написать более элегантную реализацию pipeline.
Composing
Composing, как и pipelining, имеет корни в математической теории.
Это последовательность вызовов функций, в которой выходные данные одной функции становятся входными данными для следующей функции, но в порядке, противоположном тому, который используется в pipelining.
Опираясь на подход, использованный при написании pipeline, типизация данных для compose будет во многом аналогична. Сначала создадим вспомогательный тип ComposeFnsTypeCheck, который проверит правильность сочетания наших функций.
type ComposeFnsTypeCheck<FNS extends FN[]> =
1 extends FNS["length"]
? boolean
: FNS extends [
...infer FNInit extends FN[],
infer FNPrev extends FN,
infer FNLast extends FN
]
? Parameters<FNPrev> extends [ReturnType<FNLast>]
? ComposeFnsTypeCheck<[...FNInit, FNPrev]>
: never
: never;
Это практически то же самое, что мы сделали для pipeline, за исключением того, что функции обрабатываются в обратном порядке — справа налево. Теперь, когда это завершено, мы можем приступить к написанию типа Compose.
type Compose<FNS extends FN[]> =
boolean extends ComposeFnsTypeCheck<FNS>
? 1 extends FNS["length"]
? FNS[0]
: FNS extends [
infer FNFIRST extends FN,
...FN[],
infer FNLAST extends FN
]
? (...args: Parameters<FNLAST>) =>
ReturnType<FNFIRST>
: never
: never;
Теперь мы можем применить написанные типы к compose.
Реализация c использованием замыкания:
function compose<FNS extends FN[]>(...fns: FNS): Compose<FNS>;
function compose<FNS extends FN[]>(...fns: FNS): FN {
return (...args: Parameters<FNS[0]>) => {
let result = fns[fns.length - 1](...args);
for (let i = fns.length - 2; i >= 0; i--) {
result = fns[i](result);
}
return result;
};
}
Реализация с использованием pipeline:
function compose<FNS extends FN[]>(...fns: FNS): Compose<FNS>;
function compose<FNS extends FN[]>(...fns: FNS) {
return pipeline(...fns.reverse());
}
compose(
half,
square,
add
)(2, 2); // 8
Заключение:
В статье мы рассмотрели две техники — pipelining и composing.
Pipelining и composing упрощают код, создавая линейные потоки данных, что облегчает понимание последовательности операций и устраняют сложную вложенность вызовов функций.
В pipeline() данные проходят через функции последовательно — от первой к последней,
а в compose() функции применяются в обратном порядке — от последней к первой.
Оба подхода способствуют созданию более ясной структуры кода, что значительно улучшает его читаемость.
Использование типов Pipeline и Compose обеспечивает дополнительный уровень проверки типов.
Комментарии (10)
meonsou
12.08.2024 10:12+1Выкладывать примеры которые не тайпчекаются в статье про "Реализацию на TypeScript" это такое себе конечно. Что за тип
FN
хотя бы? Было бы неплохо рассмотреть как в таких функциях нормальный вывод типов сделать ещё (спойлер: в общем случае никак, но с некоторыми ограничениями можно).Serj_Pashnin Автор
12.08.2024 10:12Спасибо за ваши замечания!
Действительно, FN в статье используется без явного определения. Внесу правку в статью.
Что касается нормального вывода типов, тут вы так же абсолютно правы. В ближайшие пару дней внесу дополнения в статью.
Alexandroppolus
12.08.2024 10:12В общем случае сделать, наверно, можно (почти допиленный вариант, без поддержки некоторых кейсов, например без необязательных элементов кортежа), но без нормального автокомплита/саджеста, только тайпчекинг. Использован прием с проверкой аргумента генерика, не помню как называется, там какая-то аббревиатура.
meonsou
12.08.2024 10:12К сожалению такие манипуляции как минимум не прокатывают на женериках, что является существенным ограничением в плане тайпчекинга.
const square = (x: number) => x * x const double = (x: number) => x * 2 const id = <T,>(x: T) => x const r = pipeline(square, id, double) // Error
Щас все в основном реализуют подобный функционал через хардкод N-ого числа аргументов (effect, fp-ts, ramda, remeda), потому что это накладывает наименьше ограничения. Обычно делают штук 30 и в итоге мало кому надо столько в принципе, а если и надо можно просто сделать `pipeline(..., pipeline(,,,))`.
Alexandroppolus
12.08.2024 10:12через хардкод N-ого числа аргументов
Это позволяет решить проблему с генериками?
И, кстати, в этих вариантах нельзя воткнуть произвольный массив функций (константа arr в моем наброске). Хотя это наверно редкий кейс.
meonsou
12.08.2024 10:12Это позволяет решить проблему с генериками?
Да, ту которую я выше привёл позволяет. + Вывод типов аргументов работает и их не надо хардкодить.
Можно ещё кстати такие штуки городить например.
cmyser
12.08.2024 10:12Такой код сложнее поддерживать и дебажить
Будет ошибка допустим во 2 функции, и вам нужно ее результат вывести, с таким подходом это не получится
Serj_Pashnin Автор
12.08.2024 10:12Спасибо за ваш комментарий!
Ваше замечание о поддержке и отладке важно. В случае ошибки, например, во 2ой функции, может быть сложно получить промежуточный результат и определить, на каком этапе произошла ошибка. Хотя в традиционном подходе с последовательными и вложенными вызовами функций может показаться, что отладка проще, на практике это может быть сложно при глубокой вложенности, так как требует ручного вмешательства на каждом уровне.Для улучшения отладки при использовании пайплайнинга и композиции можно применять такие техники, как логирование промежуточных результатов (например, с помощью функции tap ) и создание модульных тестов для проверки отдельных шагов процесса.
Тем не менее, эти темы выходят за рамки данной статьи. Основная цель статьи — продемонстрировать подходы, которые улучшают читаемость кода.
ponikrf
Больше та и сказать нечего.
Hidden text
Serj_Pashnin Автор
:-) классика