Как мы погрузились в теорию компиляторов и написали свой транслятор кода
- четверг, 16 апреля 2026 г. в 00:00:14

Всем привет! Меня зовут Егор Ермаков, я бэкенд‑разработчик в группе разработки процессинга Техплатформы городских сервисов Яндекса.
Техплатформа — это инфраструктурная платформа для всех городских сервисов Яндекса: Такси, Еды, Лавки, Доставки, а также для различных шеринговых сервисов — каршеринга, зарядных станций, самокатов и других.
Один из ключевых сервисов нашей команды — ProcaaS (Processing as a Service). Он предназначен для асинхронного выполнения динамических сценариев, которые:
состоят из большого числа действий;
включают ветвления;
поддерживают приостановку обработки;
позволяют выполнять отложенные действия;
реализуют разные паттерны взаимодействия с сервисами (ретраи, поллинг, обратные вызовы и др.);
обеспечивают передачу и обновление контекста между фазами и стадиями задачи.
Подробнее о самом сервисе, его архитектуре и роли в сетке микросервисов Такси мы рассказывали в предыдущей статье. В этом материале я хочу поделиться опытом решения одной непростой и при этом очень интересной технической задачи, с которой мы столкнулись в рамках развития ProcaaS, а именно — рассказать, как мы написали свой транслятор кода.
Пользователи нашего сервиса — продуктовые разработчики. Они в формате no‑code/low‑code - подхода описывают свою схему (процесс), по которой ProcaaS будет обрабатывать пользовательские входные данные.
Сама схема описывается на языке YAML. Однако языка YAML в нашем случае недостаточно, так как у пользователей возникает потребность описывать выражения для преобразования данных. Поэтому мы в команде разработали наш собственный специальный язык (Domain Specific Language).
Он необходим для описания преобразований данных — позволяет формировать выражения, которые используют входные данные и набор операций для получения новых данных в нужном формате.
И вот здесь мы подходим к основной задаче. Мы хотим перейти с этого языка на Go, а другими словами, нам нужно выполнить полное преобразование программы, написанной на одном языке программирования, в функционально эквивалентную программу на другом языке. То есть написать транслятор.
Прежде чем переходить к трансляции, расскажу немного про сам язык, его грамматику и устройство реализации.На его создание нас во многом вдохновил язык выражений в Python, поэтому часть конструкций и поведения может показаться знакомой. Ниже — самые основные аспекты.
Данные могут быть представлены одним из встроенных примитивных типов:
целое или вещественное число — Int, Float;
булево значение — Boolean;
пустое значение — Null;
строка — String;
дата и время — Datetime;
длительность (длина временного интервала) — Duration.
Также поддерживаются сложные типы данных:
объект — по сути, словарь с ключами типа String и значениями любого типа (Object);
список — может содержать элементы любого типа (List).
Выражения позволяют динамически вычислять значения в нужных местах. Чтобы описать выражение, строку в YAML надо пометить специальным тегом !$ — в этом случае она будет интерпретирована как выражение языка.
При этом для описания статических объектов, списков и констант по‑прежнему можно использовать обычный YAML:
result: foo: !$ '...' bar: - !$ '...' list: - true - 0.1 - [1, 2, 3]
Для конструирования и преобразования списков поддерживаются comprehensions в следующем виде:
[ new_element for element in container if filter ]
Часть с фильтрацией через if опциональна. Если условие не указано, выражение просто преобразует каждый элемент контейнера в соответствии с new_element.
Таким образом, можно одновременно выполнять и отображение (map), и фильтрацию (filter) элементов списка.
aliases: list: !$ '[1, 2, 3]' example: !$ '[i + 1 for i in list]' # [2, 3, 4]
Для списков и строк также реализованы операторы получения элемента [idx] и среза [start : end : step].
Аналогичным образом поддерживаются comprehensions для объектов:
{ new_key: new_value for key, value in container if filter }
Фильтрация через if, как и в случае со списками, опциональна. Для доступа к элементу объекта используется синтаксис [key], где индексом должна быть строка, соответствующая ключу в объекте. Однако для объектов также можно — и рекомендуется — использовать более удобный синтаксис:
object.key
Примечание. Формально оператор . (оператор MemberAccess) является частным случаем оператора [] (оператора Index). По сути, это его сужение на подмножестве типов левого операнда.
В реальных сценариях часто возникает необходимость переиспользовать часть выражения — например, как в примере выше — или вынести сложный фрагмент в отдельное именованное выражение для упрощения и повышения читаемости.
Для этого предусмотрена секция aliases, в которой можно объявлять именованные выражения и затем ссылаться на них в других местах схемы:
aliases: foo: !$ 'if true then 1 else 2'
После объявления выражение можно использовать по его идентификатору — например, foo — в других выражениях.
Однако в некоторых случаях возможностей языка выражений может быть недостаточно для выполнения более сложных операций. Поэтому предусмотрен набор встроенных функций, которые расширяют выразительность языка.
Функции вызываются по идентификатору в стандартном виде:
function(arguments, ...)
example: !$ sum([1, 2, 1 + 2]) # 6
Примечание. На данный момент в языке более 20 встроенных функций.
Для ветвления используется конструкция:
if condition then then_expression else else_expression
Если condition вычисляется в true, результатом выражения становится then_expression. В противном случае вычисляется else_expression.
Выражение доступа к индексам объектов или списков можно обернуть в try:
try some.path else default_value
Тогда, если происходит обращение к несуществующему полю или индексу, вычисление не вызовет ошибку, а вернёт default_value.
Иными словами, если все индексы в пути существуют, результат выражения try ... — это значение по указанному пути. Если хотя бы один индекс отсутствует, результатом становится значение по умолчанию.
aliases: obj: {"foo": {"bar": 1}} example: !$ (try obj.foo.baz else 100) + (try obj.foo.bar else 10) # 101
Для проверки существования пути используется конструкция:
exists some.path
Она вычисляется в true, если указанный путь существует, и в false, если хотя бы один индекс по этому пути отсутствует.
Также есть специфическая механика использования конструкций if и try без ветки else. Она поддерживается только в контексте инициализации списка или объекта. В случаях, когда необходимо не заполнять поле в зависимости от условия, ветку else в if и try можно опустить. Тогда:
при ложном значении условия в if
либо при отсутствии индекса в пути в try
соответствующее поле объекта или элемент списка просто не будут включены в результирующую структуру. Таким образом, конструкции позволяют не только выбирать значения, но и управлять самой структурой результирующих данных.
aliases: obj: !$ '{"foo": {"bar": 1}}' example-object: !$ |- # {"bar": 2} { "foo": if false then 1, "bar": if true then 2, "baz": try obj.foo.baz, }
Как и во многих известных языках программирования, реализованы стандартные арифметические бинарные операторы (+, -, *, /, %), а также операторы сравнения и логические операторы.
Существует и бинарный оператор in, который используется для проверки наличия элемента в списке или ключа в объекте.
Логические операторы not, or и and могут применяться к любым типам данных. Любое значение интерпретируется как истинное или ложное.
Ложными считаются значения:
false;
null;
численные значения 0 и 0.0;
пустая строка, пустой список или пустой объект.
Все остальные значения считаются истинными.
Под коллекциями понимаются специальные сущности (классы) с заранее определённой структурой, которые можно использовать в выражениях. Доступ к ним осуществляется по идентификатору.
Ближайшая аналогия — built‑in‑переменные — специальные переменные, которые автоматически устанавливаются в контексте программы.
Пример использование в коде:
example: !$ event.payload.tag in configs.TAXI_ORDERS_TAGS.value # true or false
При этом у полей коллекций тоже есть свои типы.
Мы уже обсудили синтаксис и основные возможности языка. Теперь немного о том, как этот язык реализован и работает. На самом деле всё устроено почти как в учебнике «Компиляторы: принципы, технологии и инструментарий»:

Это базовая модель начальной стадии компиляции языков
Во‑первых, язык динамически типизирован, а во‑вторых, он интерпретируемый. Его интерпретатор — как и в целом весь код, описанный в схемах выше, — написан на C++.
После того как схема полностью обработана и построено AST‑дерево, оно сохраняется в специальный полиморфный класс Variant. Этот класс — ядро языка.
По сути, это std::variant, который может содержать как константные выражения (поля в YAML без тега !$), так и динамически вычисляемые выражения. Он инкапсулирует логику вычисления значения выражения в рантайме, сразу на входных данных.
Итого: вся работа по исполнению кода ложится на плечи этого класса.
Результатом трансляции должен быть код на Go, который:
успешно компилируется;
обладает хорошей читабельностью и похож на привычный всем код на Go.
на выходе получается эквивалентная программа на Go.
Фундаментальная сложность заключается в нескольких моментах:
Динамическая типизация с полиморфным типом Variant против статической типизации Go.
В Go нет конструкций типа inline if, поэтому выражение частично придётся разбивать на отдельные statement«ы.»
В Go отсутствуют исключения, на которые опирается реализация языка выражения.
В Go нет некоторых встроенных типов данных и операций (например, JSON, BSON и так далее).
В общем виде решение выглядит так: мы берём AST‑дерево выражения, последовательно обходим его, с каждым узлом сопоставляем код на Go.
В некоторых случаях требуется обмен данными между дочерними и родительскими узлами для корректной конвертации. После этого мы объединяем код всех дочерних узлов, формируя результат для родительского выражения.
Рассмотрим пример. Пусть у нас есть выражение:
!$ try event.payload.tag else "tag_default"
Его AST‑дерево будет примерно следующим образом:

Нужно обойти AST‑дерево, посетив все узлы, и сопоставить с каждым из них соответствующий код на Go. Для этого используется обход в глубину (DFS, Depth‑First Search). Обход начинается с корня, и далее выполняется рекурсивное посещение дочерних узлов в любом порядке (необязательно слева направо).
Этот метод называется «в глубину», потому что сначала обходятся все дочерние узлы до самого глубокого уровня. То есть узлы, находящиеся на максимальной удалённости от корня, посещаются первыми.

Но не всё так просто. В общем случае не со всеми узлами (операторами и конструкциями нашего языка) можно сопоставить одну прямую конструкцию Go. Это связано с четырьмя проблемами, о которых мы говорили выше.
Во‑первых, нужно избавиться от динамической типизации. Мы делаем это так: при трансляции кода с каждым блоком кода сопоставляется конкретный тип.
Во‑вторых, нужно научиться генерировать из однострочных выражений не просто однострочный код на Go, а максимально идиоматичный для Go.
Сам обход AST и транслятор реализованы на C++. Как уже говорилось, мы рекурсивно спускаемся по дереву, проходим все дочерние узлы, объединяем результаты их обхода и получаем готовый код для родительского узла.
Определение типа Go‑кода крайне важно, поскольку Go — статически типизированный язык. Для всех функций и операторов нужно, чтобы типы были корректными. В трансляторе на это сделан особый акцент: мы избавляемся от динамической типизации, присваивая каждому блоку кода конкретный тип.
Например, рассмотрим выражение:
!$ now() - seconds(100)
Здесь из временной точки (Datetime) вычитается временной интервал (Duration). В нашем языке оператор - для типа Datetime определён следующим образом:
| Левый операнд | Правый операнд | Результат | Эквивалентный код на Go | |---------------|----------------|-----------|-----------------------------------------| | Datetime | Datetime | Duration | t1.Sub(t2), где t1 и t2 — это time.Time | | Datetime | Duration | Datetime | t.Add(-duration), где t — это time.Time, а duration — time.Duration |
Как видно из этого простого случая, понимание типов необходимо для правильной генерации кода.
Теперь, когда общая схема решения стала ясна, детальнее рассмотрим основные тонкости и сложности, вызванные спецификой языков.
Как уже говорилось выше, определение точного типа для блока Go‑кода — важная задача. Но сделать это не всегда возможно на этапе трансляции. Невозможно по той причине, что типы каких‑то выражений можно узнать только в рантайме. Связано это со спецификой языка выражений. Во‑первых, мы не знаем схему пользовательских входных данных, пользователи могут прислать данные любого типа, и наш язык только в рантайме будет пытаться определить, какой у них тип. Во‑вторых, могут быть случаи, когда пользователи при написании кода сами пишут выражения с типом, который можно определить только в рантайме; например, рассмотрим выражение:
!$ if (configs.FEATURE_FLAGS.value.feature1.enabled) then 42 else "42"
Тип этого выражения нельзя однозначно определить на этапе трансляции: он может быть либо Int, либо String, в зависимости от значения конфигурации, которое вычисляется только в рантайме. В таких случаях без полиморфного типа в Go не обойтись.
В Go нет аналога C++‑класса std::variant, и язык изначально рассчитан на минимизацию подобных конструкций.
Однако в Go есть тип any. Это универсальный тип, который может содержать значения любого типа и которому можно присваивать разные значения, например:
var x any x = int64(42) fmt.Println(x, reflect.TypeOf(x).String()) // 42 int64 x = []string{"hellow", "world"} fmt.Println(x, reflect.TypeOf(x).String()) // [hellow world] []string
Таким образом, тип any можно использовать как полиморфный тип Variant. С помощью рефлексии в Go можно определить, какой тип хранится в переменной any, и получить её значение.
Для трансляции нужно также поддержать специальные функции в Go, которые соответствуют обычным операторам, но работают с типом any.
Например, для выражения…
!$ expr1 + expr2
…, где expr1 и expr2 имеют тип any, в Go нужно реализовать оператор + в виде функции:
func Add(x any, y any) any
Тогда вызов в сгенерированном коде будет выглядеть так:
agl.Add(expr1, expr2)
Внутри функции Add реализуется логика сложения по правилам языка выражений.
По сути, приходится реализовывать на Go часть интерпретатора в виде набора функций для работы с типом any. На практике эта библиотека невелика и относительно проста, особенно с учётом того, что весь её код у нас уже написан на C++. Она содержит реализацию всех операторов и встроенных функций, которые используются при трансляции выражений.
При этом в случае смешивания статической типизации и any выражение будет приведено к типу any. То есть в общем случае если у оператора есть несколько операндов с разными типами (например, бинарный оператор сложение, у которого левый операнд имеет тип Int, а правый — тип any), то результатом применения оператора будет выражение с наиболее обобщённым типом — any.
Обратно же от any к какому‑то конкретному типу можно будет перейти, зная дополнительный контекст, например тип поля для запроса в HTTP‑ручку или когда можно по семантике языка точно определить конкретный тип выражения. Например, при обращении к ключу объекта: ключ всегда строка. Если при трансляции тип ключа оказался any, можно выполнить явное приведение:
key_str := key.(string)
Единственное ограничение: при таком приведении вылетит ошибка, если тип в рантайме не совпадает со строкой. Это нормально, так как в рантайме язык выражений в аналогичной ситуации тоже сгенерирует ошибку — поведение остаётся эквивалентным.
Ещё один интересный момент связан с JSON. Он может встречаться, например, в поле event.payload (входные данные) или быть частью запроса или ответа обычной HTTP‑ручки. Язык выражений умеет работать с таким типом.
Например, можно писать:
!$ event.payload.foo
В этом случае мы интерпретируем JSON как объект (мапу) или:
!$ event.payload[0]
Тогда JSON будет интерпретирован как массив.
В языках программирования, например в C++, тип JSON часто реализуется через полиморфный тип (тот же std::variant), который может хранить константу, массив или объект.
В Go такой реализации нет. Кроме того, в Go не идиоматично использовать такой подход: обычно JSON представляется как []byte или string и преобразуется в нужную структуру с помощью Unmarshal(json_str, &MyStruct).
В нашем случае этого не подходит, так как структуру JSON мы можем и не знать.
Поэтому для Go мы написали собственный тип JSON:
type JSON = any
Он поддерживает значения типов: boolean, int64, float64, string, map[string]any, []any.
То есть для хранения данных JSON мы используем тип any, что удобно: можно сразу применять функции из нашей библиотеки для работы с операторами, о которых мы говорили выше, без дополнительного кода.
Напомним, что идентификаторы — это алиасы на другие выражения.
При трансляции важно учитывать порядок обработки выражений. Сначала надо определить, от каких идентификаторов зависит данное выражение, и рекурсивно выполнить трансляцию для всех зависимостей.
Иными словами, нужно сделать топологическую сортировку.
Пример:
aliases: foo: !$ ... bar: !$ ... example: !$ foo + 5 * bar
Для того чтобы конвертировать выражение example, надо сначала конвертировать код идентификаторов foo и bar.
Результаты трансляции идентификаторов можно сохранять в общем хранилище (например, реализовать как отдельную Go‑функцию), так как идентификаторы могут использоваться в разных выражениях.
Для трансляции специфических функций используется следующий подход:
Создаётся специальное хранилище функций FuncStorage — это просто мапа, где ключом является имя функции.
Каждое значение в мапе содержит данные, необходимые для конвертации:
имя функции;
типы аргументов;
значения аргументов по умолчанию;
тип возвращаемого значения;
шаблон Go‑кода для генерации.
Все функции заранее известны, поэтому FuncStorage можно сделать константным.
struct FuncData { std::vector<GoType> args_type; std::vector<GoConvertResult> default_args; std::string func_template; GoType func_type; }; using FuncStorage = std::unordered_map<std::string, FuncData>;
Алгоритм трансляции такой:
При трансляции ищем функцию в мапе. Сначала конвертируются все её аргументы. Если аргументов не хватает, недостающие значения подставляются из дефолтных аргументов.
Объединяем все результаты трансляции аргументов в один блок кода.
Генерируем код самой функции по шаблону. Некоторые функции уже реализованы в Go, остальные берутся из нашей специальной библиотеки для Go.
До этого момента мы обсуждали проблему типизации: как из динамической типизации получить статическую и с какими сложностями это связано. Теперь рассмотрим ещё одну задачу — inline‑конструкции.
В нашем языке допустимо писать однострочные вложенные конструкции, например:
!$ if (if ...) then ... else ...
В Go такой однострочник написать нельзя. Придётся разворачивать конструкцию в два последовательных if:
var temp_res1 bool if (...) { temp_res1 = ... } else { temp_res1 = ... } if (temp_res1) { ...
Ещё, например, при проверке путей через оператор exists: !$ exists objc.foo.bar (или оператор try) в Go это будет необходимо развернуть во что‑то наподобие:
temp_res1 := false if temp_res2, err := objc["foo"]; !err { if , err := tempres2["bar"]; !err { temp_res1 = true } }
При этом стоит понимать, что проверки в каждом вложенном if могут быть совершенно разными:
проверка наличия ключа в мапе;
проверка, что индекс обращения не выходит за пределы массива;
проверка, что указатель не равен nil.
Все эти проверки зависят только от типа кода. Через определение типа мы понимаем, какой Go‑код нужно генерировать.
Это же относится и к Comprehensions, например Go‑код для !$ [e+1 for e in list if e > 0] будет выглядеть как‑то так:
var temp_res1 []int64 for , e := range list { if e > 0 { tempres1.append(temp_res1, e+1) } }
Как и в предыдущем случае, существует множество вариантов: для объектов и массивов, возможность итерироваться по нескольким контейнерам одновременно и так далее
Значительное разнообразие комбинаций выражений, для которых нет аналога inline‑конструкций, вынуждает придумывать различные шаблоны кода на Go и их сочетания.
А с учётом того, что всё это происходит при обходе AST‑дерева, транслятору нужно корректно передавать информацию между узлами: понимать, какой узел был предыдущим, а какой будет следующим. Все эти сложности приходится брать в расчёт при генерации Go‑кода.
В пору активного развития нейросетей встаёт вопрос: а почему бы для задачи трансляции да и в целом компиляции не использовать какую‑то специальную LLM? Почему бы не загрузить в LLM полностью описанную грамматику языка и его семантику и не доверить ей генерацию кода.
Наверное, одни из главных аргументов — детерминированность и верификация.
Компилятор — это детерминированная программа. Входные данные A всегда должны давать выходной результат B. LLM предсказывает следующее слово (токен) на основе вероятностей. Если вы попросите её скомпилировать один и тот же код дважды, в теории она может выдать немного разные результаты или в одном случае допустить ошибку, а в другом — нет.
Надёжные компиляторы часто проходят процесс формальной верификации. Это математическое доказательство того, что компилятор работает корректно для любых возможных входных данных. Код LLM — это «чёрный ящик». Невозможно математически доказать, что нейросеть не ошибётся на каком‑то специфическом входе. Если компилятор пишет LLM, мы не можем аудировать его логику. Мы вынуждены слепо доверять модели. Для инфраструктурного ПО это неприемлемый риск.
Кстати, про верификацию программ прекрасно выразился в одной из своих лекций — Reflections on Trusting Trust — Кен Томпсон: «The moral is obvious. You can't trust code that you did not totally create yourself. (Especially code from companies that employ people like me.)». Мы написали транслятор сами, поэтому доверия у нас к нему больше.
На самом деле тут возникает ещё момент, связанный с потреблением ресурсов. Код программы может содержать десятки, сотни тысяч, миллионы строк кода. Сколько надо энергии, железа, токенов, чтобы LLM такое переварила, и сколько времени это займёт? Ответ: намного больше, чем для компилятора.
Написание транслятора — задача не такая простая, как кажется на первый взгляд: это не просто сопоставить одно AST‑дерево с другим. На практике проявляются специфики исходного и целевого языков, из‑за которых универсальный транслятор невозможен. Для каждой пары языков приходится придумывать собственное решение.
Ещё одна нетривиальная задача — создание конвертированного кода, который воспринимается как продукт человеческой деятельности. Один и тот же блок можно реализовать разными способами: одни варианты оптимальны с точки зрения компиляции, другие — более идиоматичны для целевого языка. Возникает компромисс: усложнить работу транслятора ради эстетически привлекательного и семантически корректного кода или ограничиться базовой функциональностью, пусть и с потерей качества выходного кода.
Трансляция языков — сложный когнитивный процесс, в котором взаимодействуют лексические и грамматические аспекты обеих систем. Эффективная трансляция требует не только глубокого понимания структуры исходного и целевого языков, но и адаптации конструкций, чтобы обеспечить точную передачу смысла и функциональную эквивалентность оригинала.