Как стать автором
Обновить

Pipelining & Composing: улучшаем читаемость кода. Реализация на TypeScript

Время на прочтение4 мин
Количество просмотров2.6K

Как часто вам приходилось видеть что-то подобное в коде?

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 обеспечивает дополнительный уровень проверки типов.

Теги:
Хабы:
+5
Комментарии10

Публикации

Истории

Работа

Ближайшие события