flang — язык с доказуемым завершением Кодогенерация: печать в восемь языков
0%

Кодогенерация: печать в восемь языков

Кодогенерация: печать в восемь языков

Бэкендов у flang восемь, и их список проверяется у самого инструмента:

$ node flang/bin/flang.mjs emit проба.flang --target zzz
{"error":"неизвестная цель «zzz»; доступны: c, csharp, elixir, go, java, js, python, rust", …}

Список берётся не из константы, а из содержимого каталога flang/src/emit. Решение объяснено в CLI: новый язык подключается тем, что рядом с c.mjs, go.mjs и js.mjs появляется ещё один модуль, «правок в CLI это не требует ни при печати, ни в справке, ни в диагностике неизвестной цели».

Устройство окупилось трижды подряд, и это стоит проследить по редакциям этой главы. Первая называлась «печать в C, Go и JavaScript», и в ней стояло «бэкендов три». Через несколько часов их стало пять: rust.mjs и python.mjs легли в тот же каталог. Ещё через сутки в тот же каталог легли java.mjs, csharp.mjs и elixir.mjs — и справка с диагностикой подхватили их сами. Правки каждый раз требовала эта статья, а не CLI.

Оговорка, важная для тех, кто читал корневой README.md: восемь целей есть теперь и у tools/ftsc, проектного компилятора FTS-моделей (глава «flang и FTS»), — но это по-прежнему разные списки одинаковой длины. У ftsc восьмой цели js нет, зато есть typescript; у flang наоборот. Списки сошлись числом, а не составом.

Отстала же теперь сама спецификация: раздел 1 flang/SPEC.md перечисляет воспроизведённый лимит шагов только для «C, Go, Rust и Python», хотя в Java, C# и Elixir он тоже печатается — это проверено ниже. Заголовок таблицы там же обещает печать «на C/Rust/Java/…», и вот это обещание наконец сбылось.

Функции первого класса легли одним проходом — и логики в бэкендах ноль

С прошлой редакции этой главы в языке случилось то, что обычно ломает кодогенерацию целиком: функция стала значением. Пять из восьми целей замыканий в том виде, в каком они есть в JS, не имеют вовсе, и наивный путь — печатать окружения, боксинг и управление их временем жизни — стоил бы восьми разных реализаций.

Не потребовалось ни одной. Способ известен с 1972 года — дефункционализация (Рейнольдс): перед печатью значение-функция заменяется тегом, применение — вызовом диспетчера, и на вход бэкенду приходит первопорядковая программа, какую он печатает с первого дня.

Реализация — один файл, flang/src/defunc.mjs, 315 строк. В каждом из восьми бэкендов стоит по одной строке вызова этого прохода; логики — ноль. Общей точки входа у печати нет (восемь экспортов, которые CLI и тесты зовут напрямую), поэтому строк восемь, и забыть одну молча нельзя: отдельный тест печатает модуль с функциями-значениями во все восемь целей.

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

fl_status vysshiy_poryadok_primenit_1(fl_ctx *ctx, fl_value teg, fl_value a1,
                                      fl_value *result, fl_error *error) {
  if (fl_variant_is(teg, "Удвоить")) {
    return vysshiy_poryadok_udvoit(ctx, a1, result, error);
  } else {
    return fl_match_fail(ctx, teg, error);
  }
}

Структура с тегом и разбор по нему — то есть ровно то, чем печатается любой вариант суммы типов. Тег и есть вариант по смыслу, а не по совпадению: захваченные поля тега — это поля варианта, и в первой фазе полей ноль, потому что захватывать нечего.

Собирается это под -std=c99 -Wall -Wextra -Werror -pedantic и считает:

$ echo '{"fn":"Применить дважды","args":[{"v":"Удвоить","f":[]},{"n":"5"}]}' | ./flang_cli
{"ok":true,"value":{"n":"20"}}

Мы прогнали flang/test/hof-emit.test.mjs целиком: 18 тестов, все зелёные. Напечатанное в C (gcc и clang по отдельности), Java, Elixir, Rust, Python и JavaScript считает то же, что интерпретатор.

Обещание «не трогать то, чего не касается» проверяется одной строкой

Проход обязан быть невидим для программ без функций-значений, и это не декларация: на такой программе он возвращает тот же самый объект, а не равную копию. Проверяется тождеством, и мы проверили сами — прогнали defunctionalize по всем .flang каталога flang/:

файлов .flang в flang/: 56 | тот же объект: 56 | изменён: 0

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

Одно расхождение, и оно записано

Чужой тег — тот, которого программа не строит, — отвергают обе стороны, но разными словами. Интерпретатор и напечатанный код говорят по-разному, потому что «возбудить ошибку с этим текстом» в языке невыразимо. Расхождение названо в flang/cat/HOF.md, а не обнаружено читателем.

Общее требование: совпадение с интерпретатором

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

  • Строгий порядок вычисления слева направо. Каждый узел, способный дать ошибку, материализуется в отдельный оператор — «порядок операторов и есть порядок вычисления». Иначе сосед справа успел бы бросить свою ошибку раньше соседа слева.
  • Проценты печатаются как (процент / 100) * значение. Перестановка множителей меняет последний бит мантиссы.
  • Сообщения об ошибках копируются буквально, вплоть до кавычек-ёлочек: «код и текст — часть наблюдаемого поведения, а не украшение».
  • Строки меряются кодовыми точками, индексация с 1 включительно.

Требование это не декларация в шапке, а тест, и устроен он у всех восьми бэкендов одинаково. Набор программ — не выдуманные фикстуры, а всё, что в репозитории написано на самом flang: flang/stdlib/*.flang и flang/examples/leetcode/*.flang, то есть 31 программа, полторы сотни функций и четверть тысячи примеров. Каждая печатается в пустой каталог, собирается и запускается настоящим процессом ровно из того, что выдал бэкенд — «ничего не подкладывается из репозитория: если бы рантайм работал только потому, что лежит рядом, дыра нашлась бы у первого же пользователя, а не здесь». Сетка входов строится из примеров и из порчи их аргументов заведомо неподходящими значениями — там проверяются коды и тексты диагностик; тест требует, чтобы точек в сетке было не меньше двух тысяч, «иначе сетка слишком редкая».

Обратите внимание на источник сетки: не фантазия автора теста, а объявленные в самих функциях примеры. Приём переносится куда угодно, где у кода есть две реализации и нужно доказать, что они одна и та же.

Особенно показателен отказ от чужого решения. Бэкенд C для FTS-моделей (tools/ftsc) сравнивает числа с допуском — для модели, считающей деньги, это верно. Бэкенд C для flang допуск сознательно не повторил:

flang сравнивает значения по Object.is (SPEC, раздел 5), и «0.1 плюс 0.2 равно 0.3» обязано быть ложью в обоих движках. Допуск сделал бы его истиной — то есть расхождением с интерпретатором.

Требование окупилось на наших глазах

Пока писалась эта статья, требование поймало настоящий дефект — и способ, каким оно его поймало, стоит отдельного абзаца.

Вариант в JSON записан как { variant, fields }: классов JSON не знает, а значения ходят через него в трёх местах — значение примера в AST, --args командной строки и факты для факт-чекинга. Интерпретатор читает эту форму вариантом. Бэкенд Go такой проверки не имел и на любом объекте строил запись. Значит функция, возвращающая вариант литералом, в Go давала запись, и та же программа считалась по-разному в Go и в интерпретаторе — притом молча, потому что запись с теми же полями сравнивается структурно и на простых случаях совпадает.

Найден дефект не тестом бэкенда Go, а сверкой бэкендов между собой: C, Rust, JS и Python эту форму узнавали, Go не узнавал. Пятый бэкенд оказался полезен не тем, что печатает в Python, а тем, что стал пятым независимым свидетелем. Тест на дефект намеренно не требует тулчейна Go — он проверяет напечатанный текст, поэтому работает и на машине без компилятора Go, то есть в том числе на нашей.

Второй случай: дефект нашёлся сразу во всех пяти — и вернулся в трёх новых

Следующая находка пришла с неожиданной стороны — из лексера flang, написанного на самом flang (глава «Ядро FTS на flang»). Чтобы решать, какой символ считать буквой, он несёт выписанную руками таблицу кодов \uXXXX, и в неё попадает блок U+2000…U+207F. Одиннадцать символов оттуда — управляющие символы двунаправленного текста, те самые, которыми делают «троянские» исходники: файл читается человеком не так, как исполняется машиной (Trojan Source, CVE-2021-42574).

Печатались они сырыми — и не в одном бэкенде, а во всех пяти, сколько их тогда было. Дальше цели разошлись, и разошлись поучительно:

Цель Что делает с сырым U+202E в литерале
C gcc 13 под -Wall -Werror отказывается собирать: -Wbidi-chars
Rust rustc отвергает файл ошибкой: «unicode codepoint changing visible direction of text present in literal»
Go, Python, JS печатают молча — то есть выдают ровно тот файл, ради которого атаку и придумали

Первые две цели повели себя лучше остальных трёх: отказ собираться — это диагностика, а молчание — нет. Починено во всех пяти сразу, причём каждой цели своим способом: в C байты экранируются восьмеричными (в C99 у узкой строки нет формы \u), у остальных берётся их собственная форма \u. Байты и кодовые точки при этом те же, поэтому значение программы не меняется — а именно этого требование совпадения и не разрешило бы нарушить. Проверяется это на пробе в пять кодовых точек, две из которых управляющие:

$ node flang/bin/flang.mjs emit bidi.flang --target c   --out bidi-c
$ node flang/bin/flang.mjs emit bidi.flang --target rust --out bidi-rust
$ grep -h 'а' bidi-c/dvunapravlennyy.c bidi-rust/src/dvunapravlennyy.rs
  … { .string = { "а\342\200\256б\342\201\246в", 12, 5 } } };
  return Ok(rt::text("а\u{202e}б\u{2066}в"));

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

А теперь то, ради чего мы прогнали ту же пробу заново. В шапке c.mjs, где объявлен набор из двенадцати управляющих кодов, записано правило на все цели сразу: «печатать их сырыми нельзя ни в одном бэкенде: набор общий, а форма экранирования у каждого языка своя». Три новых бэкенда это правило не унаследовали. Прогоняем пробу по всем восьми целям и ищем в выдаче сырой байт:

$ for t in c go js python rust java csharp elixir; do
    node flang/bin/flang.mjs emit bidi.flang --target $t --out b-$t >/dev/null
    printf '%-8s ' $t
    grep -rqP '\x{202e}' b-$t && echo 'СЫРОЙ U+202E ЕСТЬ' || echo 'сырых нет'
  done
c        сырых нет
go       сырых нет
js       сырых нет
python   сырых нет
rust     сырых нет
java     СЫРОЙ U+202E ЕСТЬ
csharp   СЫРОЙ U+202E ЕСТЬ
elixir   СЫРОЙ U+202E ЕСТЬ

Поиск по каталогу подтверждает разделение начисто: слово bidi встречается ровно в пяти файлах flang/src/emit/c.mjs, go.mjs, js.mjs, python.mjs, rust.mjs, — то есть в тех пяти, которые чинили, и ни в одном из трёх новых. Дальше три цели повели себя так же по-разному, как когда-то первые пять, и лучше всех — самая молодая:

$ cd b-elixir && make build
elixirc --warnings-as-errors -o _build flang_runtime.ex dvunapravlennyy.ex flang_cli.ex

== Compilation error in file dvunapravlennyy.ex ==
** (SyntaxError) invalid syntax found on dvunapravlennyy.ex:44:21:
    error: invalid bidirectional formatting character in string: \u202E.
    If you want to use such character, use it in its escaped \u202E form instead
make: *** [Makefile:18: build] Error 1

Elixir печать не принимает — как gcc и как rustc, и подсказывает ту самую форму, которой не хватает бэкенду. Java принимает и молчит:

$ cd b-java && make build
javac -encoding UTF-8 -Xlint:all -Werror -d . *.java

$ printf '{"fn":"Проба","args":[]}\n' | java -cp . FlangCli Dvunapravlennyy | cat -v
{"ok":true,"value":{"s":"M-PM-0M-bM-^@M-.M-PM-1M-bM-^AM-&M-PM-2"}}

cat -v здесь обязателен — без него в терминале видно ровно ту перестановку символов, ради которой атака и придумана. C# мы проверили только печатью: dotnet на нашей машине нет, и утверждать, как поведёт себя компилятор, мы не станем — в файле Dvunapravlennyy.cs сырой U+202E лежит, а что на него скажет csc, вопрос открытый.

Мораль ровно та, что и в первый раз, только злее: правило, записанное в шапке одного бэкенда, на другие бэкенды не распространяется — распространяется только тест. А тест здесь ровно один и привязан к одной цели: «двунаправленные управляющие экранируются: -Wbidi-chars молчит, значение то же» в flang/test/emit-c.test.mjs. Он перебирает все двенадцать управляющих кодов и требует, чтобы в напечатанном C не было ни одного сырого, — но проверяет он файлы бэкенда C, а не набор бэкендов. Пять целей починили, три новых написали заново — и написали с того же места, с которого начинали пять предыдущих.

Прогон: от исходника до нативного бинарника

Проверим цепочку целиком. Программа:

модуль «Проба»

тотальная функция «Взять символ»
  принимает текст: строка, номер: число
  возвращает строка
  пример «Второй символ»
    дано текст равно "мир"
    дано номер равно 2
    ожидается "и"
  символ номер в текст

тотальная функция «Длина текста»
  принимает текст: строка
  возвращает число
  пример «Эмодзи считается одним»
    дано текст равно "мир 🌍"
    ожидается 5
  длина текст

Интерпретатор: flang test — оба примера проходят.

JavaScript

$ node flang/bin/flang.mjs emit проба.flang --target js --out out-js
{"target":"js","module":"Проба","files":[{"path":"proba.js","bytes":4641}]}

Один файл, ES-модуль без зависимостей. Имена транслитерированы:

$ node -e 'import("./out-js/proba.js").then(m => {
    console.log(Object.keys(m).join(", "))
    console.log(m.dlinaTeksta("мир 🌍"), m.vzyatSimvol("мир", 2))
  })'
dlinaTeksta, vzyatSimvol
5 и

Пять кодовых точек, а не восемь единиц UTF-16 — семантика перенесена верно.

Почему один файл на программу, объяснено: AST описывает ровно один модуль, а функции внутри свободно вызывают друг друга, в том числе взаимно рекурсивно; разложить их по файлам значило бы «завести циклические импорты ради ничего».

C

$ node flang/bin/flang.mjs emit проба.flang --target c --out out-c
{"target":"c","module":"Проба","files":[
  {"path":"flang_runtime.h","bytes":29120},
  {"path":"flang_runtime.c","bytes":68604},
  {"path":"proba.h","bytes":2495},
  {"path":"proba.c","bytes":2743},
  {"path":"flang_cli.c","bytes":22522},
  {"path":"Makefile","bytes":606}]}

Обратите внимание на пропорции: рантайм — 95 КБ, сама программа — 5 КБ. Для полного языка рантайм неизбежен: нужны представление значений, арена, UTF-8. Рантайм, кстати, за двое суток подрос с 87 КБ — это цена счётчика шагов и линейного добавить, о которых ниже и в главе «Ядро FTS на flang».

Собираем:

$ make
cc -std=c99 -Wall -Wextra -Werror -pedantic -O2   -c -o flang_runtime.o flang_runtime.c
cc -std=c99 -Wall -Wextra -Werror -pedantic -O2   -c -o proba.o proba.c
ar rcs libproba.a flang_runtime.o proba.o
cc -std=c99 -Wall -Wextra -Werror -pedantic -O2   -c -o flang_cli.o flang_cli.c
cc -std=c99 -Wall -Wextra -Werror -pedantic -O2 -o flang_cli flang_cli.o flang_runtime.o proba.o -lm

Флаги — часть контракта бэкенда: в самом Makefile написано, что сгенерированный код обязан собираться без единого предупреждения. С -Werror и -pedantic собрался.

Прогонщик читает построчный JSON со стандартного ввода. Формат тегированный — скаляры едут как {"s": …} и {"n": …}, числа в виде строк, чтобы double пережил дорогу без потерь:

$ printf '{"fn":"Длина текста","args":[{"s":"мир 🌍"}]}\n{"fn":"Взять символ","args":[{"s":"мир"},{"n":"2"}]}\n' | ./flang_cli
{"ok":true,"value":{"n":"5"}}
{"ok":true,"value":{"s":"и"}}

Те же ответы, что у интерпретатора и у JS-модуля. Цепочка «исходник → C → нативный бинарник → тот же результат» замкнулась.

Зачем вообще C, сказано в шапке бэкенда: печать в JS даёт модуль для Node и браузера, но программа должна работать и там, где Node нет и не будет — «NetBSD, RISC-V, любой POSIX». И отдельно: «Он же первый шаг к самохостингу: компилятор flang, написанный на flang, соберётся через C» (глава «Ядро FTS на flang»).

Go

$ node flang/bin/flang.mjs emit проба.flang --target go --out out-go
{"target":"go","module":"Проба","files":[
  {"path":"go.mod","bytes":29},
  {"path":"flangrt/flang_runtime.go","bytes":44868},
  {"path":"flang/proba.go","bytes":3789},
  {"path":"cli/main.go","bytes":9654},
  {"path":"Makefile","bytes":445}]}

Go на нашей машине не установлен, поэтому сборку мы не проверяли — печать проверили, компиляцию нет. Ниша бэкенда описана как «один статически слинкованный файл без рантайма снаружи, кросс-компиляция под любую платформу одной переменной окружения и сборщик мусора».

Шапка go.mjs — лучший в репозитории пример разбора «где расходятся сами языки». Шесть пунктов, из которых стоит привести три.

Суммы типов. В Go нет ни объединений, ни сумм. Выбор стоял между структурой с тегом и интерфейсом с методом-маркером; выбрана структура. Обоснование: интерфейс «читался бы приятнее ровно до первого требования SPEC — структурное равенство любых двух значений, сообщение „разбор не покрывает значение…“, доступ к полю по имени, приём значения снаружи. Всё это потребовало бы рефлексии, то есть второго, теневого представления рядом с первым, а два представления одного значения — это два набора расхождений с интерпретатором».

Числа. Все числа flang — double, значит везде float64. Но печать числа обязана давать тот же текст, что Number::toString («1», а не «1.0»; «1e+21», а не «1000000000000000000000»), поэтому в рантайме лежит собственный NumberTextstrconv.FormatFloat с 'g' не годится, у него свои пороги перехода к экспоненте.

Деление на ноль. В Go деление float64 на ноль даёт ±Inf, ровно как в JS, — но только для переменных: деление константы на константный ноль Go не компилирует вовсе. Поэтому арифметика идёт через функции рантайма, а не печатается оператором на месте.

Rust

$ node flang/bin/flang.mjs emit проба.flang --target rust --out out-rs
{"target":"rust","module":"Проба","files":[
  {"path":"Cargo.toml","bytes":810},
  {"path":"src/runtime.rs","bytes":58471},
  {"path":"src/proba.rs","bytes":4678},
  {"path":"src/lib.rs","bytes":1392},
  {"path":"src/cli.rs","bytes":20431},
  {"path":"src/main.rs","bytes":668},
  {"path":"Makefile","bytes":481}]}

Rust на нашей машине есть, поэтому цепочку проверили целиком. Флаги сборки — часть контракта бэкенда: в Makefile записано, что напечатанный код обязан собираться «под -D warnings без единого замечания».

$ make build
RUSTFLAGS='-D warnings' cargo build --offline
   Compiling flangprogram v0.0.0 (…/out-rs)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.81s

$ printf '{"fn":"Длина текста","args":[{"s":"мир 🌍"}]}\n{"fn":"Взять символ","args":[{"s":"мир"},{"n":"2"}]}\n' \
    | ./target/debug/flang_cli
{"ok":true,"value":{"n":"5"}}
{"ok":true,"value":{"s":"и"}}

Собралось на rustc 1.96.0 без предупреждений, ответы совпали с интерпретатором, с JS-модулем и с нативным бинарником из C.

Ниша названа в шапке rust.mjs и не повторяет ни одну из трёх прежних: «встраивание в чужую программу без сборщика мусора и без рантайма — библиотека, которую линкуют в приложение, в ядро WebAssembly или в расширение базы данных».

Три решения стоит отметить, потому что все три приняты против первого побуждения.

Суммы типов — одно динамическое представление, а не enum на каждую сумму. У Rust, в отличие от C и Go, настоящие суммы есть, и соблазн печатать каждый тип «…» своим enum велик. Отвергнут по тому же доводу, по которому в Go отвергнут интерфейс с методом-маркером: SPEC требует структурного равенства любых двух значений, доступа к полю по имени (имя приезжает из AST строкой) и приёма значения снаружи. В Rust это либо dyn Any с нисходящим приведением, либо крейт сериализации — то есть второе, теневое представление рядом с первым. Выигрыш от enum при этом всё же взят: «в Go тег и поля лежали в одной структуре и „число со списком внутри“ было представимо, здесь недопустимое состояние не выражается вовсе».

Rc, а не Box. Причина не в удобстве: хвост обязан отдавать суффикс списка без копирования, иначе рекурсия «голова и хвост» становится квадратичной, а при единственном владении суффикс пришлось бы копировать. Следствие важнее причины — клонирование стоит один инкремент счётчика, поэтому в напечатанном коде нет ни одного времени жизни и ни одной борьбы с проверкой заимствований.

Паника недопустима. Ни unwrap, ни индексации вычисленным индексом: ошибка обязана быть значением, иначе коды разойдутся с интерпретатором. Переполнение стека в Rust — не паника, а убитый процесс, поэтому вычисление идёт в отдельном потоке со стеком в 512 МиБ: предел глубины должен сработать раньше, чем кончится стек, иначе вместо FLANG_RECURSION_LIMIT пользователь получил бы обрезанный вывод.

Известная неполнота: контракта форматера у Rust нет. Тест бэкенда Go гоняет напечатанный код через gofmt -l и go vet; в тесте бэкенда Rust слова rustfmt нет вовсе — там проверяется сборка под -D warnings и совпадение с интерпретатором, но не нормализованность печати.

Python

$ node flang/bin/flang.mjs emit проба.flang --target python --out out-py
{"target":"python","module":"Проба","files":[
  {"path":"flang_runtime.py","bytes":48971},
  {"path":"proba.py","bytes":3847},
  {"path":"flang_cli.py","bytes":9568},
  {"path":"Makefile","bytes":760}]}
$ printf '{"fn":"Длина текста","args":[{"s":"мир 🌍"}]}\n{"fn":"Взять символ","args":[{"s":"мир"},{"n":"2"}]}\n' \
    | python3 -B flang_cli.py proba
{"ok":true,"value":{"n":"5"}}
{"ok":true,"value":{"s":"и"}}

Прогон шёл на Python 3.12.3. Ниша сформулирована без пафоса: «язык, который уже стоит на машине аналитика, научного работника и администратора, и в который программу flang нужно не „встроить“, а просто импортировать — вместе с pandas, Jupyter и всем остальным, что там уже есть».

Родные типы Python отвергнуты в пользу одного размеченного значения, и причины перечислены по пунктам.

Числа. В Python есть int неограниченной точности, и он заразен: len(x) даёт int, 2 ** 70 — точное целое, которого в IEEE-754 нет. Поэтому число flang всегда float, а печать идёт через rt.number_text по правилам Number::toString: repr(1.0) даёт «1.0», а Number::toString(1) — «1», и разницу видно пользователю через к строке.

Равенство. Object.is и == расходятся в обе стороны сразу: nan == nan ложно, 0.0 == -0.0 истинно, а True == 1 истинно, потому что bool в Python — подтип int. Отсюда своё равенство rt.equal, а не родное.

Деление на ноль. Единственное место, где Python расходится с flang не представлением, а поведением: 1.0 / 0.0 возбуждает ZeroDivisionError, а SPEC требует ±Infinity. Ловится в рантайме — и ровно поэтому арифметика не печатается операторами на месте.

Порядок вычисления. Ошибки в Python — исключения, поэтому там, где правый операнд требует собственных операторов (условие, разбор, цикл), левый принудительно материализуется во временное до них. Иначе первая ошибка оказалась бы правой.

Отдельно — про имена. Модуль Python это одно пространство имён на все объявления верхнего уровня, и повторное def не ошибка, а молчаливое затирание. Поэтому роль входит в каждый идентификатор верхнего уровня, и это видно в напечатанном файле:

$ grep '^def ' out-py/proba.py
def new_context():
def fn_vzyat_simvol(ctx, tekst, nomer):
def fn_dlina_teksta(ctx, tekst):
def call(ctx, name, args):

Префикс fn_ у функции, v_ у конструктора варианта, rec_ у записи, step_ у шага батута. Дефект, найденный раньше в бэкенде C — модель, где одно имя несёт три разные роли, — здесь был бы не ошибкой сборки, а тихой потерей одной из трёх.

Известная неполнота: срез списка в Python создаёт новый список, поэтому рекурсия «голова и хвост» квадратична — как в JS и в отличие от Go.

Java

$ node flang/bin/flang.mjs emit проба.flang --target java --out out-java
{"target":"java","module":"Проба","files":[
  {"path":"Value.java","bytes":21988},
  {"path":"Field.java","bytes":1615},
  {"path":"FlangError.java","bytes":4916},
  {"path":"Ctx.java","bytes":5672},
  {"path":"Flang.java","bytes":35533},
  {"path":"Proba.java","bytes":4570},
  {"path":"FlangCli.java","bytes":21959},
  {"path":"Makefile","bytes":1074}]}

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

JDK на нашей машине есть, поэтому цепочку проверили до конца:

$ make build
javac -encoding UTF-8 -Xlint:all -Werror -d . *.java

$ printf '{"fn":"Длина текста","args":[{"s":"мир 🌍"}]}\n{"fn":"Взять символ","args":[{"s":"мир"},{"n":"2"}]}\n' \
    | java -cp . FlangCli Proba
{"ok":true,"value":{"n":"5"}}
{"ok":true,"value":{"s":"и"}}

Собралось на javac 25.0.3 под -Xlint:all -Werror без замечаний, ответы те же, что у интерпретатора и у остальных целей. Флаг -encoding UTF-8 в Makefile обязателен и объяснён там же: имена в напечатанном коде кириллические, а кодировка исходника по умолчанию зависит от локали машины.

Ниша названа в шапке java.mjs и не повторяет ни одну прежнюю: «язык, на котором написан слой предметной логики в банке, страховой и торговой системе, — то есть ровно там, где живут модели FTS. Программу flang туда нужно не „встроить через процесс“, а положить классом рядом с прочими и вызвать методом».

Три решения стоит отметить.

Проверяемых исключений нет. Java — единственная цель, где у автора вообще был выбор, и выбран непроверяемый FlangError. Довод записан: ошибку языка умеет дать любая операция, вплоть до сложения, поэтому throws FlangError стояло бы на каждой напечатанной функции, ничего не сообщая, — зато сделало бы напечатанный код непригодным для лямбды со сторонней сигнатурой (Stream.map, Comparator).

Числа совпадают даром. Число flang — IEEE-754 double, и double в Java ровно он же: деление на ноль даёт ±Infinity, % — оператор ECMAScript дословно. В шапке это названо «совпадением, а не уступкой»: арифметика сходится с ядром сама по себе — в отличие от Python, где деление на ноль возбуждает исключение, и Go, где целые заразны. Поправлять пришлось только печать числа и равенство, обе поправки лежат в Value.java.

Недостижимый оператор — ошибка компиляции. Здесь Java строже C. Функция, у которой все хвостовые позиции — самовызов, разворачивается в while (true), и любой оператор после такого цикла javac отвергает. Поэтому после хвостового цикла не печатается ничего, и функция даже не объявляет переменную результата, в которую ни разу не пишет. Это ровно тот дефект, который в C ломал сборку под -Werror неиспользованным result, — только здесь язык не даёт его допустить.

C#

$ node flang/bin/flang.mjs emit проба.flang --target csharp --out out-cs
{"target":"csharp","module":"Проба","files":[
  {"path":"Value.cs","bytes":23673},
  {"path":"Field.cs","bytes":2096},
  {"path":"FlangError.cs","bytes":3523},
  {"path":"Ctx.cs","bytes":5983},
  {"path":"Flang.cs","bytes":40846},
  {"path":"Proba.cs","bytes":4835},
  {"path":"FlangCli.cs","bytes":23802},
  {"path":"flang.csproj","bytes":747},
  {"path":"Makefile","bytes":742}]}

Файл проекта тут третий в ряду — после go.mod и Cargo.toml: flang.csproj нужен dotnet build, и в нём же включён <Nullable>enable</Nullable>. У Java и Elixir проектного файла нет вовсе, и оба раза это записанное решение: javac *.java собирает каталог сам, а mix потребовал бы структуры проекта там, где хватает трёх файлов рядом.

dotnet на нашей машине нет, поэтому честно: печать проверили, сборку нет. Ниша — «та же, что у Java, на другой половине корпоративного мира: там, где предметная логика написана под .NET».

Интереснее всего в шапке csharp.mjs то, чего бэкенд не сделал, хотя мог.

Value — класс, а не структура. У C# есть значимые типы, которых нет в Java, и соблазн велик: значения неизменяемы, живут недолго, передаются повсюду. Отвергнуто по трём причинам сразу — у структуры всегда есть состояние по умолчанию (все поля в нулях), которое выглядит как «ничто», но роняет всё, что его прочитает; сорок байт копии дороже восьми байт ссылки на каждой из десятков тысяч передач за вызов; и значение всё равно рекурсивно, так что от кучи не уйти. А вот Field структурой сделан: пара ссылок, состояния по умолчанию не бывает, рекурсии нет.

decimal не годится. Для предметной области, где считают деньги, decimal выглядит правильным выбором — и в бэкенде ftsc → C# он им и является. Здесь отвергнут: это база 10 с 28 значащими цифрами, а не IEEE-754; ни NaN, ни бесконечностей в нём нет, а 0.1 плюс 0.2 дало бы ровно 0.3. То же рассуждение, что про допуск в бэкенде C, только с другой стороны.

Роль здешнего линтера играет TreatWarningsAsErrors, и она ловит три вещи: недостижимый код (CS0162 — тот же случай, что в Java), неприсвоенную переменную (CS0165 — из-за него у цепочки образцов всегда есть последняя ветвь) и связанное, но неиспользованное имя, которое уходит в отбрасывающее присваивание _ = …. Выбросить само связывание нельзя: у варианта оно обязано сходить за полем и дать FLANG_UNKNOWN_NAME, если поля нет.

Elixir

$ node flang/bin/flang.mjs emit проба.flang --target elixir --out out-ex
{"target":"elixir","module":"Проба","files":[
  {"path":"flang_runtime.ex","bytes":53168},
  {"path":"proba.ex","bytes":4467},
  {"path":"flang_cli.ex","bytes":15528},
  {"path":"Makefile","bytes":1036}]}

mix.exs не печатается, и это записанное решение: зависимостей нет ни одной, а mix потребовал бы структуры проекта там, где хватает трёх файлов рядом.

Elixir на машине есть, цепочку проверили целиком:

$ make build
elixirc --warnings-as-errors -o _build flang_runtime.ex proba.ex flang_cli.ex

$ printf '{"fn":"Длина текста","args":[{"s":"мир 🌍"}]}\n{"fn":"Взять символ","args":[{"s":"мир"},{"n":"2"}]}\n' \
    | elixir -pa _build -e 'Flang.Cli.main(["Proba"])'
{"ok":true,"value":{"n":"5"}}
{"ok":true,"value":{"s":"и"}}

Собралось под --warnings-as-errors на Elixir 1.20.2 / OTP 29. Ниша: «программу flang нужно поместить в живую систему, которая уже работает и которую нельзя останавливать, — и вызывать её из тысяч процессов одновременно, не думая ни о разделяемом состоянии, ни о блокировках, потому что и того, и другого здесь нет по устройству».

Эта цель расходится с остальными сильнее всех — и расходится в обе стороны, что и делает её самой интересной для чтения.

Что достаётся даром и потому не печатается вовсе:

  • неизменяемость. Во всех прочих целях договор о неизменяемости приходится соблюдать вручную; здесь это свойство языка, и хвост перестаёт копировать — список Elixir односвязный, его хвост это тот же список без первой ячейки. Рекурсия «голова и хвост» выходит линейной там, где у интерпретатора и у остальных бэкендов она квадратична;
  • сопоставление с образцом — разбор печатается через cond, а не цепочкой if с ручной проверкой дискриминанта;
  • хвостовые вызовы — их гарантирует машина. Здесь нет ни цикла (for (;;), while (true), loop { … } — по вкусу целевого языка), в который хвостовой самовызов разворачивают все семь остальных целей, ни батута, которым они же разворачивают взаимную хвостовую рекурсию. Функция вызывает себя напрямую и идёт в постоянной глубине — как у интерпретатора, и без единой строчки, которая бы это устраивала.

Что приходится печатать заново — и вот это неожиданно:

IEEE-754 в Elixir не выражается. Не «целые заразны», как в Python, а хуже: на BEAM невозможно получить значение с плавающей точкой, равное NaN или бесконечности — машина их не возвращает, а возбуждает ArithmeticError, в том числе на 1.0 / 0.0. А SPEC требует, чтобы деление на ноль давало значение. Поэтому число flang здесь — float() для конечных значений и один из атомов :nan, :inf, :ninf для остальных, а вся арифметика идёт через рантайм.

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

Счётчики живут в словаре процесса. Изменяемого состояния в Elixir нет, а контекст вычисления изменяем по природе; протаскивать его через каждое выражение значило бы удвоить размер напечатанного кода. Словарь процесса — единственное место BEAM, где изменяемость локальна процессу и не требует ни сервера, ни передачи состояния.

И одна цена, которую пришлось заплатить за подсчёт глубины: try/after ломает хвостовые вызовы. Поэтому тело функции, считающей глубину, печатается отдельной приватной функцией loop_…, а публичная только считает глубину вокруг неё — хвостовой самовызов идёт в приватную и остаётся хвостовым.

Процессы flang поехали на настоящей BEAM

Это самое крупное, что случилось с бэкендами за сутки, и оно случилось только у Elixir.

У языка появились процессы и надзор (глава «Синтаксис по факту»). Написать под них собственную виртуальную машину — работа на годы; вместо этого решение записано одной фразой: свою BEAM писать не надо — надо в неё печатать. Отображение получилось почти дословное:

flang Elixir/OTP
процесс GenServer, зарегистрированный под именем процесса
состояние процесса состояние GenServer
обработчик чистая функция flang, вызываемая из handle_info/handle_cast
почтовый ящик почтовый ящик процесса BEAM
отправить send
отложить send самому себе — в хвост, то есть за уже пришедшие
продолжить {:noreply, …, {:continue, …}}
через … отправить Process.send_after
остановить «норма» {:stop, :normal, …}
надзор дерево супервизоров OTP
порог отказов max_restarts/max_seconds супервизора

Одно место придумывать всё-таки пришлось, и оно поучительно. Стратегия в flang названа за каждым ребёнком; у OTP по ребёнку настраивается только вид перезапуска, а «уйти наверх» — свойство супервизора: он уходит, лишь исчерпав свою интенсивность. Выразить «передать выше» настройкой ребёнка нечем. Поэтому на один надзор flang печатаются три супервизора OTP:

S_верх  (интенсивность 0 — любой перезапуск здесь означает «выше»)
  ├── S_выше  (интенсивность 0)          ← дети «передать выше»
  ├── S_снова (интенсивность = порог)    ← дети «перезапустить»
  └── дети «остановить», :temporary, напрямую

Сверка не по значению, а по множеству допустимых исходов

Вот ради чего этот раздел стоит читать, даже если BEAM вам безразлична.

У всех остальных бэкендов сверка простая: на одном входе напечатанный код обязан дать то же значение и ту же ошибку, что интерпретатор. Здесь так нельзя. Семантика модели — любое чередование атомарных пробегов, и «то же самое» для конкурентной программы значит не одно значение, а набор возможных исходов. Планировщик BEAM выбирает чередование сам, семени у него нет, и требовать от него исхода с семени 4172 было бы требованием, которого модель не предъявляет.

Поэтому сверка устроена так: эталон прогоняется на сетке из тысячи семян на каждый прогон, из итогов собирается множество (состояния всех процессов, кто остался жив, исход программы, список отказов), напечатанное собирается настоящим elixirc и запускается на настоящей BEAM много раз, и каждый исход с BEAM обязан лежать в множестве эталона — не «быть похожим», а лежать.

У такой сверки два очевидных слабых места, и оба закрыты отдельными тестами, что и делает её сверкой, а не ритуалом:

  • множество может быть ненасыщенным — тогда исход выпадет не потому, что печать неверна, а потому, что семян не хватило. Проверка: набор с половины сетки обязан совпасть с набором со всей;
  • множество может быть слишком широким — тогда в него попадёт что угодно. Проверок на зубы две: у любого исхода достаточно изменить одно число, и он из набора выпадает; и наборы двух прогонов одной программы с разными входными сообщениями не пересекаются ни в одной точке.

Мы прогнали этот тест целиком. Elixir на машине есть, и вот что он показал:

$ node --test flang/test/emit-elixir-conc.test.mjs
✔ напечатанное на BEAM не выходит за набор эталона: ни исходом, ни чередованием (21090 ms)
ℹ программ: 6, прогонов: 11, запусков на BEAM: 275, сверенных журналов: 250
ℹ    (различных чередований у эталона: 178), семян у эталона на прогон: 1000, за 21 с
✔ набор исходов эталона насыщен: половина сетки даёт тот же набор
✔ сверка имеет зубы: одно изменённое число выводит исход из набора
✔ наборы разных прогонов не пересекаются: набор — свойство прогона, а не программы
ℹ tests 7 · pass 7 · fail 0

Шесть программ, одиннадцать прогонов, 275 запусков на настоящей BEAM, 250 сверенных журналов доставок, 178 различных чередований у эталона, тысяча семян на прогон — двадцать одна секунда.

Особенно стоит отметить третью строку: проверка на зубы. Тест, который сравнивает результат с множеством, обязан доказать, что множество не всеядно, — иначе он зелёный всегда. Здесь это сделано прямо: берётся исход из набора, меняется одно число, и он обязан выпасть.

Ящик пришлось спасать руками, и одна дырка осталась

Контракт решает иначе, чем BEAM: перезапуск трогает только состояние, ящик остаётся. На BEAM ящик умирает вместе с процессом, поэтому обещание печатается явно — процесс в terminate/2 вычерпывает остаток ящика в хранилище, следующее воплощение забирает его в init/1 и досылает себе.

Дырка названа честно: между смертью процесса и регистрацией нового воплощения имя не зарегистрировано, и сообщение, отправленное ровно в это окно, пропадает. У эталона такого окна нет — там процесс запись в таблице. Окно измеряется микросекундами и в примерах не проявилось, но оно есть, и закрыть его нечем, пока имя процесса — имя BEAM.

Печати конкурентности в остальные семь целей нет, планировщика в рантайме C нет, распределённости нет, породить не сделан. Это записано в контракте отдельным списком, а не подразумевается.

Где печать расходится с интерпретатором

Расхождения есть, они перечислены явно, и ни одно не меняет результат корректной программы.

Главное — лимит шагов воспроизведён не везде. Это единственное место, где восемь бэкендов ведут себя по-разному, и разница видна ровно на тех программах, которые не завершаются. Возьмём ту, что мы уже гоняли в главе «Интерпретатор»:

функция «Вечность»
  принимает н: число
  возвращает число
  «Вечность» отплюс 1)

Интерпретатор отвечает FLANG_RECURSION_LIMIT, «исчерпала лимит шагов (1000000) на глубине вызовов 1». Печатаем её во все восемь целей:

Цель Что происходит
C, Rust, Python, Java, Elixir собрали и запустили: тот же код, то же сообщение, тот же миллион шагов
Go, C# лимит стоит в напечатанном коде; собрать нечем — ни go, ни dotnet на машине нет
JS крутится вечно, и это решение, а не долг

У семи целей из восьми лимит стоит прямо в тексте напечатанной программы: #define FL_MAX_STEPS 1000000 в C, ctx.MaxSteps = 1000000 в Go и C#, ctx.set_max_steps(1000000) в Rust, ctx.max_steps = 1000000 в Python, ctx.maxSteps = 1000000L в Java и Flang.Rt.new_context(…, 1000000) в Elixir. Проверить это можно, не собирая ничего, — просто поискав число в выдаче:

$ for t in c go js python rust java csharp elixir; do
    node flang/bin/flang.mjs emit вечность.flang --target $t --out v-$t >/dev/null
    printf '%-8s ' $t; grep -rlq 1000000 v-$t && echo 'лимит есть' || echo 'лимита нет'
  done
c        лимит есть
go       лимит есть
js       лимита нет
python   лимит есть
rust     лимит есть
java     лимит есть
csharp   лимит есть
elixir   лимит есть

У пяти целей, которые мы смогли собрать, ответ приходит дословно один и тот же:

$ printf '{"fn":"Вечность","args":[{"n":"0"}]}\n' | ./flang_cli
{"ok":false,"code":"FLANG_RECURSION_LIMIT",
 "message":"функция «Вечность» исчерпала лимит шагов (1000000) на глубине вызовов 1"}

Это ответ нативного бинарника из C; байт в байт та же строка приходит из собранных Rust, Python, Java и Elixir — из JVM и из BEAM в том числе. Обратите внимание, чего здесь не видно: ни одного слова про особенности целевого языка. Это и есть проверяемая часть требования — код и текст ошибки одинаковы, откуда бы ответ ни пришёл.

Строка про C — самая новая в этой главе, и в прошлой её редакции стояло ровно обратное: «в C и в печатаемом JS лимита шагов нет». Разбор — ниже, вместе со второй половиной той же истории.

В печатаемом JS такой строки нет по-прежнему, но там это записанное решение, а не долг, и шапка flang/src/emit/js.mjs объясняет разницу: «Счётчик шагов вокруг каждой операции стоил бы и скорости, и читаемости, а смысл печати — обычный модуль. Незавершающаяся обычная функция в JS даст зависание или RangeError, а не FLANG_RECURSION_LIMIT». Модуль JS у нас честно висел, пока мы его не сняли.

Предел глубины сохранён везде, включая C, и причина у него в каждом языке своя: «в JS переполнение стека даёт исключение, в C — падение процесса», в Rust — убитый процесс, в Python — свой sys.setrecursionlimit, который не имеет права подменить собой предел языка, в Java — StackOverflowError, который не диагностика, а Error. Каждая функция, лежащая на цикле графа вызовов, считает глубину и на превышении даёт тот же код и текст, что интерпретатор; в Rust, Python и Java вычисление ради этого вынесено в отдельный поток с большим стеком (thread::Builder::new().stack_size(…), threading.stack_size(…), Flang.withDeepStack). Elixir здесь снова особняком: у BEAM нет стека фиксированного размера — он растёт в куче, — поэтому переполнения там не случается вовсе, и границу ставит только счёт.

Дыра в C: сначала не собиралось, потом не останавливалось

В прошлой редакции здесь стояло, что в C эта программа даже не собирается. Стояло по факту: печать проходила, а make — нет.

$ make
zaciklivanie.c:13:80: error: unused parameter ‘result’ [-Werror=unused-parameter]
   13 | static fl_status zaciklivanie_vechnost_body(fl_ctx *ctx, fl_value n,
      |                                             fl_value *result, fl_error *error) {
cc1: all warnings being treated as errors

Причина видна в напечатанном теле: хвостовой самовызов развёрнут в for (;;) без единого break, поэтому в *result не пишется ничего — писать нечего, функция не возвращается никогда. Компилятору это неотличимо от забытого параметра, а флаги -Wall -Wextra -Werror -pedantic бэкенд ставит себе сам и называет их частью контракта.

Это исправлено, и починка вскрыла дефект посерьёзнее. Мест печати оказалось три — тело обычной функции, тело под счётчиком глубины и шаг батута, — а починено раньше было одно (про батут написано в flang/self/SPEC.md, см. главу «Ядро FTS на flang»). Теперь закрыты все три: в напечатанном теле стоит (void)result;, и та же программа собирается на тех же флагах без замечаний.

А как только она собралась, стало видно, чем она занята: ничем и бесконечно. Предел глубины её не ловил — хвостовая рекурсия глубину не растит, — а лимита шагов в C не было вовсе. То есть первый дефект прикрывал второй: пока печать не собиралась, никто не замечал, что она ещё и не останавливается.

Теперь шаг считается в трёх местах — вход в функцию, оборот цикла хвостового самовызова и отскок батута, — ровно как в бэкенде Go. Тонкость, которую шапка c.mjs оговаривает отдельно, стоит того, чтобы её понять:

Шаг интерпретатора мельче (итерация его машины), так что счётчик здесь всегда МЕНЬШЕ, и при одинаковом пределе интерпретатор упирается первым: расхождение одностороннее и безопасное — напечатанный код не объявит исчерпанным то, что интерпретатор досчитал до конца.

Одностороннее расхождение — это и есть образец того, как в таком проекте разрешают неустранимую разницу. Совпасть посчитанными шагами два разных исполнителя не могут в принципе. Но из двух возможных направлений ошибки одно безопасно (напечатанный код досчитает то, что интерпретатор бросил), а второе нет (напечатанный код бросит то, что интерпретатор досчитал), — и бэкенд устроен так, чтобы промахиваться только в безопасную сторону.

Заодно у flang emit появился ключ --max-steps, и предел перестал быть зашитым числом:

$ node flang/bin/flang.mjs emit вечность.flang --target c --out out-c --max-steps 4242
$ cd out-c && make && printf '{"fn":"Вечность","args":[{"n":"0"}]}\n' | ./flang_cli
{"ok":false,"code":"FLANG_RECURSION_LIMIT",
 "message":"функция «Вечность» исчерпала лимит шагов (4242) на глубине вызовов 1"}

Что из этого следует про классы программ

Отсюда видно, как класс программы влияет на печать на самом деле. Он её не запрещает: обычная функция — законная часть языка, и печатается она во все восемь целей. Раздел 1 flang/SPEC.md это признаёт прямо, отдельно оговаривая, что первая редакция таблицы обещала «только JS/TS» и обещала неверно.

Класс определяет другое — что вы получаете на выходе. Для тотальной функции завершение доказано и вне интерпретатора. Для обычной — вы полагаетесь на лимит шагов там, где он воспроизведён, на предел глубины там, где не воспроизведён, и на удачу там, где не срабатывает ни то, ни другое.

Что здесь стоит отметить

Восемь бэкендов для языка, которому идут вторые сутки, — очень много. Но написаны они не «чтобы было»: каждый закрывает названную нишу (браузер и Node, переносимость до RISC-V, статический бинарник в контейнер, встраивание без сборщика мусора, импорт рядом с pandas, предметный слой банковской системы на JVM и на .NET, живая система на BEAM, которую нельзя останавливать), и все восемь подчинены одному проверяемому требованию — совпадать с интерпретатором по значению и по тексту ошибки.

Именно требование совпадения делает эти файлы читаемыми: почти каждое решение внутри объяснено через него, а не через вкус автора. Для тех, кто проходил трек «Компиляторы и языки», это редкая возможность посмотреть на восемь бэкендов одного языка рядом и увидеть, что в них общего, а что продиктовано целевой платформой. Пары получаются поучительные: C и Rust решают одну задачу без сборщика мусора и приходят к разным представлениям значения; Go и Python берут ошибку вторым результатом и исключением соответственно, и от этого расходится форма каждой напечатанной функции; Java и C# написаны почти одинаково и расходятся ровно в четырёх местах, каждое из которых названо.

Ещё одно наблюдение, которое стоит унести из этой главы отдельно от flang. Все восемь бэкендов пришли к одному представлению значения — тег плюс полезная нагрузка, — хотя Rust умеет суммы типов сам и просился бы на enum, а Java 25 предложила бы запечатанный интерфейс с записями. Причина каждый раз одна и та же и каждый раз записана: у напечатанного кода нет статических типов, потому что их нет у входа. Полезно это не как факт про flang, а как приём — решение, принятое восемь раз независимо и получившееся одинаковым, это уже не вкус автора.

И третье, самое практичное. Три дефекта этой главы — вариант, ставший записью в Go; сырые двунаправленные символы во всех пяти тогдашних целях; они же, вернувшиеся в трёх новых, — найдены не тестом одного бэкенда, а тем, что бэкендов несколько и они обязаны совпадать. Восьмая цель полезна не тем, что печатает в Elixir, а тем, что она восьмой независимый свидетель. И тем же самым она опасна: свидетель, которого не спросили, ничего не покажет — правило про двунаправленные символы жило в пяти шапках и не жило ни в одном общем тесте, поэтому три новых бэкенда его и не унаследовали.

Дальше — глава «Ядро FTS на flang», где кодогенерация получает свою настоящую цель: печать в C — это единственный путь, которым язык может перестать зависеть от Node. К моменту этой редакции путь пройден до конца: бэкенд C переписан на flang, компилятор печатает сам себя, и неподвижная точка сошлась.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов
Дальше