Несколько месяцев назад генерация C++-кода в MLC работала через конкатенацию строк. Каждая конструкция на выходе — заголовочный файл, пространство имён, объявление функции — собиралась из текстовых кусков.
Такой подход сложно трансформировать и тестировать: чтобы проверить структуру вывода, нужно разбирать уже сгенерированный текст обратно. Ошибки выглядят как неверные пробелы или лишние переносы строк, а не как понятные структурные несоответствия.
Правильнее — дерево объектов. Принтер превращает его в текст один раз, в одном месте. Ниже — конкретные примеры того, как это выглядело в коде до и после.
Сборка заголовочного файла
Самый показательный пример. Вся структура файла на выходе была одним большим блоком конкатенации:
- fn assemble_header_string(parts) -> string = [
- include_guard_ifndef_line(parts.guard),
- include_guard_define_line(parts.guard),
- "
",
- parts.std_includes,
- namespace_open_line(parts.module_namespace),
- "
",
- parts.decl_parts.type_fwds.join(''),
- parts.decl_parts.type_defs.join(''),
- parts.decl_parts.fn_protos.join(''),
- "
",
- namespace_close_comment_line(parts.module_namespace),
- include_guard_endif_comment_line(parts.guard)
- ].join('')
Теперь каждый элемент — отдельный объект: CppIfndef, CppDefineMacro,
CppNamespaceBegin и так далее. Принтер знает как напечатать каждый из них.
Объявление функции
Прототип функции тоже собирался как строка. Новая версия создаёт объект CppFnProto
с явными полями — имя, тип возврата, список параметров:
fn native_fn_proto_cpp(name, params, return_type, context) -> CppDecl = do
const proto_context = prototype_context_for_function(context, params)
const safe_name = context.resolve(name)
const return_cpp = sem_type_to_cpp(proto_context, return_type)
const parameters = gen_parameter_proto_items(proto_context, params)
Shared.new(CppFnProto(return_cpp, safe_name, parameters))
end
Структуры и варианты типов
Объявление структуры данных тоже генерировалось как текст. Теперь каждый вид варианта
обрабатывается отдельно и возвращает объект CppStruct:
match variant {
VarUnit(name) => CppStruct(prefix, name, []),
VarTuple(name, field_types) => CppStruct(prefix, name, tuple_fields_cpp(context, field_types)),
VarRecord(name, field_defs) => CppStruct(prefix, name, record_fields_cpp(context, field_defs))
}
Каждое поле структуры тоже стало объектом — CppField с типом и именем.
Инициализация аргументов командной строки
Для функции main нужно передать аргументы командной строки в рантайм.
Раньше это генерировалось как строка с вручную подставленными именами переменных.
Теперь — дерево вызовов:
fn main_set_args_vector_argument_cpp() -> CppExpr =
CppCall(
make_identifier('std::vector<mlc::String>'),
[
CppBinary("+", make_identifier('argv'), make_integer(1)),
CppBinary("+", make_identifier('argv'), make_identifier('argc'))
])
Оператор ? (распаковка результата)
Оператор ? в MLC разворачивает значение типа Result или возвращает ошибку.
Старый вариант генерировал строки с прямой вставкой имён переменных:
- make_fragment_cpp_statement(
- `if (std::get_if<1>(&${try_identifier})) return *std::get_if<1>(&${try_identifier});`)
- make_fragment_cpp_statement(
- `return std::get<0>(${try_identifier}).field0;`)
Теперь вместо форматирования строки — объекты: CppIf, CppReturn,
CppCall с std::get_if как именованным вызовом:
make_if_cpp_statement(
err_pointer_expression(try_identifier),
make_block([make_return(CppUnary("*", err_pointer_expression(try_identifier)))]),
make_block([]))
make_return(ok_field0_expression(try_identifier))
Объявление переменной с выведенным типом
Когда нужно объявить временную переменную в сгенерированном C++-коде, раньше её тип нужно было вычислить и вставить в строку вручную:
- const holder_type = sem_type_to_cpp(context, sexpr_type(expression))
- make_fragment_cpp_statement(
- `${holder_type} ${holder_name} = ${print_expr(evaluated)};`)
Теперь используется узел CppAutoDecl — компилятор выводит тип сам:
+ make_auto_cpp_statement(holder_name, evaluated_expression)
Удаление слоя проверок «нужна ли строка»
Пока часть конструкций генерировалась через строки, перед каждым вызовом нужно было проверить: «а умеем ли мы это в нативном виде или придётся в строку?» Под это существовал целый слой функций:
- fn prefix_control_flow_expression_needs_string_bridge(expression) -> bool =
- match expression {
- SExprWhile(_, _, _, _) => true,
- SExprFor(_, _, _, _, _) => true,
- SExprIf(_, then, else, _, _) =>
- prefix_control_flow_expression_needs_string_bridge(then)
- || prefix_control_flow_expression_needs_string_bridge(else),
- ...
- }
-
- fn prefix_statement_needs_string_bridge(s) -> bool = ...
- fn prefix_statements_need_string_bridge(ss) -> bool = ...
Когда циклы получили нативные узлы — весь этот слой был удалён целиком.
Удаление дублирующих обёрток
После перехода на нативный вывод в API компилятора остались экспортируемые функции, которые возвращали строку. Они больше не были нужны:
- export fn gen_type_decl_cpp_as_string(...) -> string = ...
- export fn gen_fn_decl_cpp_as_string(...) -> string = ...
- export fn gen_expr_cpp(...) -> string = ...
27 строк кода удалено без замены — внешний API теперь везде работает с объектами.
Разбор кортежа
При разборе паттерна let (a, b) = expr старый код генерировал одну строку
auto [a, b] = __lt; — синтаксис C++17, работает не везде.
Новый вариант генерирует отдельные объявления через std::get:
- structured_binding_statement(["a", "b"], "__lt")
- // → auto [a, b] = __lt;
+ simple_tuple_ident_binding_statements(subpatterns, "__lt")
+ // → auto a = std::get<0>(__lt);
+ // auto b = std::get<1>(__lt);
Тип объявления по фазе
Сборка объявлений типа раньше вызывала разные функции в разных местах кода.
Теперь — один match по виду объявления и фазе (предварительное объявление или тело):
fn decl_segment_cpp(declaration, phase) -> [CppDecl] =
match declaration {
SDeclType(name, params, variants, derives) =>
if phase == 0 then gen_type_decl_fwd_cpp(...)
else gen_type_decl_body_cpp(...),
SDeclTrait(name, params, methods) =>
if phase == 0 then [gen_trait_decl_cpp(...)]
else [],
_ => []
}
Переименование переменных
В коде было много мест, где результат парсинга или вывода типа хранился
в переменной с именем res, result или res2:
- const res = parse_one_param_bounds(state.advance())
- bounds_list.push(res.bounds)
- state = res.parser
+ const bounds_parsed = parse_one_param_bounds(state.advance())
+ bounds_list.push(bounds_parsed.bounds)
+ state = bounds_parsed.parser
Каждое такое место теперь называет переменную по смыслу. В большой функции с несколькими вызовами парсера это существенно упрощает чтение.
Итог
В production-коде компилятора не осталось функций, которые возвращают сырые строки с фрагментами C++-кода. 936 тестов проходят, компилятор собирает сам себя, побайтовый diff между двумя поколениями — пустой.
Следующий этап — улучшения самого языка MLC: сопоставление с образцом по строковым значениям, псевдонимы типов, переименование оставшихся сокращений.
Расщепление реестра типов
Весь контекст типизации хранился в одной структуре TypeRegistry с десятью полями.
Это затрудняло понимание: непонятно какие поля связаны друг с другом.
Сейчас агент работает над разбивкой на подструктуры по смыслу:
- export type TypeRegistry = TypeRegistry {
- fn_types: Map<string, Shared<Type>>,
- ctor_types: Map<string, Shared<Type>>,
- field_types: Map<string, Map<string, Shared<Type>>>,
- record_field_names_ordered: Map<string, [string]>,
- ...и ещё шесть полей...
- }
+ export type FunctionIndex = FunctionIndex { fn_types: ..., function_trait_bounds: ... }
+ export type AdtIndex = AdtIndex { ctor_types: ..., algebraic_decl_type_parameter_names: ... }
+ export type RecordIndex = RecordIndex { field_types: ..., record_field_names_ordered: ... }
+
+ export type TypeRegistry = TypeRegistry {
+ function_index: FunctionIndex,
+ adt_index: AdtIndex,
+ record_index: RecordIndex
+ }
Теперь понятно что FunctionIndex знает о функциях, AdtIndex — о типах-суммах,
RecordIndex — о полях записей. Все вызовы обновляются автоматически через методы.
Имена сокращений: lam_r, lam_ret
В коде вывода типов было много переменных с однобуквенными или жаргонными именами.
Один из самых частых паттернов — lam_r и lam_ret
для результата вывода типа лямбда-выражения:
- const lam_r = infer_expr_maybe_lambda(arg0, inference_context)
- merged = merged.absorb(lam_r)
- const lam_ret = function_return_type(lam_r.inferred_type)
- if is_result_type(lam_ret) then
- const inner_e = result_inner_err_from_ret(lam_ret)
+ const lambda_parsed = infer_expr_maybe_lambda(arg0, inference_context)
+ merged = merged.absorb(lambda_parsed)
+ const lambda_return_type = function_return_type(lambda_parsed.inferred_type)
+ if is_result_type(lambda_return_type) then
+ const inner_e = result_inner_err_from_ret(lambda_return_type)
Этот паттерн встречался четыре раза в одном файле — каждый раз одинаково переименован.
Пустая строка как объект
Отдельный небольшой пример из текущей работы. Вставка пустой строки между блоками в сгенерированном C++-файле делалась через текстовый фрагмент:
- fn make_blank_line_cpp_declaration() -> CppDecl =
- emit_helpers.make_fragment_cpp_declaration("\n")
Теперь для этого есть отдельный узел:
+ fn make_blank_line_cpp_declaration() -> CppDecl =
+ Shared.new(CppBlankLine)
Using namespace: строка → список объектов
Директивы using namespace X; в исходном файле модуля генерировались
как одна строка с переводами строк:
- implementation_using_namespaces: string,
- // = "using namespace foo;
using namespace bar;
"
+ implementation_import_paths: [string],
- // вместо этого — список путей импорта, из которых названия пространств имён
- // вычисляются отдельной функцией и создают объекты CppUsingNamespace
+ fn make_using_namespace_cpp_declaration(namespace) -> CppDecl =
+ Shared.new(CppUsingNamespace(namespace))
+
+ fn append_using_namespace_declarations(declarations, import_paths) -> [CppDecl] = do
+ const namespace_names = using_namespace_names(import_paths)
+ namespace_names.each(name => declarations.push(make_using_namespace_cpp_declaration(name)))
+ end