Понимание генераторов TypeScript

Источник: «Understanding TypeScript generators»
Функции-генераторы выглядят как обычные функции, но ведут себя немного иначе, позволяя приостановить и выполнить код в более поздний момент времени.

Обычные функции выполняются сверху вниз и затем завершаются. Генераторные функции также выполняются сверху вниз, но их можно приостановить во время выполнения и возобновить позже с той же точки. Так продолжается до конца процесса, после чего они завершаются. В этой статье мы узнаем, как использовать функции-генераторы в TypeScript, рассмотрим несколько различных примеров и случаев использования. Давайте начнём!

Создание функции-генератора в TypeScript

Обычные функции выполняются охотно, в то время как генераторы — лениво, то есть их можно попросить выполнить в более поздний момент времени. Чтобы создать функцию-генератор, мы воспользуемся командой function *. Функции-генераторы выглядят как обычные функции, но ведут себя немного по-другому. Взгляните на следующий пример:

function normalFunction() {
console.log("Это нормальная функция");
}
function* generatorFunction() {
console.log("Это функция-генератор");
}

normalFunction(); // "Это нормальная функция"
generatorFunction();

Хотя он написан и выполняется как обычная функция, при вызове generatorFunction мы не получаем никаких логов в консоли. Проще говоря, вызов генератора не приводит к выполнению кода:

Вызов генератора не приводит к выполнению кода

Вы заметили, что функция генератора возвращает тип Generator; мы подробно рассмотрим это в следующем разделе. Чтобы заставить генератор выполнить наш код, мы сделаем следующее:

function* generatorFunction() {
console.log("Это функция-генератор");
}

const a = generatorFunction();
a.next();

Обратите внимание, что метод next возвращает IteratorResult. Таким образом, если бы мы хотели вернуть число из generatorFunction, мы бы получили доступ к значению следующим образом:

function* generatorFunction() {
console.log("Это функция-генератор");
return 3;
}
const a = generatorFunction();
const b = a.next();
console.log(b); // {"value": 3, "done": true}
console.log(b.value); // 3

Интерфейс генератора расширяет Iterator, что позволяет нам вызывать next. Он также имеет свойство [Symbol.iterator], что делает его итератором.

Понимание итераций и итераторов JavaScript

Итерируемые объекты — объекты, которые можно итерировать с помощью for...of. Они должны реализовывать метод Symbol.iterator; например, массивы в JavaScript являются встроенными итераторами, поэтому они должны иметь итератор:

const a = [1,2,3,4];
const it: Iterator<number> = a[Symbol.iterator]();
while (true) {
let next = it.next()
if (!next.done) {
console.log(next.value)
} else {
break;
}
}

Итератор позволяет выполнять итерацию по итерируемой таблице. Посмотрите на следующий код, который представляет собой очень простую реализацию итератора:

function naturalNumbers() {
let n = 0;
return {
next: function() {
n += 1;
return {value:n, done:false};
}
};
}

const iterable = naturalNumbers();
iterable.next().value; // 1
iterable.next().value; // 2
iterable.next().value; // 3
iterable.next().value; // 4

Как упоминалось выше, итератор — объект, обладающий свойством Symbol.iterator. Таким образом, если мы назначим функцию, возвращающую функцию next(), как в примере выше, наш объект станет JavaScript итерируемым, что позволит нам выполнять итерацию по нему, используя синтаксис for..of.

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

Работа с генераторами в TypeScript

Самое интересное в генераторах то, что вы можете приостановить выполнение с помощью оператора yield, чего мы не сделали в предыдущем примере. Когда вызывается next, генератор выполняет код синхронно, пока не встретится yield, в этот момент он приостанавливает выполнение. Если next будет вызван снова, он возобновит выполнение с того места, где оно было приостановлено. Давайте рассмотрим пример:

function* iterator() {
yield 1
yield 2
yield 3
}
for(let x of iterator()) {
console.log(x)
}

yield, по сути, позволяет нам возвращаться из функции несколько раз. Кроме того, массив никогда не будет создан в памяти, что позволяет нам создавать бесконечные последовательности очень экономно расходуя память. В следующем примере будут созданы бесконечные чётные числа:

function* evenNumbers() {
let n = 0;
while(true) {
yield n += 2;
}
}
const gen = evenNumbers();
console.log(gen.next().value); //2
console.log(gen.next().value); //4
console.log(gen.next().value); //6
console.log(gen.next().value); //8
console.log(gen.next().value); //10

Мы также можем модифицировать приведённый выше пример так, чтобы он принимал параметр и выдавал чётные числа, начиная с указанного числа:

function* evenNumbers(start: number) {
let n = start;
while(true) {
if (start === 0) {
yield n += 2;
} else {
yield n;
n += 2;
}
}
}
const gen = evenNumbers(6);
console.log(gen.next().value); //6
console.log(gen.next().value); //8
console.log(gen.next().value); //10
console.log(gen.next().value); //12
console.log(gen.next().value); //14

Рекурсивное использование генераторов

Эффективные для памяти свойства генераторов можно использовать для чего-то более полезного, например, для рекурсивного чтения имён файлов внутри каталога. На самом деле, рекурсивный обход вложенных структур — это то, что приходит мне в голову, когда я думаю о генераторах.

Поскольку yield является выражением, yield* можно использовать для делегирования другому итерируемому объекту, как показано в следующем примере:

function* readFilesRecursive(dir: string): Generator<string> {
const files = fs.readdirSync(dir, { withFileTypes: true });

for (const file of files) {
if (file.isDirectory()) {
yield* readFilesRecursive(path.join(dir, file.name));
} else {
yield path.join(dir, file.name);
}
}
}

Мы можем использовать нашу функцию следующим образом:

for (const file of readFilesRecursive('/path/to/directory')) {
console.log(file);
}

Мы также можем использовать yield для передачи значения генератору. Взгляните на следующий пример:

function* sumNaturalNumbers(): Generator<number, any, number> {
let value = 1;
while(true) {
const input = yield value;
value += input;
}
}
const it = sumNaturalNumbers();
it.next();
console.log(it.next(2).value); //3
console.log(it.next(3).value); //6
console.log(it.next(4).value); //10
console.log(it.next(5).value); //15

Когда вызывается next(2), входным данным присваивается значение 2; аналогично, когда вызывается next(3), входным данным присваивается значение 3.

Обработка ошибок

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

Когда генератор сталкивается с ошибкой, он может выбросить исключение, используя ключевое слово throw. Это исключение может быть поймано и обработано с помощью блока try...catch внутри функции генератора или вне её при использовании генератора:

function* generateValues(): Generator<number, void, string> {
try {
yield 1;
yield 2;
throw new Error('Something went wrong');
yield 3; // Это не будет достигнуто
} catch (error) {
yield* handleError(error); // Обрабатываем ошибку и продолжаем работу
}
}

function* handleError(error: Error): Generator<number, void, string> {
yield 0; // Продолжить со значением по умолчанию
yield* generateFallbackValues(); // Выдача значений по умолчанию
throw `Error handled: ${error.message}`; // Выброс новой ошибки или повторный выброс существующей ошибки
}

const generator = generateValues();

console.log(generator.next()); // { value: 1, done: false }
console.log(generator.next()); // { value: 2, done: false }
console.log(generator.next()); // Не пойманная ошибка: Something went wrong

console.log(generator.next()); // { value: 0, done: false }
console.log(generator.next()); // { value: 4, done: false }
console.log(generator.next()); // Something went wrong

В этом примере функция генератора generateValues выбрасывает ошибку после получения значения 2. Блок catch внутри генератора перехватывает ошибку, и управление передаётся функции генератора handleError, которая выдаёт запасные значения. Наконец, функция handleError выбрасывает новую ошибку или повторно выбрасывает существующую.

При использовании генератора вы можете отлавливать возникающие ошибки с помощью блока try...catch:

const generator = generateValues();

try {
console.log(generator.next());
console.log(generator.next());
console.log(generator.next());
} catch (error) {
console.error('Caught error:', error);
}

В этом случае ошибка будет поймана блоком catch, и вы сможете обработать её соответствующим образом.

Заключение

Генераторы можно использовать для множества интересных целей; эффективность использования памяти делает их действительно полезными в некоторых особых случаях. В этой статье мы узнали, как использовать генераторы в TypeScript, рассмотрели их синтаксис и основы итераторов и итерируемых элементов JavaScript. Мы также узнали, как использовать генераторы TypeScript рекурсивно и обрабатывать ошибки с помощью генераторов. Надеюсь, вам понравилась эта статья, и не забудьте оставить комментарий, если у вас возникли вопросы.

Дополнительные материалы

Предыдущая Статья

Работа со сторонними сервисами в Laravel

Следующая Статья

Vim: Повторить последнюю замену