За­чем звёз­доч­ка у ге­не­ра­то­ров?

Синтаксис JavaScript сложнее, чем мог бы быть. А ещё он непоследовательный.

Вот, например, почему мы пишем function* когда объявляем генератор в JavaScript? Зачем эта звёздочка?

В тех же Python или C# никакая метка не нужна — если в функции написан yield, значит это генератор! Но в JavaScript нам зачем-то приходится писать звёздочку.

И почему нужно писать именно этот символ? Почему не ключевое слово типа generator — по аналогии с синтаксисом асинхронных функций?

Это очень напоминает Perl с его синтаксисом типа

perl -pe '$_ = ++$x." $_" if /./'
Однострочник на Perl для нумерации всех не пустых строк в файле

Чтобы ответить на эти вопросы, я провёл мини-исследование. И, отчасти, во всей этой ситуации виноват оказался 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
Итераторы в языке CLU

Этот язык, кстати, разработала та самая Барбара Лисков в 1975 году. А спустя два года вышел язык Icon — со своими генераторами. Вместо yield в нём использовалось слово suspend:

procedure odds(n)
    i := 1
    while i < n do {
        suspend i
        i +:= 2
    }
    fail
end
Итераторы в языке Icon.

Слово suspend не прижилось и почти во всех языках стали использовать yield из CLU.

Отказ от suspend, как мне кажется, хорошо объясняет этот комментарий из обсуждения генераторов в Python :

Wouldn't yield sound better than suspend? It's not s which is being suspended!
Перевод: Разве «yield» не звучит лучше, чем «suspend»? Приостанавливается ведь не «s»!

Хотя, спустя 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

Мечта Гвидо ван Россума о сопрограммах в 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
Пример сопрограммы на Python

А 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
Сопрограмма на JS 1.7

Конечно, с развитием стандарта 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 используется для функций с переменным количеством аргументов

Поэтому для 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())) # => []
Два способа создать пустой генератор в Python

А так — в JavaScript:

function* empty() {}

console.log([...empty()]); // => []
Классный способ создать пустой генератор в JavaScript

Но по идее можно было бы сочинить что-то вроде 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);
});
Библиотека co может использоваться для асинхронного программирования без 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#

Особенность 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);
    }
}
Асинхронная итерация и управление ресурсами в C#
await using file = initAsyncResource();
for await (const line of file.lines()) {
    console.log(line)
}
Асинхронная итерация и управление ресурсами в TypeScript (в JS оператор using ещё не сделали)

В 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]
Лямбды-корутины в Python

В JavaScript же противоположная ситуация — асинхронные лямбды есть:

const fetchPost = async (url, options = {}) => await fetch(url, {
    method: "POST",
    ...options,
});
Асинхронная лямбда в JavaScript

а вот лямбд-генераторов нет. И, возможно, не будет никогда.

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

Трудно придумать что-то красивое и рабочее для генераторов. Самый логичный вариант, *(arg) => {} — может быть легко перепутан парсером с умножением. Делать ещё одну стрелочку типа =*> — стрёмно. Парсить синтаксис вида (a, b) * => {} — тяжело.

Обсуждения синтаксиса генераторов до сих пор идут, а ведь начались они ещё 12 лет назад! То, что, позволило упростить парсер годы назад, теперь немного стопорит развитие языка.

В заключение

Было ли использование звёздочки для обозначения сопрограмм ошибкой? Я бы сказал, что да. И дело тут не столько в символе звёздочки, сколько в отсутствии системного подхода.

Язык Dart унаследовал от JS звёздочку для обозначения генераторов. Выглядит это так:

Iterable naturalsTo(n) sync* {
    int k = 0;
    while (k < n) {
        yield k++;
    }
}
Генератор в Dart

Слово sync на самом деле не нужно, его даже нельзя использовать без звёздочки — оно просто парное к слову async (для асинхронных генераторов используется async*). Но разработчики Dart приняли решение не указывать только звёздочку:

// Генератор
Iterable naturalsTo(n) sync* {}
// Асинхронный генератор
Stream asyncNaturalsTo(n) async* {}
// Асинхронная функция
Future asyncNatural() async {}
// Обычная функция
int natural() {}
Синтаксис функций, асинхронных функций, генераторов и асинхронных генераторов в Dart

Несмотря на то, что есть это странное слово-затычка 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);
    }
}
Пример генератора-лямбды в Dart

Возможно, сообществу JavaScript просто не хватает решимости. Выбрать какой-то вариант синтаксиса  — не самый красивый и последовательный — можно. Реализовать парсер сложно, но тоже можно.

А может и не стоит закрывать эту «логическую дыру» с лямбдами-генераторами просто потому что она есть. Например, в C# лямбд-генераторов тоже нет. Хотя синтаксис вполне позволяет. Просто реализовать их долго и дорого с учётом устройства компилятора C#. По сравнению с другими фичами, лямбды-генераторы не дают достаточно пользы. Поэтому их реализацию откладывают уже больше 10 лет.

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