Боже, дайте мне поинтерпретировать!

Про что?

Все классификации немного грешны. Есть такая классификация - компилируемые языки и интерпретируемые. На практике она бывает полезна и зачастую предопределяет use-case рассматриваемого языка, однако мы грешны ещё и в том, что зачастую для нас это: “быстрые” vs “медленные” языки, или “статические” vs “динамические” (читать - медленные). А теперь, дорогие статическо-системные-ловлевел-бояре я вас напугаю - в новых системных языках программирования есть… интерпретаторы!

Сейчас покажу кое-что интересное, но сначала быстро про термины, простите:

Компилятор

С высоты птичьего полёта процесс компиляции выглядит весьма просто:

compile :: String -> String

Собственно, всё… Мы берём одно представление и переводим его в другое:

int four() {
    return 2 * 2;
}

=>

four:
    li a0, 4
    ret

Но вы это наверняка и так знаете, и уже немало раз пинали свой компилятор в неутомимом ожидании получить… 40 страниц иероглифов ошибки. Тем не менее, компилируемые языки занимает большую часть “рынка” языков программирования и славятся прежде всего своей скоростью - всё-таки мы переводим наш любимый язык в ассемблер, так сказать общаемся с компутером на его родном. Тут кто-то может спросить - а разве можно по-другому?

Интерпретаторы

Действительно можно, интерпретация выглядит как-то так:

data Expr = Lit Int | Sum Expr Expr
data Value = VInt Int 

eval :: Expr -> InterpM Value

Что происходит? Строго говоря, мы переводим наш входной язык на язык на котором написан интерпретатор:

eval (Sum lhs rhs) = do 
    lv <- evalExpr lhs
    rv <- evalExpr rhs
    return (lv + rv)

То есть, мы не получаем никаких ассемблеров, объектных файлов, .exe - когда мы запускаем наш интерпретатор - он парсит программу и тут же её исполняет.

Обычно это всё ассоциируется с медленностью. Почему? Исполнение файла - его парсинг, трансляция в хостовый язык - происходит прямо во время запуска файла - заново каждый раз. При компиляции - один раз спарсили, отмудохали исходную программу до неузнаваемости (читать - оптимизировали) и выплюнули отполированный исполняемый файл - запускай сколько хочешь. А при интерпретации - всё это придётся делать каждый раз заново…

Свобода выбора

А что если я сам хочу выбрать, что мне интерпретировать а что компилировать? Ну и непотребство, просить такую свободу… Но вообще-то мы и так можем выбирать, даже в дедовском Си (well, sort of…):

#define X 4
int x1 = X; // 4 готова ещё перед компиляцией

int x2 = 2 + 2; // а это будет сделано во время компиляции

Да, препроцессинг позволяет нам сделать что-нибудь небольшое ещё до компиляции. А в примере с x2 компилятор так и вовсе возьмёт и сам вычислит 2 +2 и даже ничего нам не скажет (тиран). Но хочется ведь всё-таки побольше свободы, правда?

Магия

Давайте посмотрим на небольшую демку:

Matrix :: struct (N: int) {
    elements: [N*N] float; // Zero-initialized by default.

    #insert #run identity_initializer(N);
}

identity_initializer :: (N: int) -> string {
    code: String_Builder;

    for i: 0..N-1 {
        print("elements[%] = 1;\n", i*N + i);
        print_to_builder(*code, "elements[%] = 1;\n", i*N + i);
    }

    return builder_to_string(*code);
}

main :: () {
    matrix: Matrix(5);

    for y: 0..matrix.N-1 {
        for x: 0..matrix.N-1 {
            print("% ", matrix.elements[y*matrix.N + x]);
        }
        print("\n");
    }
}

Что ж, пошли компилировать:

~/Tools/jai/demo> jai comptime_matrix.jai
elements[0] = 1;
elements[6] = 1;
elements[12] = 1;
elements[18] = 1;
elements[24] = 1;
Running linker: ...

Stats for Workspace 2 ("Target Program"):
Lexer lines processed: 10323 (17159 including blank lines, comments.)
Front-end time: 0.064636 seconds.
llvm      time: 0.137020 seconds.

Compiler  time: 0.201656 seconds.
Link      time: 0.000218 seconds.
Total     time: 0.201874 seconds.

Подождите-ка, а почему во время билда у нас вдруг какой-то аутпут? Это точно компилируемый язык? Чтобы понять что мы не сошли с ума запустим бинарник:

~/Tools/jai/demo> ./comptime_matrix
1 0 0 0 0
0 1 0 0 0
0 0 1 0 0
0 0 0 1 0
0 0 0 0 1

Всё так, всё компилируется… Вы уже наверняка обратили внимание на странные строчки: #insert #run - да, это и есть слова заклинания для comptime магии. Мы кстати можем сделать наш пример ещё более магическим:

Matrix :: struct (N: int) {
    elements: [N*N] float; // Zero-initialized by default.

    #insert #run identity_initializer(N);
}

identity_initializer :: (N: int) -> string {
    code: String_Builder;

    for i: 0..N-1 {
        print("elements[%] = 1;\n", i*N + i);
        print_to_builder(*code, "elements[%] = 1;\n", i*N + i);
    }

    return builder_to_string(*code);
}

runner :: () {
    matrix: Matrix(5);

    for y: 0..matrix.N-1 {
        for x: 0..matrix.N-1 {
            print("% ", matrix.elements[y*matrix.N + x]);
        }
        print("\n");
    }

}

#run runner();

main :: () {
    // Not even a "hello world" in here!
}

Добавили ещё одно заклинание #run runner(), а main теперь вообще пустой. Итак кастуем спелл:

~/Tools/jai/demo> jai comptime_matrix_full.jai
elements[0] = 1;
elements[6] = 1;
elements[12] = 1;
elements[18] = 1;
elements[24] = 1;
1 0 0 0 0
0 1 0 0 0
0 0 1 0 0
0 0 0 1 0
0 0 0 0 1
Running linker: ...

Stats for Workspace 2 ("Target Program"):
Lexer lines processed: 10326 (17166 including blank lines, comments.)
Front-end time: 0.067675 seconds.
llvm      time: 0.130042 seconds.

Compiler  time: 0.197717 seconds.
Link      time: 0.000206 seconds.
Total     time: 0.197923 seconds.

Магия! Прямо во время сборки бинаря - у нас выполнилась вся программа! Наши заклинания: - #insert - вставляет запущенный код во время сборки в наш файл, заменяя функцию на её результат - в нашем классе Matrix это:

Matrix :: struct {
    elements: [N*N] float; 
    #insert #run identity_initializer(N);
}

=>

Matrix :: struct {
  elements: [25] float;

  elements[0]  = 1;
  elements[6]  = 1;
  elements[12] = 1;
  elements[18] = 1;
  elements[24] = 1;
}

Так компилируемый ли это язык или интерпретируемый? Что-то между, ведь правда?

Как это работает (должен сразу сказать - ничего “академического”) разберёмся позже, но теперь обратим внимание на вот что - в данном примере мы во время билда программы генерировали некий код - на что это похоже? В общем-то да, на метапрограммирование, а сама техника comptime - это в целом некий свой подход к метапрограммированию. Ну давайте, например, посмотрим на то как выглядят дженерики:

Holder :: struct (T: Type, N: s64) {
  array: [N] T;
}

floats:  Holder(float, 5);
vectors: Holder(Vector3, 3);

За исключением синтаксиса, происходит почти то же самое что и в плюсах:

template<typename T, std::size_t N>
struct Holder {
  T array[N];
};

Holder<float, 5> floats;

Во время сборки модуля мы генерируем необходимые версии структуры - однако можно заметить, что в Jai - параметр T - это целая часть “экосистемы” comptime фичи языка - ровно как и запуск функции, вывод текста мы можем сделать как во время компиляции так и во время запуска (comptime vs runtime) - точно также и с параметрами для структуры.

В чём плюсы такого подхода? Ну давайте на самом прямолобом примере:

#import "Basic";

fib :: (n: u64) -> u64 {
    if n < 2 return n;
    return fib(n - 1) + fib(n - 2);
}

main :: () {
    res := fib(34);
    // res := #run fib(34); <- во второй версии раскомм. тут
    print("fib(10) = %\n", res);
}

В версии, где мы честно исполняем Фибоначчи:

movl   $0x22, %esi                    # аргумент 34
callq  0x212990 <fib_4000000da>       # считаем fib(34)
movq   -0x68(%rbp), %rax              # вытягиваем результат

А в магической версии с #run:

movq $0x5704e7, -0x68(%rbp)           # сразу готовая константа!

Мы сократили наш бинарник на несколько инструкций, сделав потенциально дорогостоящую нагрузку на процессор заранее!

В итоге - язык даёт нам полный контроль над тем - что мы хотим выполнять в runtime а что в comptime.

Но как?

Язык который я показывал - это Jai, к сожалению его код закрытый, но к нашему счастью есть другой язык где comptime - это главная фича языка - Zig. Его код открытый, поэтому посмотрим на стадии его компиляции и заглянем внутрь.

Вот так примерно выглядит его пайплайн:

SOURCE -> AST -> ZIR ->[sema]-> AIR -> MIR 

Внутри несколько промежуточных представлений - ZIR и AIR. Переходим из одного в другое после семантического анализа. Вот возьмём простую программу - функцию вызываем со спецификатором comptime:

fn multiply(a :i64, b: i64) i64 {
    return a * b;
}

pub fn main() void {
    const len = comptime multiply(4, 5);
    return len;
}

Посмотрим как выглядит в ZIR - Как видим в ZIR отдельно выделены комптайм блоки:

%15 = block_comptime (reason=comptime_keyword, {
  %16 = decl_val("multiply") token_offset: 6:26 to :6:34
  %17 dbg_stmt(2, 34)
  %18 = call(.auto, %16, [
    {
        %19 = int(4)
        %20 = break_inline (%18, %19)
    },
    {
        %21 = int(5)
        %22 = break_inline (%18, %21) 
    },
  ]) node_offset:6:26 to :6:40 
  %23 = break_inline (%15, %18)
}) node_offset: 6:17 to :6:40

Теперь идём от ZIR к AIR - после семантического анализа смотрим - а у нас вызов функции с параметрами int(4), int(5) - превратилось в константу 20:

# Begin Function AIR: test2.main: 
# Total AIR+Liveness bytes: 206B
# AIR Instructions: 6 (54B)
# AIR Extra Data: 8 (32B)
# Liveness tomb_bits: 8B
# Liveness Extra Data: 0 (OB)
# Liveness special table: 0 (OB)
    %0!= save_err_return_trace_index() %1!= dbg stmt (2:5)
    %3!= dbg_var_val(<i64, 20>, "Len") %4!= dbg stmt(3:5)
    %5!= ret_safe(@.void_value) 
#End Function AIR: test2.main

Стоп-стоп, нет! Это никакая не свёртка констант и не оптимизация. На деле всё предельно просто - на стадии семантического анализа мы просматриваем код, иногда моделируем значения делаем над ними всякую эквилибристику - так почему бы и сразу не интерпретировать их?

Посмотрим на код компилятора. Вот главная функция где значения zir становятся значениями air. Попробуем оттрасировать исполнение нашей программы. Вот мы нашли паттерн матчинг для вызова, он ведёт нас в интерпретатор функций:

fn analyzeBodyInner( sema: *Sema,
    block:
    Block,
    body: []const Zir. Inst. Index,
) CompileError! void {
    // ...
    const air_ref: Air. Inst.Ref = inst: switch (tags[@intFromEnum (inst)]) { .call try sema.zirCall(block, inst, .direct),
    }
    // ...
}

После прохода по дереву до нашего выражения с арифметикой видим очень знакомый код:

fn analyzeArithmetic (
    sema: *Sema,
    ...,
) CompileError! Air. Inst. Ref {
    // ...
    const maybe_lhs_val = sema.resolveValue (casted_lhs); const maybe_rhs_val = sema.resolveValue(casted_rhs);
    if (maybe lhs_val) Ilhs_val❘ {
        if (maybe_rhs_val) Irhs_vall {
            const result_val = switch (zir_tag) {
            // ...
            .mul => try arith.mul(sema, block, resolved_type, lhs_val,
            rhs_val, src, lhs_src, rhs_src),
            // ...
        };
        return Air.internedToRef (result_val.to Intern());
    }
    // ...
}

Как будто из самого наивного интерпретатора, правда ведь? Эвалуируем левую часть, правую и умножаем. Так интерпретировалась наша функция. Затем это значение вернётся в дерево как уже умноженная константа.

Итоги

Подведём итоги комптайма, что было круто: