flang — полный язык поверх FTS Кодогенерация: печать в C, Go, JavaScript, Rust и Python
0%

Кодогенерация: печать в C, Go, JavaScript, Rust и Python

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

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

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

Главное — лимита шагов в напечатанном коде нет. Незавершающаяся обычная функция крутится вечно во всех пяти целях, а не получает FLANG_RECURSION_LIMIT. Предел глубины при этом сохранён, и причина у него разная в разных языках: «в JS переполнение стека даёт исключение, в C — падение процесса», в Rust — убитый процесс, в Python — свой sys.setrecursionlimit, который не имеет права подменить собой предел языка. Каждая функция, лежащая на цикле графа вызовов, считает глубину и на превышении даёт тот же код и текст, что интерпретатор; в Rust и Python вычисление ради этого вынесено в отдельный поток с большим стеком.

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

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

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

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

Дальше — глава «Ядро FTS на flang», где кодогенерация получает свою настоящую цель.

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

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

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

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