Введение
Вы, наверное, сталкивались с этим раньше - вам хотелось сохранить в памяти действительно большой список. После тщательного рассмотрения вы пришли к выводу, что это либо невозможно, поскольку память является ценным и ограниченным ресурсом, либо даже не нужно, поскольку нам нужен только один элемент за раз.
Это когда « Генераторы ».
Что такое генераторы?
Генераторы - это функции, которые создают элементы последовательности, один за другим.
Это дает нам возможность сохранять в памяти только один элемент за раз. Это, конечно, делает нашу программу намного более компактной.
Функции генератора существуют на нескольких популярных языках, включая Javascript, Python, C # и Ruby.
Реализация основана на концепции « Ленивые списки ».
Один из способов представления ленивых списков - это следующее определение:
Ленивый список - это пара текущего элемента (индексированного i) и функции генератора, которая лениво создает остальную часть списка.
Мы можем определить его рекурсивно как -
LazyList(i) = (Element(i), Function(() => LazyList(i+1)))
Предположим, у нас есть бесконечный список целых чисел, скажем, простых чисел - 2, 3, 5, 7, 11…
Эта последовательность целых чисел может быть представлена с помощью ленивого списка как -
LazyListPrime(0) = (2, function() => LazyListPrime(1)) LazyListPrime(1) = (3, function() => LazyListPrime(2)) LazyListPrime(2) = (5, function() => LazyListPrime(3)) LazyListPrime(3) = (7, function() => LazyListPrime(4)) LazyListPrime(4) = (11, function() => LazyListPrime(5))
В целом -
LazyListPrime(i) = (Prime(i), function() => LazyListPrime(i+1))
Где Prime (i) - функция, возвращающая i-е простое число.
Очевидно, что невозможно создать этот список и сохранить его в памяти, так как он бесконечен.
Таким образом, мы могли бы создать этот список с помощью генератора.
Давайте поговорим о технологиях
Языки, которые изначально поддерживают генераторы, используют ключевое слово yield
для их реализации.
Такими языками, например, являются Javascript, Python, Ruby и C #.
Общая идея генераторов заключается в том, что состояние выполняющейся функции генератора должны сохраняться между вызовами. В разных языках это реализовано по-разному.
В этой части статьи мы сосредоточимся на генераторах в Node.JS.
Генераторы в Node.JS
Генераторы в Javascript (или Typescript) определяются с помощью function*
keyword. Внутри генераторов мы можем приостановить выполнение функции с помощью ключевого слова yield
.
Примечание. Функция генератора также может содержать оператор return
, который останавливает выполнение функции, как обычная функция.
Продолжим наш пример с простыми числами -
function* primeGenerator() { for (let num = 2; ; num++) { if (isPrime(num)) { yield num; } } } function isPrime(num) { for (let i = 2, s = Math.sqrt(num); i <= s; i++) { if (num % i === 0) { return false; } } return true; }
Функция primeGenerator
- это базовый генератор. Мы знаем это, поскольку объявлено с использованием function*
.
Хотя функция-генератор не обязательно должна содержать оператор yield
, бессмысленно определять ее как таковую.
Все, что она делает, это перебирает целые числа одно за другим, и если целое число является простым числом, оно возвращает это звонящему.
Чтобы создать экземпляр генератора, мы просто вызываем функцию -
const gen = primeGenerator();
Такой вызов не выполняет функцию, а просто создает объект-генератор. Если бы мы проверили созданный объект, мы бы получили -
gen = primeGenerator {[[GeneratorStatus]]: "suspended"} __proto__ = Generator [[GeneratorLocation]] = Object [[GeneratorStatus]] = "suspended" [[GeneratorFunction]] = function* primeGenerator() { [[GeneratorReceiver]] = global [[Scopes]] = Scopes[3] Local (primeGenerator) {} Closure {isPrime: Function} Global {global: global, ...}
Чтобы вызвать созданный генератор, мы используем gen.next()
.
При первом вызове gen.next()
функция начинает выполнение с первой строки. Любые последующие вызовы gen.next()
возобновят выполнение с последней точки, в которой оно было приостановлено.
Попробуем запустить этот код. Если бы мы запустили следующую строку:
console.log(gen.next());
Результатом будет {value: 2, done: false}
, где value
- текущее сгенерированное простое число, а done
- состояние генератора, что означает, что функция может возобновить выполнение в будущем. Объект gen
будет выглядеть так -
gen = primeGenerator {[[GeneratorStatus]]: "suspended"} __proto__ = Generator [[GeneratorLocation]] = Object [[GeneratorStatus]] = "suspended" [[GeneratorFunction]] = function* primeGenerator() { [[GeneratorReceiver]] = global [[Scopes]] = Scopes[4] Block (primeGenerator) {num: 2} Local (primeGenerator) {} Closure {isPrime: Function} Global {global: global, ...}
Если бы мы снова запустили gen.next()
, то получили бы {value: 3, done: false}
.
Чтобы получить, например, первые 10 простых чисел, мы можем перебрать сгенерированные простые числа. Мы можем использовать синтаксис let...of
, поскольку функции генератора повторяются -
let i = 0; for (let prime of primeGenerator()) { console.log(prime); i++; if (i > 10) { break; } }
Или мы можем использовать базовый цикл for -
const gen = primeGenerator(); for (let i = 0; i < 10; i++) { console.log(gen.next()); }
Обратите внимание, как определенный выше генератор создает бесконечный список элементов.
Это не обязательно, и функции генератора могут создавать конечное число элементов.
Давайте посмотрим на следующий пример:
function* lettersGenerator() { const letters = 'abc'; for (let letter of letters) { yield {letter: letter}; } }
Когда мы бежим -
const gen = lettersGenerator(); console.log(gen.next()); console.log(gen.next()); console.log(gen.next()); console.log(gen.next());
Первые три вызова gen.next()
произведут: {value: {letter: a}, done: false}
{value: {letter: b}, done: false}
{value: {letter: c}, done: false}
Четвертый (и любой последующий вызов gen.next()
) произведет {value: undefined, done: true}
, что указывает на то, что генератор исчерпал свои ресурсы и больше не будет производить никаких значений.
Под капотом
Так как же на самом деле генераторы работают в Node.JS?
Как мы знаем, Node.JS использует движок V8 для запуска кода Javascript (как и Chrome). После синтаксического анализа кода V8 использует компилятор для создания байтового кода из кода Javascript, который будет запущен, с именем Ignition.
Как можно увидеть здесь, в функции Parser::ParseAndRewriteGeneratorFunctionBody
, когда синтаксический анализатор встречает выражение function*
, он анализирует его, как если бы добавление yield
выражения добавлялось к началу функции, которая при запуске функции просто немедленно возвращает вызывающей стороне.
Код, переводящий операцию yield в байт-код (исполняемый код), можно найти здесь. ByecodeGenerator::VisitYield
- это функция, которая вызывается каждый раз, когда движок встречает ключевое слово yield
в коде.
VisitYield
вызывается с yield
выражением из AST (абстрактного синтаксического дерева). Если это первое yield
выражение, которое встретилось, оно просто приостанавливает выполнение функции, не возвращая никакого значения. Если это не первый yield
, то также возвращается полученное значение. В обоих случаях VisitYield
, в свою очередь, вызывает BuildSuspendPoint
, который отвечает за приостановку генератора и передачу результата (если таковой имеется) вызывающей стороне, сохраняя при этом состояние для следующей итерации. BuildSuspendPoint
делает это, используя функция SuspendGenerator
, которая сохраняет все состояние (регистры, контекст, закрытие, параметры, текущий счетчик приостановки и т. д.) в объекте-генераторе, затем возвращает сгенерированное значение и возвращается вызывающей стороне.
В следующий раз генератор вызывается (с использованием gen.next()
), функция BuildSuspendPoint
возобновляет выполнение после точки приостановки. Затем он просто вызывает функцию ResumeGenerator
, которая отвечает за восстановление состояния (которое было сохранено в объекте-генераторе). После этого он продолжает выполнение с точки, в которой он остановился в VisitYield
, а остальная часть функции генератора после yield продолжит работу.
В следующий раз, когда мы снова перейдем к yield
выражению, этот цикл повторится.
Расширения
Одна концепция, основанная на генераторах, называется асинхронными генераторами.
Эта концепция существует в JavaScript, Python и C #.
Она очень полезна в случаях потоковой передачи данных, так как получаемые результаты Обещания, которые разрешаются асинхронно.
Мы всегда должны стараться распознавать варианты использования, в которых применимы генераторы. Это может значительно улучшить время выполнения и память нашей программы.