Зачем звёздочка у генераторов?
Синтаксис JavaScript сложнее, чем мог бы быть. А ещё он непоследовательный.
Вот, например, почему мы пишем function*
когда объявляем генератор в JavaScript? Зачем эта звёздочка?
В тех же Python или C# никакая метка не нужна — если в функции написан yield
, значит это генератор! Но в JavaScript нам зачем-то приходится писать звёздочку.
И почему нужно писать именно этот символ? Почему не ключевое слово типа generator
— по аналогии с синтаксисом асинхронных функций?
Это очень напоминает Perl с его синтаксисом типа
perl -pe '$_ = ++$x." $_" if /./'
Чтобы ответить на эти вопросы, я провёл мини-исследование. И, отчасти, во всей этой ситуации виноват оказался Python. Но обо всём по порядку.
Про термины
В статье я часто буду писать «генератор» и «сопрограмма» — ведь статья, в первую очередь, об их синтаксисе. Так что давай расскажу что я имею в виду под этими терминами.
Генератор — это функция, которая может приостановиться, после того, как вернёт значение. Выполнение генератора можно возобновить с того момента, на котором оно остановилось. Ключевой момент в том, что генераторы сохраняют своё состояние между вызовами. Благодаря тому, что генераторы могут возвращать несколько значений, их удобно использовать для создания ленивых коллекций.
Сопрограмма — это генератор, который может не только отдавать значения несколько раз, но и принимать новые значения из вне. Сопрограммы ещё называют корутины, это буквальный перевод с английского: co-routine → со-программа.
Функция | Генератор | Сопрограмма |
---|---|---|
Может вернуть значение только один раз | Может вернуть значения несколько раз | |
Принимает значения только один раз | Может принимать значения несколько раз |
Откуда взялся yield
Ключевое слово yield
впервые появилось в языке CLU. Вот так в нём выглядят генераторы:
odds = iter(n: int) yields int
i: int
i = 1
while i < n do
yield i
i := i + 2
end
end odds
Этот язык, кстати, разработала та самая Барбара Лисков в 1975 году. А спустя два года вышел язык Icon — со своими генераторами. Вместо yield
в нём использовалось слово suspend
:
procedure odds(n)
i := 1
while i < n do {
suspend i
i +:= 2
}
fail
end
Слово suspend
не прижилось и почти во всех языках стали использовать yield
из CLU.
Отказ от suspend
, как мне кажется, хорошо объясняет этот комментарий из обсуждения генераторов в Python :

Хотя, спустя 15 лет после этого комментария, в Kotlin выбрали именно suspend
— просто поставили его в правильное место!
Образец для подражания
В сообществе Python 90-х периодически обсуждали сопрограммы и генераторы. В 96-м Гвидо ван Россум в одном из своих докладов сказал, что для Python 2 он хочет переписать виртуальную машину — чтобы потом можно было добавить поддержку сопрограмм.
В 2001 году появился PEP 255 — предложение сделать генераторы и в том же году они были релизнуты с версией Python 2.2.
def fibonacci():
a = 1
b = 1
while True:
yield b
a, b = a + b, a
sequence = fibonacci()
print(sequence.__next__()) # => 1
print(sequence.__next__()) # => 1
print(sequence.__next__()) # => 2
Мечта Гвидо ван Россума о сопрограммах в Python сбылась лишь спустя пять лет с момента появления генераторов. Сопрограммы появились в 2006 году, вместе с выходом Python 2.5.
def sum():
currentSum = 0
while True:
currentSum += yield currentSum
add = sum()
print(add.__next__()) # => 0
print(add.send(5)) # => 5
print(add.send(4)) # => 9
print(add.send(8)) # => 17
А JavaScript всё это время смотрел что происходит в Python и учился.
JavaScript 1.7
EcmaScript 6 и генераторы появились в 2015 году. Но Firefox запилил сопрограммы спустя всего два месяца после того, как они появились в Python!
В 2004 году началась вторая война браузеров. Больше фичей в браузере — больше пользователей. Ещё и стандартов нет — страшное время. Firefox по популярности уступал только IE. А ещё в Mozilla тогда работал создатель JavaScript — Брендан Айк. Поэтому Firefox мог позволить себе сопрограммы в 2006 году.
Тогда сопрограммы JavaScript полностью копировали сопрограммы Python:
function fibonacci() {
var a = 1;
var b = 1;
while (true) {
var current = b;
b = a;
a = a + current;
yield current;
}
}
var sequence = fibonacci();
print(sequence.next()); // => 1
print(sequence.next()); // => 1
print(sequence.next()); // => 2
Конечно, с развитием стандарта EcmaScript, эти сопрограммы из Firefox удалили. Но обрати внимание — в этой версии не было ни yield*
, ни function*
.
Появление звёздочки
Это странно, но впервые звёздочка появилась в Python. В 2003 году Шейн Холловэй предложил оператор yield*
чтобы ускорить работу кода вроде этого:
def iterateTree(tree):
for each in tree.left:
yield each
yield tree
for each in tree.right:
yield each
С новым синтаксисом предполагалось писать подобный код компактнее:
def iterateTree(tree):
yield *tree.left:
yield tree
yield *tree.right:
Почему именно звёздочка? В Python она используется, чтобы распаковывать итераторы в массивы и вызовы функций. То есть там *
— это оператор распаковки коллекций по аналогии с ...
в JavaScript. Она же используется для обозначения переменного количества аргументов:
# Аналогично function sumAll(...numbers) в JS
def sumAll(*numbers):
result = 0
for number in numbers:
result += number
return result
print(sumAll(1, 2, 3)) # => 6
Поэтому для Python yield*
вполне логичен. Но они его не выбрали. 🫠
Вместо него сделали yield from
так как yield*
«слишком похож на обычный yield
и разницу легко не заметить».
К тому же, yield*
предполагался как синтаксический сахар для цикла for
. А yield from
— это полноценное делегирование, передача управления другой сопрограмме.
Стандарт генераторов
К 2010-м годам, генераторы решили реализовать в EcmaScript. И основой для них опять стали генераторы Python. Поэтому и yield*
включили в предложение — фича ведь нужная.
Но почему выбрали именно yield*
, неясно. Кажется, что логичнее писать yield ...x
. Три точки были в спецификации EcmaScript 4 ещё в 2006 году — их уже использовали для обозначения переменного количества аргументов. ES4 так и не выпустили, но три точки «перекочевали» в ES6 — в ту же версию, в которой ввели yield*
.
Впрочем, yield ...x
выглядит не так красиво как yield* x
. Да и звёздочка тут тоже имеет какой-никакой смысл — это звезда Клини. Та самая, которая используется в регулярных выражениях как квантификатор «ноль или более».
Но интересно то, что и в Python, и в JavaScript не стали использовать существующий синтаксис операторов распаковки, а предпочли что-то другое.
На ранних этапах спецификации, генераторы в JavaScript выглядели так:
function iterateTree(tree) {
yield* tree.left
yield tree
yield* tree.right
}
Чего-то не хватает...
function*
Введение yield
ломало старый код — это слово не было зарезервировано и люди спокойно могли создать переменную или функцию с таким названием.
Пришлось делать «костыль». И очень удачно сложилось, что решили взять оператор yield*
из Python. Вот и использовали звёздочку из этого оператора, чтобы отличить обычные функции от сопрограмм. А внутри сопрограмм yield
сделали ключевым словом.
В Python были похожие проблемы. Но они могли постепенно мигрировать при помощи предупреждений о том, что yield
будет зарезервирован. Python может себе это позволить, а в браузере программист не может выбрать, какую версию JS он хочет использовать (хотя в нулевых такая возможность была — именно так сделали генераторы в JS 1.7).
В итоге в JS решили писать function*
. Это, кстати, дало ещё один плюс — пустые генераторы. Вот как они выглядят в Python:
# Вариант 1:
def emptyByReturn():
return
yield
print(list(emptyByReturn())) # => []
# Вариант 2:
def emptyByDelegate():
yield from ()
print(list(emptyByDelegate())) # => []
А так — в JavaScript:
function* empty() {}
console.log([...empty()]); // => []
Но по идее можно было бы сочинить что-то вроде generator function
— по аналогии с async function
.
Сопрограммы и асинхронные функции
Это не очевидно, но асинхронные функции — это частный случай сопрограмм. Их суть ровно та же — уметь приостановиться. В сопрограммах это происходит при помощи оператора yield
, а в асинхронных функциях — с помощью await
.
Интересно, что часто сначала в языках появляются сопрограммы, а потом добавляют поддержку асинхронных функций. А пока асинхронные функции не добавили, они эмулируются при помощи сопрограмм. Например, для JS была (и есть) библиотека co:
const co = require('co');
function fetchData(id) {
return new Promise(resolve => resolve(`Data for ID: ${id}`));
}
co(function* () {
const data1 = yield fetchData(1);
const data2 = yield fetchData(2);
console.log(data1, data2);
});
async/await
Логично ожидать, что синтаксис асинхронных функций и сопрограмм будет единообразным, но нет:
function* generatorFunction() {}
async function asyncFunction() {}
К использованию async
пришли не сразу и изначально предлагали сделать синтаксис вроде function!
или function^
. Он хорошо сочетался с синтаксисом сопрограмм.
Казалось, что всё круто — пишем рандомные символы после function
и не нужно запариваться с грамматикой и парсингом. Но спустя время всё-таки решили использовать ключевое слово async
. Точной причины этого я не смог найти, но могу предположить, что на это есть несколько причин:
async
— это стандарт de facto во многих языках;- звёздочка для сопрограмм имеет смысл, а восклицательный знак для асинхронности — не имеет никакого;
- при использовании таких символов не сделать нормального синтаксиса для лямбд.
Вот и получился не совсем последовательный синтаксис для схожих конструкций. Но этой последовательности нет и в других языках.
async/await — вы не понимаете, это другое
В C# генераторы определяются по наличию yield return
или yield break
, а для асинхронных функций нужно написать async
:
using System;
using System.Linq;
using System.Threading.Tasks;
using System.Collections.Generic;
IEnumerable<int> Fib() {
(int a, int b) = (1, 1);
while (true) {
yield return b;
(a, b) = (a + b, a);
}
}
async Task Log() {
await Task.Delay(3000);
Console.WriteLine("Task completed");
}
Console.WriteLine(string.Join(", ", Fib().Take(3))); // => 1, 1, 2
await Log(); // => Task completed
Особенность C# в том, что слово yield
может быть только в паре с другим: yield break
или yield return
. Этот хак позволяет сохранять обратную совместимость между версиями языка.
А ещё, в отличие от JS и Python, yield
может быть только оператором, но не выражением. Когда новое ключевое слово имеет ограниченный контекст применения, ввести его проще.
Но с await
появились проблемы. Оно должно быть доступно почти в любом выражении и единственным вариантом было ввести ключевое слово async
. Зато есть обратная совместимость — а это для разработчиков C# важно.
Для сообщества Python же сломать обратную совместимость — не столь критично (привет, Python 2). И её сломали, когда вводили слово yield
для сопрограмм. Но сохранили, введя async
/await
.
Подход Python в случае асинхронных функций похож на подход C#: сопрограммы определяем по наличию yield
, а асинхронные функции должны помечаться модификатором async
. И если в C# это сделано для обратной совместимости, то в Python — чтобы синтаксис был последовательным (и для обратной совместимости тоже!).
Введение слова async
в Python давало возможность комбинировать его с другими ключевыми словами:
async def
для объявления асинхронных функций,async for
для асинхронной итерации,async with
для управления асинхронными ресурсами (RAII).
Иными словами, async
сделали потому что его можно применять в нескольких местах и это делает синтаксис последовательным.
Эти примеры показывают, что «нелогичный» синтаксис возникает во всех языках, пусть и по разным причинам.
Асинхронные операторы
В C# и JS тоже есть конструкции для асинхронной итерации и даже для асинхронных ресурсов. Выглядят они так:
await using (var resource = InitAsyncResource()) {
await foreach (var line in file.lines()) {
Console.WriteLine(line);
}
}
await using file = initAsyncResource();
for await (const line of file.lines()) {
console.log(line)
}
В TypeScript можно заметить ещё одну особенность — for await
, но await using
. Изначально было предложение сделать using await
— это и последовательно, и парсить легче. Потом среди разработчиков провели опросы и они показали, что смысл оператора лучше описывает синтаксис await using
. Решили выбрать его. Забавно сравнить конструкции Python и TypeScript:
Python | TypeScript |
---|---|
async def | async function |
async for | for await |
async with | await using |
При обсуждениях синтаксиса этих конструкций в JS много внимания уделяется смыслу выражений и тому, как их можно воспринимать. async
вводит «асинхронный контекст», в котором можно использовать await
. Поэтому async for
писать не стоит — обязательно нужно писать await
. Если мы пишем await for
, то выглядит так, будто мы ожидаем промис, который возвращается циклом for
, а это не совсем так. При этом, если мы пишем using await x = ...
, то может показаться что мы ожидаем значение x
, хотя мы ожидаем оператор using
.
Попытка отразить все нюансы и избавиться от двусмысленных трактовок, как мне кажется, провалилась. Подход Python тут, как мне кажется, более удачен. Он основан не на описании нюансов, а на создании простого правила, которое легко запомнить и использовать.
О последствиях
Введение асинхронности в Python выглядит классно с точки зрения синтаксиса. Очень круто, что они ввели сразу и async for
и async with
— в других языках (в том же JS) эти фичи разделены годами. Поэтому странно, что они решили не делать async lambda
.
Конечно, лямбды в Python специфичные и могут состоять только из одной строки. Это серьёзно ограничивает применение асинхронных лямбд. Хотя вот лямбды-корутины поддерживаются:
single = lambda x: (yield x)
double = lambda x: (yield from [x, x])
print(list(single(9))) # => [9]
print(list(double(9))) # => [9, 9]
В JavaScript же противоположная ситуация — асинхронные лямбды есть:
const fetchPost = async (url, options = {}) => await fetch(url, {
method: "POST",
...options,
});
а вот лямбд-генераторов нет. И, возможно, не будет никогда.
В сообществе было много обсуждений, нужны ли лямбды-генераторы или нет. Кто-то приводил примеры их использования, кто-то предлагал концептуальные объяснения тому, почему их не стоит делать. Но, думаю, основная причина заключается именно в синтаксисе.
Трудно придумать что-то красивое и рабочее для генераторов. Самый логичный вариант, *(arg) => {}
— может быть легко перепутан парсером с умножением. Делать ещё одну стрелочку типа =*>
— стрёмно. Парсить синтаксис вида (a, b) * => {}
— тяжело.
Обсуждения синтаксиса генераторов до сих пор идут, а ведь начались они ещё 12 лет назад! То, что, позволило упростить парсер годы назад, теперь немного стопорит развитие языка.
В заключение
Было ли использование звёздочки для обозначения сопрограмм ошибкой? Я бы сказал, что да. И дело тут не столько в символе звёздочки, сколько в отсутствии системного подхода.
Язык Dart унаследовал от JS звёздочку для обозначения генераторов. Выглядит это так:
Iterable naturalsTo(n) sync* {
int k = 0;
while (k < n) {
yield k++;
}
}
Слово sync
на самом деле не нужно, его даже нельзя использовать без звёздочки — оно просто парное к слову async
(для асинхронных генераторов используется async*
). Но разработчики Dart приняли решение не указывать только звёздочку:
// Генератор
Iterable naturalsTo(n) sync* {}
// Асинхронный генератор
Stream asyncNaturalsTo(n) async* {}
// Асинхронная функция
Future asyncNatural() async {}
// Обычная функция
int natural() {}
Несмотря на то, что есть это странное слово-затычка sync
, это позволило реализовать все виды лямбд. Их синтаксис, кстати, далеко не из простых с точки зрения парсинга:
void main() {
printAll((int n) sync* {
int k = 0;
while (k < n) {
yield k++;
}
});
}
void printAll(Iterable<int> g(int n)) {
for (var i in g(10)) {
print(i);
}
}
Возможно, сообществу JavaScript просто не хватает решимости. Выбрать какой-то вариант синтаксиса — не самый красивый и последовательный — можно. Реализовать парсер сложно, но тоже можно.
А может и не стоит закрывать эту «логическую дыру» с лямбдами-генераторами просто потому что она есть. Например, в C# лямбд-генераторов тоже нет. Хотя синтаксис вполне позволяет. Просто реализовать их долго и дорого с учётом устройства компилятора C#. По сравнению с другими фичами, лямбды-генераторы не дают достаточно пользы. Поэтому их реализацию откладывают уже больше 10 лет.
Непоследовательный синтаксис, вызывающий много вопросов есть, наверное, в каждом языке, но это не мешает нам использовать эти языки, чтобы менять мир вокруг нас в лучшую сторону.