Кодогенерация: печать в C, Go, JavaScript, Rust и Python
Бэкендов у flang пять, и их список проверяется у самого инструмента:
$ node flang/bin/flang.mjs emit проба.flang --target zzz
{"error":"неизвестная цель «zzz»; доступны: c, go, js, python, rust", …}
Список берётся не из константы, а из содержимого каталога flang/src/emit.
Решение объяснено в CLI: новый язык подключается тем, что рядом с c.mjs,
go.mjs и js.mjs появляется ещё один модуль, «правок в CLI это не требует ни
при печати, ни в справке, ни в диагностике неизвестной цели».
Устройство немедленно окупилось. Первая редакция этой главы называлась «печать
в C, Go и JavaScript», и в ней стояло «бэкендов три». Через несколько часов их
стало пять: rust.mjs и python.mjs легли в тот же каталог, и справка с
диагностикой подхватили их сами. Правки потребовала эта статья, а не CLI.
Оговорка, важная для тех, кто читал корневой README.md: обещанные там восемь
целевых языков — это tools/ftsc, проектный компилятор FTS-моделей, а не flang
(глава «flang и FTS»). Списки пересекаются
всё сильнее, но это по-прежнему разные списки. И раздел 1 flang/SPEC.md,
упоминающий печать «на C/Rust/Java/…», описывает замысел, а не текущее
состояние: Java там всё ещё нет.
Общее требование: совпадение с интерпретатором
Оно повторено в шапке каждого бэкенда почти дословно: сгенерированный код обязан давать то же значение и ту же ошибку — код и текст. Из этого вытекают решения, которые иначе выглядели бы странно.
- Строгий порядок вычисления слева направо. Каждый узел, способный дать ошибку, материализуется в отдельный оператор — «порядок операторов и есть порядок вычисления». Иначе сосед справа успел бы бросить свою ошибку раньше соседа слева.
- Проценты печатаются как
(процент / 100) * значение. Перестановка множителей меняет последний бит мантиссы. - Сообщения об ошибках копируются буквально, вплоть до кавычек-ёлочек: «код и текст — часть наблюдаемого поведения, а не украшение».
- Строки меряются кодовыми точками, индексация с 1 включительно.
Особенно показателен отказ от чужого решения. Бэкенд 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, то есть в том числе на нашей.
Прогон: от исходника до нативного бинарника
Проверим цепочку целиком. Программа:
модуль «Проба»
тотальная функция «Взять символ»
принимает текст: строка, номер: число
возвращает строка
пример «Второй символ»
дано текст равно "мир"
дано номер равно 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":22960},
{"path":"flang_runtime.c","bytes":55364},
{"path":"proba.h","bytes":2495},
{"path":"proba.c","bytes":2743},
{"path":"flang_cli.c","bytes":22167},
{"path":"Makefile","bytes":606}]}
Обратите внимание на пропорции: рантайм — 78 КБ, сама программа — 5 КБ. Для полного языка рантайм неизбежен: нужны представление значений, арена, UTF-8.
Собираем:
$ 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.
Где печать расходится с интерпретатором
Расхождения есть, они перечислены явно, и ни одно не меняет результат корректной программы.
Главное — лимита шагов в напечатанном коде нет. Незавершающаяся обычная
функция крутится вечно во всех пяти целях, а не получает
FLANG_RECURSION_LIMIT. Предел глубины при этом сохранён, и причина у него
разная в разных языках: «в JS переполнение стека даёт исключение, в C — падение
процесса», в Rust — убитый процесс, в Python — свой sys.setrecursionlimit,
который не имеет права подменить собой предел языка. Каждая функция, лежащая на
цикле графа вызовов, считает глубину и на превышении даёт тот же код и текст,
что интерпретатор; в Rust и Python вычисление ради этого вынесено в отдельный
поток с большим стеком.
Отсюда видно, как класс программы влияет на печать на самом деле. Он не запрещает генерацию — он определяет, что вы получаете: для тотальной функции завершение доказано и вне интерпретатора, для обычной вы полагаетесь на предел глубины и на удачу.
Что здесь стоит отметить
Пять бэкендов для суточного языка — очень много. Но написаны они не «чтобы было»: каждый закрывает названную нишу (браузер и Node, переносимость до RISC-V, статический бинарник в контейнер, встраивание без сборщика мусора, импорт рядом с pandas), и все пять подчинены одному проверяемому требованию — совпадать с интерпретатором по значению и по тексту ошибки.
Именно требование совпадения делает эти файлы читаемыми: почти каждое решение внутри объяснено через него, а не через вкус автора. Для тех, кто проходил трек «Компиляторы и языки», это редкая возможность посмотреть на пять бэкендов одного языка рядом и увидеть, что в них общего, а что продиктовано целевой платформой. Пары получаются поучительные: C и Rust решают одну задачу без сборщика мусора и приходят к разным представлениям значения; Go и Python берут ошибку вторым результатом и исключением соответственно, и от этого расходится форма каждой напечатанной функции.
Дальше — глава «Ядро FTS на flang», где кодогенерация получает свою настоящую цель.