Дело об абстракциях кода

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

Напоминаем, что асинхронность позволяет программистам делать много вещей «как бы в одно и то же время», т. е. прерывать выполнение в середине выполнения, чтобы делать что-то еще. Это становится особенно важным, если в программе есть много операций, блокируемых вводом-выводом (например, вызов удаленных служб, доступ к диску, ожидание ввода данных пользователем и т. д.).

Синтаксис async/await является относительно новым дополнением к основным языкам программирования. Это позволяет писать линейный код, который не будет выполняться линейно.

Типичное использование выглядит примерно так:

// Execution will possibly go elsewhere here
const nBoxes = await getNumberOfBoxes();
// Here too.
const nBunnies = await getNumberOfBunnies();
const message = nBoxes - nBunnies > 0
 ? 'we have enough boxes'
 : 'we don\'t have enough boxes';
console.log(message);

Два асинхронных вызова выполняются последовательно, но, поскольку они не имеют побочных эффектов (они доступны только для чтения), они могли бы произойти одновременно, что было бы значительно быстрее:

const [nBoxes, nBunnies] = await Promise.all([
 getNumberOfBoxes(),
 getNumberOfBunnies()
]);
const message = nBoxes - nBunnies > 0
 ? 'we have enough boxes'
 : 'we don\'t have enough boxes';
console.log(message);

Такая неэффективность распространена и часто выявляется при проверке кода.

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

Можем ли мы сделать лучше? Можем ли мы уменьшить шум и убедиться, что код работает оптимально?

Если мы посмотрим на графовое представление наших программ, то зависимости между узлами в этом графе подразумевают, что можно делать параллельно (интуитивно все что угодно в одном «слое»).

В этом представлении отнимается несемантическая степень свободы; мы больше не можем применять к коду преобразование «перестановка строк».

Но иногда управление await является семантическим. Возьмем, к примеру, этот случай:

await createUser(data)
const users = await getUsers()
return users

Это не будет эквивалентно следующему:

const [_, users] = await Promise.all([createUser(data), getUsers()])
return users

У асинхронных вызовов есть побочные эффекты, что означает, что порядок await важен, даже если зависимости не подразумевают это явно.

Но в большинстве случаев позволить программам управлять как await, так и порядком выполнения — это путь к проблемам или, по крайней мере, некоторые требуют дополнительных усилий. Это просто не тот уровень абстракции. Мы добавим эти операторы, чтобы код «просто работал».

В некотором смысле асинхронность похожа на манипулирование указателями. Раньше мы управляли использованием памяти вручную. Теперь большинство из нас, большую часть времени, просто больше не знают. Он был абстрагирован умным синтаксисом.

Таким образом, мы должны предпочесть, чтобы async управлялся для нас, а не нами. Для этого мы можем использовать лучшие абстракции для исполнения: асинхронные композиторы (см. реализацию ниже). Используя их, приведенный выше пример будет записан так:

pipe(
 juxt(getNumberOfBoxes, getNumberOfBunnies),
 subtraction,
 greater(0)),
 result => console.log(
   result
     ? ’we have enough boxes’
     : ’we don\’t have enough boxes’
 ),
)

pipe в основном означает композицию функций (в обратном порядке), а juxt означает компоновку параллельно, поэтому мы вводим одно значение и получаем массив результатов.

Этот способ письма имеет три преимущества:

  1. Он оптимально ждет. На самом деле мы никогда не делаем вызов f() или await сами явно. juxt и pipe принимают разумные решения по этому поводу, так что одной проблемой меньше.
  2. Он обесшумлен, потому что меньше способов написать его правильно. Порядок больше зависит от зависимостей и более семантичен — мы не можем переключать строки.
  3. Он меньше дублируется/коррелирует с составляющими в том смысле, что изменение getNumberOfBunnies на синхронное не потребует здесь изменения кода.

А как насчет случаев, когда IO оказывает влияние, и мы хотим убедиться, что все происходит последовательно? Мы должны явно стремиться к этому, поэтому некоторые композиторы связывают действия, которые должны происходить последовательно, а не просто плавают: будущий редактор может непреднамеренно изменить их порядок и вызвать ошибку. Абстракция doInSequence работает следующим образом:

return doInSequence(
 () => createUser(data),
  getUsers
);

Общим для этих абстракций является то, что они помогают нам мыслить в пространстве зависимостей, которое более значимо, чем пространство порядка выполнения (которое иногда имеет смысл, а иногда и произвольно).

Это, в свою очередь, уменьшает шум, делает его более лаконичным и всегда оптимальным.

Приложение

const wrapPromise = (x) => Promise.resolve(x);

const doInSequence = (head, ...rest) => wrapPromise(head()).then((x) => (rest.length ? doInSequence(...rest) : x));

const map = (f) => (xs) => {
 const results = [];
 for (const x of xs) {
   results.push(f(x));
 }
 return results.some(isPromise) ? Promise.all(results) : results;
};
const juxt =
 (...fs) =>
 (...x) =>
   map((f) => f(...x))(fs);

const reduceHelper = (reducer) => (s, xs, firstIndex) =>
 firstIndex === xs.length
   ? s
   : isPromise(s)
   ? s.then((s) =>
       reduceHelper(reducer)(reducer(s, xs[firstIndex]), xs, firstIndex + 1),
     )
   : reduceHelper(reducer)(reducer(s, xs[firstIndex]), xs, firstIndex + 1);
const reduce = (reducer, initial) => (xs) =>
 initial
   ? reduceHelper(reducer)(initial(), xs, 0)
   : reduceHelper(reducer)(xs[0], xs, 1);
const pipe =
 (...fs) =>
 (...x) =>
   reduce(
     (s, x) => x(s),
     () => fs[0](...x),
   )(fs.slice(1));

Вы можете найти эти и другие на https://github.com/uriva/gamlajs.