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

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)


  1. ponikrf
    12.08.2024 10:12
    +3

    Больше та и сказать нечего.

    Hidden text


    1. Serj_Pashnin Автор
      12.08.2024 10:12

      :-) классика


  1. meonsou
    12.08.2024 10:12
    +1

    Выкладывать примеры которые не тайпчекаются в статье про "Реализацию на TypeScript" это такое себе конечно. Что за тип FNхотя бы? Было бы неплохо рассмотреть как в таких функциях нормальный вывод типов сделать ещё (спойлер: в общем случае никак, но с некоторыми ограничениями можно).


    1. Serj_Pashnin Автор
      12.08.2024 10:12

      Спасибо за ваши замечания!
      Действительно, FN в статье используется без явного определения. Внесу правку в статью.

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


    1. Alexandroppolus
      12.08.2024 10:12

      В общем случае сделать, наверно, можно (почти допиленный вариант, без поддержки некоторых кейсов, например без необязательных элементов кортежа), но без нормального автокомплита/саджеста, только тайпчекинг. Использован прием с проверкой аргумента генерика, не помню как называется, там какая-то аббревиатура.


      1. 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(,,,))`.


        1. Alexandroppolus
          12.08.2024 10:12

          через хардкод N-ого числа аргументов

          Это позволяет решить проблему с генериками?

          И, кстати, в этих вариантах нельзя воткнуть произвольный массив функций (константа arr в моем наброске). Хотя это наверно редкий кейс.


          1. meonsou
            12.08.2024 10:12

            Это позволяет решить проблему с генериками?

            Да, ту которую я выше привёл позволяет. + Вывод типов аргументов работает и их не надо хардкодить.

            Можно ещё кстати такие штуки городить например.


  1. cmyser
    12.08.2024 10:12

    Такой код сложнее поддерживать и дебажить

    Будет ошибка допустим во 2 функции, и вам нужно ее результат вывести, с таким подходом это не получится


    1. Serj_Pashnin Автор
      12.08.2024 10:12

      Спасибо за ваш комментарий!

      Ваше замечание о поддержке и отладке важно. В случае ошибки, например, во 2ой функции, может быть сложно получить промежуточный результат и определить, на каком этапе произошла ошибка. Хотя в традиционном подходе с последовательными и вложенными вызовами функций может показаться, что отладка проще, на практике это может быть сложно при глубокой вложенности, так как требует ручного вмешательства на каждом уровне.

      Для улучшения отладки при использовании пайплайнинга и композиции можно применять такие техники, как логирование промежуточных результатов (например, с помощью функции tap ) и создание модульных тестов для проверки отдельных шагов процесса.

      Тем не менее, эти темы выходят за рамки данной статьи. Основная цель статьи — продемонстрировать подходы, которые улучшают читаемость кода.