Авторский проект IT-специалиста Олега Барабанова Персональные публикации на тему IT и не только…

Обычные и асинхронные методы-генераторы в JavaScript и TypeScript

Не секрет, что в интернете вполне достаточно информации, обьясняющих назначение и функционал генераторов в языках программирования, поэтому в статье я я буду больше уделять внимания реализации генераторов именно в виде методов класса (синтаксис ES6+), поскольку синтаксис написания таких методов нередко смущает новичков.

Пару вводных слов о том, что такое генераторы

Если вкратце вспомнить, что такое генераторы, то можно описать это простыми словами, как функцию, которая может возвращать (через объект-итератор) множество значений последовательно с использованием ключевого слова yield. Функционал генераторов существует во множестве языков программирования, например в JavaScript, PHP, Python и пр.

В JavaScript обычные генераторы обозначаются символом * при инициализации функции. Например так:

function* example() {
    yield 1;
    yield 2;
    yield 3;
    return true;
} 

Сами по себе генераторы очень удобно использовать там, где нужно возвращать большую последовательность данных или где нужно иметь возможность приостановки выполнения функции. Как пример возможностей генераторов, возьмем генерацию последовательности чисел Фибоначчи, т.е. когда каждое последующее число (кроме первых двух), равно сумме двух предыдущих чисел, например: "0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, ...". Если нам нужно сгенерировать и вывести последовательность, состоящую из нескольких миллионов, то если просто сгенерировать один массив в виде последовательности, то такой массив в один момент будет занимать большое количество памяти. Но не всегда нам нужен массив целиком, иногда достаточно предоставлять числа по-элементно (итеративно!). Т.е. нам не нужно будет гонять туда-сюда огромный массив, что вполне экономней по памяти. Вот тут как-раз генераторы наглядно удобны:

function* getFibonaccy() {
  let prevDigit = 0, nextDigit = 1;
  yield prevDigit; // возвращаем первый ноль;
  do {
    yield nextDigit;
	[prevDigit, nextDigit] = [nextDigit, nextDigit + prevDigit];
  } while(true);
}

И если пробежаться по генератору, с помощью цикла for (... of ...), то мы получим необходимую последовательность:

for (const i of getFibonaccy()) {
  if (i > 100000) break;
  console.log(i); // => 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, ...
}

Так как в JS мы привыкли работать в основном в асинхронном стиле, то неудивительно, что в языке существуют и асинхронные генераторы.

Сами по себе асинхронные генераторы пишутся по аналогии с обычными генераторами, только в качестве функции-генератора представлена асинхронная функция (с помощью ключевого слова async) внутри которой, через yield возвращается не просто значение, а именно Promise:

async function* example() {
  yield new Promise((resolve, reject) => {...}); // first task
  yield new Promise((resolve, reject) => {...}); // second task
  ...
  return true;
}

Как видите - асинхронные генераторы, достаточно мощная штука. И в принципе документации в интернете по ней полно. Но меня удивило, что не все знают, как писать подобные методы в контексте современных ES6+ классов.

Синтаксис написания обычных и асинхронных генераторов в стиле ES6+ классов

Я недавно обратил внимание, что тех, кто только знакомится с генераторами, часто запутывает именно конструкция function* и многие почему-то думают, что звездочка обязательно должна примыкать к ключевому слову function. Даже в MDN (на январь 2022) указана необходимость ключевого слова function.

Так вот, для обозначения генераторов, самое главное, чтобы символ * был перед названием создаваемой функции, т.е. одинаково подходят и function* example() и function *example(). И вот исходя из второго примера становится понятно, что для обозначения методов-генераторов, достаточно просто перед названием добавить * (и не нужны никакие function). Например так:

class Example {
  *customGenerator() {
    yield 1
    yield 2
    ...
    return true;
  }
}

А в случае с асинхронными методами-генераторами, просто к этой конструкции добавляем async:

class Example {
  async *customGenerator() {
    yield new Promise((resolve, reject) => {...}); // first task
    yield new Promise((resolve, reject) => {...}); // second task
    ...
    return true;
  }
}

Естественно асинхронные методы-генераторы можно использовать также для статических и приватных методов. Выглядит это как-то так:

class Example {
  static async *#customGenerator() {
    yield new Promise((resolve, reject) => {...}); // first task
    yield new Promise((resolve, reject) => {...}); // second task
    ...
    return true;
  }
}

Смотря на такого вида конструкции с двумя спецсимволами(*#customGenerator) хочется надеяться, что в JavaScript в дальнейшем не будут стремиться к тенденциям ввода спецсимволов, а будут вводить классические для многих языков ключевые слова, такие как: "private", "protected", "public" и пр. Ведь "static" же не поленились сделать ключевым словом. Хотя как я уже упоминал про подобный момент в одной из своих статей - для нормального стиля у нас есть TypeScript 😉.

Асинхронные генераторы в TypeScript

Кстати, что касается TypeScript, асинхронные методы генераторы в нем прекрасно поддерживаются. TypeScript ожидает, что возвращаемое значение будет соответствовать типу AsyncGenerator<T = unknown, TReturn = any, TNext = unknown>, где T - тип возвращаемого значения в Promise (сам промис указывать не надо), TReturn - тип возвращаемого значения через return, а TNext - тип значения, передаваемого в генератор через метод итератора .next(...);

Например, представим перебор последовательности чисел Фибоначчи, в виде небольшого TypeScript кода:

class Fibonaccy {
  static async *getSequence(): AsyncGenerator<number, never, void> {
    let prevDigit = 0, nextDigit = 1;
    yield new Promise<number>(resolve => resolve(prevDigit));
    do {
      yield new Promise<number>(resolve => resolve(nextDigit));
      [prevDigit, nextDigit] = [nextDigit, nextDigit + prevDigit];
    } while(true);
  }
}
(async () => {
  for await (const digit of Fibonaccy.getSequence()){
    if (digit > 1000) break; // max value
      console.log(digit);
    }
  }
)();

Вышеперечисленный пример с числами Фибоначчи на TypeScript можно запустить в песочнице.

Стоит отметить, чтобы вышеперечисленный пример работал, в TypeScript для поддержки AsyncGenerator и циклов for await необходимо в настройках компиляции (например в tsconfig.json) установить в compilerOptions для свойства target значение не ниже "es2018":

{
  "compilerOptions": {
    ...
    "target": "es2018",
    ...
  }

С подробным описанием структуры интерфейса AsyncGenerator можно ознакомиться в файле декларации lib.es2018.asyncgenerator.d.ts .

Заключение

Целью статьи было просто показать, как выглядят асинхронные методы-генераторы в JS и TS. Как видно из последних примеров, для обозначения методов-генераторов вполне достаточно перед методом добавить спецсимвол * . А то, что вы часто видите в документации синтаксис function* - это просто устоявшаяся практика, а не строгое правило.

Конечно, генераторы сами по себе не так уж и часто используются, но в специфичных ситуациях - это мощный, лаконичный и незаменимый инструмент. Главное используйте его, как и все остальное, разумно.