Как компилятор перестал склеивать код из строк

Несколько месяцев назад генерация 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