Восемь языков: одна запись, восемь способов её получить
Записи из всех восьми языков ложатся в один и тот же файл и разбираются одной и той же командой. Различается то, как запись в код попадает, и три мелочи в самих значениях. Разберём и то и другое на настоящих прогонах.
По каким расширениям что подхватывается
Язык определяется по расширению файла:
| язык | расширения |
|---|---|
| Python | .py |
| JavaScript и TypeScript | .js .mjs .cjs .jsx .ts .tsx |
| C | .c .h |
| C++ | .cc .cpp .cxx .hh .hpp .hxx |
| Elixir | .ex .exs |
| Go | .go |
| Java | .java |
| C# | .cs |
Проверить список на своей машине:
ouroboros languages
{"languages": ["python", "javascript", "c", "cpp", "elixir", "go", "java", "csharp"]}
TypeScript отдельным пунктом не значится: его файлы обрабатывает тот же разборщик, что и JavaScript.
Общее правило вставки
У всех восьми оно одно: найти границы, вставить по краям, текст не перепечатывать.
Разборщик языка используется только чтобы узнать, где в файле начинается и кончается функция. Дальше в исходный текст вставляются куски — а сам текст не переписывается заново. Поэтому комментарии, отступы и форматирование переживают обмазку: их никто не трогал.
Дальше — как это выглядит у каждого.
Python: декоратор
from ouroboros_runtime import log as _ouro_log
@_ouro_log
def discount_rate(total, member):
if member:
return 0.15
Перед каждым def встаёт одна строка. Тело не тронуто вовсе, поэтому ранние
выходы, вложенные функции и уже написанные try/finally работают как работали.
Запись:
{"p":"in","t":"2026-09-10T19:59:29.262","id":"36c9ba0c-e827-4e03-9020-3f9745d1a37c","ci":-1,"th":"7.129140652823040","fn":"discount_rate","a":"9999, False","k":""}
{"p":"out","id":"36c9ba0c-e827-4e03-9020-3f9745d1a37c","fn":"discount_rate","r":"0.0","d":2e-06}
JavaScript и TypeScript: try/finally
Здесь тело функции обхватывается снаружи:
const _ouro_rt = require("./ouroboros_runtime.js");
function rate(total, member) { const __ouro_ctx = _ouro_rt.enter("rate", [total, member]); let __ouro_result, __ouro_threw = false; try {
if (member) return (__ouro_result = (0.15));
if (total >= 10000) return (__ouro_result = (0.10));
return (__ouro_result = (0.0));
} catch (__ouro_e) { __ouro_threw = true; _ouro_rt.exit_throw(__ouro_ctx, __ouro_e); throw __ouro_e; } finally { if (!__ouro_threw) _ouro_rt.exit(__ouro_ctx, __ouro_result); }}
Каждый return при этом переписан в return (__ouro_result = …) — так
возвращаемое значение попадает в запись до того, как уйдёт вызывающему.
Запись:
{"p":"in","t":"2026-08-28T23:38:08.923","id":"879b996e-8164-424c-b42c-8fc942700694","ci":-1,"th":"2850093.0","fn":"rate","a":"10000, false","k":""}
{"p":"out","id":"879b996e-8164-424c-b42c-8fc942700694","fn":"rate","r":"0.1","d":0.000053}
C: атрибут cleanup
В C нет ни исключений, ни деструкторов, поэтому используется расширение компилятора: переменной приписывается функция, которая вызовется при выходе из области видимости — по какой бы ветви выход ни случился.
static int rate_pct(int total, int member) {
struct _ouro_call __ouro __attribute__((cleanup(_ouro_emit)));
int __ouro_result;
_ouro_enter(&__ouro, "rate_pct", "%d, %d", total, member);
if (member) return (__ouro_result = (15), _ouro_set_result(&__ouro, "%d", __ouro_result), __ouro_result);
if (total >= 10000) return (__ouro_result = (10), _ouro_set_result(&__ouro, "%d", __ouro_result), __ouro_result);
return (__ouro_result = (0), _ouro_set_result(&__ouro, "%d", __ouro_result), __ouro_result);
}
Способ печати значений выбирается по типу из разбора: %d для целого, %ld для
длинного, %p для указателя. Строка в C записывается адресом, а не текстом:
const char * — это указатель, и в записи стоит 0x5fd093a1d170. Разбирается
файл через libclang — поэтому для C эта библиотека и нужна.
Запись:
{"p":"in","t":"2026-08-30T01:01:12.382","id":"b09c2189-f343-4a5e-813b-6c49e5635913","ci":-1,"th":"3810524.3810524","fn":"rate_pct","a":"10000, 0","k":""}
{"p":"out","id":"b09c2189-f343-4a5e-813b-6c49e5635913","fn":"rate_pct","r":"10","d":0.000000}
Имён доводов в записи нет — 10000, 0, только значения по позиции. Так у
всех восьми языков: поле a держит значения, поле k — именованные доводы, и
смешивать их нельзя, иначе записи разных языков не сверить. Раньше C, C++ и
Elixir писали сюда total=10000, member=0; это убрали ради единой схемы, и цена
известна — имя позиционного довода из записи не восстановить, за ним надо идти в
подпись функции.
C++: объект-охранник
Тот же приём, но родной для языка: заводится объект, деструктор которого пишет строку выхода. Он же умеет отличить нормальный выход от выхода по исключению.
Запись:
{"p":"in","t":"2026-08-30T01:01:23.156","id":"3731a318-adca-4309-bca3-212e4147f217","ci":-1,"th":"3811895.125335935883136","fn":"shop::Cart::rate","a":"10000, 0","k":""}
{"p":"out","id":"3731a318-adca-4309-bca3-212e4147f217","fn":"shop::Cart::rate","r":"0.1","d":0.000026}
Имя здесь полное, с пространством имён и классом: shop::Cart::rate.
Elixir: подмена объявления функции
Здесь вставка одна на весь модуль:
defmodule Cart do
use Ouroboros.Trace
def rate(total, member) do
Строка use Ouroboros.Trace подменяет само объявление функции, и дальше каждая
ветвь объявления обворачивается сама. Поэтому в ответе wrap-file стоит
functions_wrapped: 1 — посчитан модуль, а не функции в нём.
Запись:
{"p":"in","t":"2026-08-30T01:01:26.522","id":"00b23a2c-0327-42ba-908d-f10e846144f2","ci":-1,"th":"3814466.#PID<0.95.0>","fn":"rate","a":"false, 10000","k":""}
{"p":"out","id":"00b23a2c-0327-42ba-908d-f10e846144f2","fn":"rate","r":"0.1","d":0.001006}
Особенность видна прямо в строке: доводы идут по алфавиту имён, а не в
порядке объявления. Функция объявлена rate(total, member), а в записи стоит
false, 10000 — сначала member, потом total. Доводы собраны из привязок, а
те неупорядочены. Имён при этом в записи нет, как и у остальных семи.
Отдельно про запуск: модуль с записями должен быть скомпилирован раньше тех, которые им пользуются.
elixir -r ouroboros_trace.ex -r cart.ex run.exs
Go: defer на любом выходе
В Go нет ни декораторов, ни деструкторов, зато есть defer — отложенный вызов,
который выполняется при выходе из функции по любой причине. Возврат получает имя,
чтобы отложенный вызов мог его прочитать:
func scale(value int) (__ouro_r0 int) {
__ouro_ctx := _ouroEnter("scale", value)
defer func() {
if __ouro_p := recover(); __ouro_p != nil {
_ouroPanicked(__ouro_ctx, __ouro_p)
panic(__ouro_p)
}
_ouroReturned(__ouro_ctx, __ouro_r0)
}()
return value * 2
}
Отложенная функция ловит и обычный возврат, и panic: пойманное она записывает
и бросает дальше, чтобы поведение программы не изменилось.
Помощник у Go — не ввоз, а файл того же пакета: соседний файл ввезти в Go нельзя. Поэтому над заголовком ничего не вставляется, а собирать надо оба файла сразу:
go build -o prog prog.go ouroboros_runtime.go
Запись — функция, возвращающая три значения:
{"p":"in","t":"2026-08-30T00:56:42.287","id":"ed93cc77-ba06-4bdc-878a-0cd283a7f6b0","ci":-1,"th":"3750022.1","fn":"parseRecord","a":"\"a=5\"","k":""}
{"p":"out","id":"ed93cc77-ba06-4bdc-878a-0cd283a7f6b0","fn":"parseRecord","r":"\"a\", 5, true","d":0.000004}
Два места стоит заметить. Несколько возвратов пишутся через запятую в одно
поле r: "a", 5, true. И вторая часть th у Go — номер горутины; узнать его
переносимо нельзя, поэтому помощник добывает его из служебного вывода, и это
стоит 2,5 микросекунды из двадцати девяти.
Java: try/catch/finally
Ни декораторов, ни деструкторов, ни defer — зато есть try/catch/finally,
который срабатывает на любом выходе. Возврат по дороге кладётся во временную:
static int add(int a, int b) { ouroboros.OuroborosRuntime.Ctx __ouro_ctx = ouroboros.OuroborosRuntime.enter("Calc.add", new java.lang.Object[]{a, b}); int __ouro_result = 0; try {
return (__ouro_result = a + b);
} catch (java.lang.Throwable __ouro_e) { ouroboros.OuroborosRuntime.exitThrow(__ouro_ctx, __ouro_e); throw __ouro_e; } finally { ouroboros.OuroborosRuntime.exit(__ouro_ctx, __ouro_result); }}
Врезки нарочно не содержат переводов строки. Поэтому у обмазанного файла столько же строк, сколько у исходного, и след вызовов необработанного исключения совпадает построчно — номера строк не разъезжаются.
Временная нужна не для красоты. Провести возврат через обобщённый помощник не
выходит: на char f() { return 65; } такой помощник выводит свой тип из довода,
а не из метода, и компилятор говорит «inferred: Integer». Временная, объявленная
тем типом, что написан у метода, возвращает сужение постоянной в законное место.
Ввоза помощника нет вовсе — он зовётся полным именем
ouroboros.OuroborosRuntime, поэтому заголовок файла остаётся нетронутым.
Собирать надо оба файла сразу:
javac -d out Calc.java OuroborosRuntime.java
Запись:
{"p":"in","t":"2026-08-30T04:44:15.554","id":"19d151e9-2f16-42d0-92c0-804f18f32f41","ci":-1,"th":"1906698.3","fn":"Calc.add","a":"2, 3","k":""}
{"p":"out","id":"19d151e9-2f16-42d0-92c0-804f18f32f41","fn":"Calc.add","r":"5","d":0.000006}
У конструктора запись ставится после super(...) или this(...): тот обязан
оставаться первым. Отвлечённые и родные методы, а также объявления в договоре без
тела пропускаются — обмазывать там нечего. Замыкания и безымянные классы сами не
обмазываются, и их return не считается выходом из внешнего метода.
C#: то же самое и ещё тело-выражение
Устройство то же, что у Java, с двумя отличиями. Тело-выражение
(int M() => a + b;) разворачивается в блок, причём текст самого выражения не
переписывается ни на знак. И перебрасывается голым throw;, чтобы у исключения
сохранилось место, откуда его бросили:
static int Add(int a, int b) { Ouroboros.OuroborosRuntime.Ctx __ouro_ctx = Ouroboros.OuroborosRuntime.Enter("Calc.Add", new object[]{a, b}); try {
return Ouroboros.OuroborosRuntime.Ret<int>(__ouro_ctx, a + b);
} catch (System.Exception __ouro_e) { Ouroboros.OuroborosRuntime.ExitThrow(__ouro_ctx, __ouro_e); throw; } finally { Ouroboros.OuroborosRuntime.ExitPending(__ouro_ctx); }}
Запись:
{"p":"in","t":"2026-08-30T04:44:42.103","id":"b282e1ef-b207-4fd0-8dbd-674ca9b1051e","ci":-1,"th":"1907799.1","fn":"Calc.Add","a":"2, 3","k":""}
{"p":"out","id":"b282e1ef-b207-4fd0-8dbd-674ca9b1051e","fn":"Calc.Add","r":"5","d":0.000296}
Разборщик Roslyn приезжает вместе с самим .NET SDK — отдельно ставить нечего, но сам SDK нужен уже на обмазывании, а не только на запуске.
Пять видов частей оставляются нетронутыми, потому что обмазанный вариант не
собрался бы: части с yield, возврат по ссылке, указатели, ссылочные структуры и
свойства с телом-выражением. Про каждую такую часть обмазка возвращает
предупреждение с причиной — молча они не пропадают.
Одно место, где обмазка может выдать несобирающийся код. Ссылочная структура, объявленная в другом файле того же проекта, разбору не видна: он идёт по написанию и имя в объявление не разрешает. Структуры платформы (
Span,ReadOnlySpanи подобные) известны по именам, объявленные в самом обмазываемом файле — берутся из объявления. Чужая же останется незамеченной, часть с ней будет обмазана и перестанет собираться. Других таких мест во всём инструменте нет.
Что различается, а что нет
Схема записи — ключи, их смысл, две строки на вызов — у всех восьми одна и та же. Различается три вещи, и все три оставлены нарочно: это родная форма каждого языка, и приводить её к общему виду значило бы врать про то, что языки видели.
| язык | строка в доводах | полное имя | число в поле d |
имена доводов |
|---|---|---|---|---|
| Python | 'free' |
Cart.checkout |
5.3e-05 |
нет |
| JavaScript | "free" |
checkout |
0.000053 |
нет |
| C | адрес: 0x5fd093a1d170 |
rate_pct |
0.000000 |
нет |
| C++ | free |
shop::Cart::rate |
0.000026 |
нет |
| Elixir | "free" |
rate |
0.001006 |
нет |
| Go | "free" |
Calc.add |
0.000001 |
нет |
| Java | "free" |
Calc.add |
0.000006 |
нет |
| C# | "free" |
Calc.Add |
0.000296 |
нет |
Строка в доводах у C — не текст, а адрес: const char * печатается через
%p. У C++ std::string печатается текстом без кавычек, а вот const char *
и там останется адресом.
Особенно заметно третье: одно и то же значение Python печатает как 1e-06, а
JavaScript — как 0.000001. Это разные записи одного числа, а не разные числа.
Чего это стоит
Замер снят на одной подопытной программе для всех восьми языков: 20 002 вызова (20 003 у C++, Java и C#), семь повторов, в таблице медиана. «Добавка на вызов» — разница между прогоном без обмазки и с обмазкой, поделённая на число вызовов. Все строки сняты одним прогоном, а не собраны из разных дней: числа заметно гуляют от прогона к прогону, и сравнивать между собой можно только строки одной таблицы.
| язык | добавка на вызов | байт на вызов |
|---|---|---|
| Python | 58,2 мкс | 252,5 |
| JavaScript | 20,4 мкс | 236,5 |
| C | 19,9 мкс | 248,4 |
| C++ | 22,2 мкс | 269,4 |
| Elixir | 190,7 мкс | 254,4 |
| Go | 28,9 мкс | 243,4 |
| Java | 20,9 мкс | 251,4 |
| C# | 15,6 мкс | 251,4 |
C, короткий вид (--minimal) |
7,7 мкс | 38,0 |
Дороже всего сама запись, а не язык. Четыре языка из восьми — JavaScript, C,
C++ и Java — уложились в 20–22 микросекунды на вызов, хотя между собой
различаются в разы. Столько стоит открыть файл, дописать строку и закрыть его, и
так дважды на вызов. Go рядом с ними, но не внутри полосы: 28,9 микросекунды, из
них 2,5 стоит поиск номера горутины для поля th.
Дешевле всех вышел C# — 15,6 микросекунды, и дело не в языке. Его помощник, как и помощник Java, держит файл открытым и дописывает в него, тогда как помощники Python, C и C++ открывают и закрывают файл на каждую запись.
Python дороже втрое: обёртка у него написана на самом Python, и каждая запись
собирается через json.dumps и reprlib. Elixir дороже всех, и часть этого —
запуск самой BEAM: без всякой обмазки он занимает 0,79 секунды, тогда как у
остальных семи вся программа укладывается в сотые доли.
Короткий вид записи для C дешевле полного втрое, потому что пишет одну строку вместо двух и не собирает кадр вызова. Платят за это подробностью: доводов и результатов в такой строке нет.
У Elixir число самое неустойчивое: отдельные прогоны давали от 161 до 202 микросекунд на вызов. Верить тут можно порядку величины, но не третьему знаку. У остальных семи повторы сошлись в пределах нескольких процентов.
Объём почти не гуляет между прогонами: у C, C++, Elixir и Go он повторяется побайтово, у Python и JavaScript расходится на сотые доли процента, а Java и C# разошлись на один байт за пять мегабайт — они пишут одно и то же. От 236,5 до 269,4 байта на вызов — это при коротких доводах, двух целых числах. Длинные доводы поднимут запись до потолка в 4096 байт, после чего значения обрезаются. Практический счёт: миллион вызовов — примерно четверть гигабайта.
Отношение «во сколько раз медленнее» по этим числам считать нельзя. Подопытная программа не делает ничего, кроме вызовов, поэтому отношение показывает лишь, насколько быстрым был на этом языке пустой цикл: получается от 6 у Elixir до 313 у C, и обе цифры не про инструмент. В настоящей программе доля добавки меньше во столько раз, во сколько сама функция дороже этих микросекунд.
Про flang
На портале есть свой язык исполняемых спецификаций и
курс по нему. В инструменте его
поддержки нет: в списке языков его не значится, разборщика для него не
написано, и в исходнике инструмента он не упоминается ни разу. Когда появится —
появится и строка в ouroboros languages.
Дальше — границы: чего инструмент не делает и что ломается на практике.