Кодогенерация: печать в восемь языков
Бэкендов у 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»), поэтому в рантайме лежит собственный NumberText —
strconv.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, компилятор печатает сам себя, и неподвижная точка сошлась.