Выполнениек кода на этапе компиляции (comptime)

2025-03-22 5014 24

Одной из самых мощных и уникальных особенностей языка программирования Zig является ключевое слово comptime. Оно позволяет выполнять вычисления на этапе компиляции, динамически генерировать код и оптимизировать производительность программы. Если вы посмотрите на официальном сайте Zig, что делает этот язык уникальным, то заметите, что помимо простоты, Zig выделяется возможностью выполнения кода во время компиляции (comptime). Это одна из ключевых особенностей, которая отличает Zig от других языков.

Что такое comptime

Comptime — это выполнение кода на этапе компиляции, что позволяет программисту решать множество разнообразных задач. Это включает в себя использование шаблонных типов, генерацию кода на основе конфигурации и многое другое. В отличие от других языков программирования, в Zig выполнение кода на этапе компиляции — это выполнение того же самого кода на Zig, который используется во время выполнения программы. Это означает, что вам не нужно изучать какой-то отдельный специализированный язык, как, например, макросы в Rust, или разбираться с директивами препроцессора, как в языке C. Конечно, код, который выполняется на этапе компиляции, имеет свои ограничения, но в целом это тот же самый код, что и в основной части программы.

Как работает comptime

Comptime работает за счет выполнения кода на этапе компиляции. Это означает, что код, помеченный как comptime, будет выполнен во время компиляции, а не во время запуска программы. При этом компилятор Zig достаточно умный и не всегда требует явного указания comptime. В некоторых случаях он самостоятельно определяет, что часть кода может быть выполнена на этапе компиляции, основываясь на анализе программы. Например, если вы объявляете переменную с начальным значением, компилятор Zig может понять, что это значение не будет изменяться во время выполнения, и автоматически пометить соответствующий код как comptime.

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

Выполнение кода на этапе компиляции, конечно, имеет свои ограничения, которые важно учитывать. Например, в коде, который выполняется во время компиляции, не должно быть никаких побочных эффектов (side effects). Это означает, что вы не можете читать файлы с диска, обрабатывать пользовательский ввод или вызывать системные вызовы (syscalls). Такие действия требуют доступа к внешним ресурсам, которые недоступны на этапе компиляции, так как программа еще не запущена. Вы не можете выделять память или создавать объекты, так как это требует выполнения кода во время выполнения программы.

Однако эти ограничения редко становятся проблемой. Большинство задач, которые решаются на этапе компиляции, не требуют взаимодействия с внешними ресурсами. Например, генерация кода, проверка типов, создание структур данных на основе входных параметров или оптимизация — все это можно эффективно выполнять без необходимости доступа к файловой системе, сети или другим внешним данным. Таким образом, ограничения comptime не мешают его использованию для решения широкого круга задач, связанных с метапрограммированием и статическим анализом.

Давайте теперь рассмотрим примеры использования comptime. Простейший пример использования comptime - вычисление значений во время компиляции:

const std = @import("std");

fn square(comptime x: i32) i32 {
    return x * x;
}

pub fn main() void {
    const result = square(5);
    std.debug.print("Square: {}\n", .{result});
}

В этом примере вызов square(5) выполняется на этапе компиляции, и результат этого вычисления сохраняется в переменной result как константа. Для того, чтобы компилятор понимал что мы хотим выполнить код на этапе компиляции, мы должны использовать ключевое слово comptime для параметра нашей функции. Выполнение кода на этапе компиляции позволяет инициализировать не только простые константы, но и более сложные типы данных, такие как массивы или структуры, еще до запуска программы.

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

var i = 0;
var j = 0.0;

В этом случае Zig определит типы наших переменных не как i32 и f64, а как comptime_int и comptime_float. Это специальные типы в Zig, которые используются для представления значений, известных на этапе компиляции. Мы уже сталкивались с ними, когда пытались изменить значение переменной, объявленной таким образом, и получали ошибку компиляции. В таких ситуациях компилятор предлагает нам либо сделать переменную константой, либо явно указать её тип, чтобы избежать неоднозначности.

Любой код, помеченный как comptime, должен работать только с данными, которые известны во время компиляции. В случае целых и вещественных чисел такие данные имеют специальные типы — comptime_int и comptime_float. Эти типы позволяют компилятору понимать, что значения вычисляются на этапе компиляции, и обеспечивают безопасность и предсказуемость кода. Например, если вы используете comptime_int для вычисления константы, компилятор гарантирует, что это значение будет известно до запуска программы, что исключает возможность ошибок во время выполнения.

Давайте теперь рассмотрим различные применения comptime кода в языке Zig более подробно.

Обобщенные типы (generic)

Одной из мощных возможностей языка Zig является поддержка обобщенных (generic) типов, которые позволяют создавать гибкие и переиспользуемые компоненты кода. В отличие от некоторых других языков, где для этого используются шаблоны или дженерики, Zig реализует эту функциональность через механизм выполнение кода на этапе компиляции. Это делает обобщенные типы в Zig простыми, предсказуемыми и эффективными.

Generic типы — это типы, которые могут работать с любыми другими типами данных. Они позволяют писать функции, структуры или другие конструкции, которые не зависят от конкретного типа данных, но при этом сохраняют типобезопасность. Например, вы можете создать функцию, которая работает как с целыми числами, так и с числами с плавающей точкой, не дублируя код.

В Zig обобщенные типы реализуются с помощью comptime параметров. Это означает, что типы передаются в функции или структуры как параметры, известные на этапе компиляции. Компилятор генерирует специализированные версии кода для каждого используемого типа, что обеспечивает высокую производительность.

Давайте теперь перейдем к примеру и сначала рассмотрим вариант кода без обобщения:

const std = @import("std");

fn addInt(a: i32, b: i32) i32 {
    return a + b;
}

fn addFloat(a: f32, b: f32) f32 {
    return a + b;
}

pub fn main() void {
    const intResult = addInt(5, 10); // Работает с целыми числами
    const floatResult = addFloat(3.14, 2.71); // Работает с числами с плавающей точкой

    std.debug.print("Сумма целых чисел: {d}\n", .{intResult});
    std.debug.print("Сумма чисел с плавающей точкой: {d}\n", .{floatResult});
}

Как мы видим, для того чтобы складывать целые и вещественные числа, нам приходится дублировать код, который отличается только типами входных и выходных параметров. Например, если у нас есть функция для сложения чисел типа i32, то для сложения чисел типа f64 нам нужно написать практически идентичную функцию, изменив только типы. А если нам понадобится складывать числа других типов, таких как i8, u16 или usize, то нам придется продублировать функцию и для них. В результате код становится громоздким, а его поддержка — более сложной.

Такая работа кажется излишней и неэффективной. Ведь программист вынужден вручную копировать и изменять код, хотя эту задачу мог бы автоматизировать компилятор. Нам достаточно лишь объяснить ему, как сгенерировать нужный набор функций для разных типов данных. И в Zig такая возможность действительно есть. Более того, возможности comptime в Zig настолько мощные, что существует аж два способа реализации обобщенных типов.

Первый способ, который мы рассмотрим, встречается в коде довольно часто. Он прост в использовании и позволяет быстро создавать гибкие функции, работающие с разными типами данных. Давайте разберем его подробнее:

const std = @import("std");

fn add(comptime T: type, a: T, b: T) T {
    return a + b;
}

pub fn main() void {
    const intResult = add(i32, 5, 10); // Работает с целыми числами
    const floatResult = add(f64, 3.14, 2.71); // Работает с числами с плавающей точкой

    std.debug.print("Сумма целых чисел: {d}\n", .{intResult});
    std.debug.print("Сумма чисел с плавающей точкой: {d}\n", .{floatResult});
}

Этот код выведет:

Сумма целых чисел: 15
Сумма чисел с плавающей точкой: 5.85

Чтобы реализовать обобщенную функцию, способную работать с разными типами данных, мы добавили в неё переменную T с типом type и пометили её префиксом comptime. Это нужно для того, чтобы компилятор понимал, что значение этой переменной должно быть известно на этапе компиляции. Затем в нашем коде мы заменили все конкретные типы на наш обобщённый тип T.

Вот и всё! После этого компилятор автоматически сгенерирует необходимый набор функций на основе всех вызовов нашего кода. Например, если вы вызываете функцию с типами i32 и f64, компилятор создаст две специализированные версии функции: одну для i32, а другую для f64. Это избавляет нас от необходимости вручную дублировать код для каждого типа, делая его более компактным и удобным для поддержки.

Но как я сказал, возможности comptime в Zig настолько мощные, что мы можем переписать наш код по другому и получить тот же результат:

const std = @import("std");

fn add(a: anytype, b: @TypeOf(a)) @TypeOf(a) {
    return a + b;
}

pub fn main() void {
    const intResult = add(5, 10); // Работает с целыми числами
    const floatResult = add(3.14, 2.71); // Работает с числами с плавающей точкой

    std.debug.print("Сумма целых чисел: {d}\n", .{intResult});
    std.debug.print("Сумма чисел с плавающей точкой: {d}\n", .{floatResult});
}

В этом примере мы определяем тип первого параметра нашей функции как anytype. Это специальный тип в Zig, который указывает, что параметр может быть любого типа. Далее, используя возможности рефлексии в Zig, мы указываем, что тип второго параметра и тип возвращаемого значения должны быть такими же, как и у первого параметра. Для этого мы используем встроенную функцию @TypeOf, которая возвращает тип переданного ей значения. Таким образом, мы гарантируем, что оба параметра и результат функции будут одного и того же типа, что делает функцию типобезопасной.

Сумма целых чисел: 15
Сумма чисел с плавающей точкой: 5.85

Конечно наш код работает, но его можно довольно просто сломать. Давайте рассмотрим следущий пример использования нашей универсальной функции:

const std = @import("std");

fn add(a: anytype, b: @TypeOf(a)) @TypeOf(a) {
    return a + b;
}

pub fn main() void {
    const result = add(false, true);

    std.debug.print("Сумма: {any}\n", .{result});
}

Если мы запустим наш пример, то получим ошибку компиляции:

run
└─ run simple
   └─ zig build-exe simple Debug native 1 errors
src/main.zig:4:14: error: invalid operands to binary expression: 'bool' and 'bool'
    return a + b;
           ~~^~~

Компилятор сообщает нам, что для булевых значений нельзя использовать оператор сложения. Это происходит потому, что в Zig оператор сложения не определён для типа bool. В других языках, таких как Rust или Go, подобные проблемы часто решаются путём указания компилятору, что используемые типы должны поддерживать определённые операции. В Zig вместо этого мы можем использовать все те же возможности comptime, чтобы решить нашу проблему:

const std = @import("std");

fn add(a: anytype, b: @TypeOf(a)) @TypeOf(a) {
    return switch (@typeInfo(@TypeOf(a))) {
        .comptime_int, .int => a + b,
        .comptime_float, .float => a + b,
        else => @compileError("Unsupported type"),
    };
}

pub fn main() void {
    const result = add(true, false);

    std.debug.print("Сумма: {any}\n", .{result});
}

Чтобы решить эту проблему, мы воспользовались двумя встроенными функциями, доступными в comptime: @typeInfo и @TypeOf.

Функция @TypeOf была использована для получения типа аргумента a. Она возвращает информацию о типе переменной, переданной в качестве аргумента. Это позволяет нам динамически определять тип данных, с которым работает функция.

Функция @typeInfo была использована для получения подробной информации о типе переменной a. Она возвращает структуру, содержащую такие характеристики типа, как его размер, знак (если это числовой тип), а также другие метаданные. Это помогает нам анализировать тип и принимать решения на основе его свойств.

После получения информации о типе, мы сравниваем его с допустимыми типами, которые поддерживает наша функция. Если тип не соответствует ожидаемым, мы используем встроенную функцию @compileError, чтобы сгенерировать ошибку компиляции. Это позволяет нам сразу же сообщить разработчику о проблеме, еще на этапе компиляции, вместо того чтобы допустить выполнение некорректного кода.

Таким образом, комбинация @TypeOf, @typeInfo и @compileError позволяет нам создавать гибкие и безопасные функции, которые могут динамически проверять типы данных и предотвращать ошибки до запуска программы.

Конечно Вы можете использовать возможности comptime не только для функций, но и в структурах. Давайте рассмотрим как может выглядеть универсальная структура стека:

const std = @import("std");

fn Stack(comptime T: type) type {
    return struct {
        items: []T,
        count: usize,
        allocator: std.mem.Allocator,

        const Self = @This();

        fn init(allocator: std.mem.Allocator, capacity: usize) !Self {
            const items = try allocator.alloc(T, capacity);
            return Self{
                .items = items,
                .count = 0,
                .allocator = allocator,
            };
        }

        fn deinit(self: *Self) void {
            self.allocator.free(self.items);
            self.items = undefined;
            self.count = 0;
        }

        fn push(self: *Self, item: T) !void {
            if (self.count >= self.items.len) {
                return error.StackFull;
            }
            self.items[self.count] = item;
            self.count += 1;
        }

        fn pop(self: *Self) ?T {
            if (self.count == 0) return null;
            self.count -= 1;
            return self.items[self.count];
        }
    };
}

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    var intStack = try Stack(i32).init(allocator, 10); // Create a stack with capacity 10
    defer intStack.deinit();

    try intStack.push(10);
    try intStack.push(20);

    const poppedValue = intStack.pop(); // Returns 20

    if (poppedValue) |value| {
        std.debug.print("Received value: {}\n", .{value});
    }
}

Стек — это одна из фундаментальных структур данных в программировании, которая реализует принцип LIFO (Last-In-First-Out, «последним пришёл — первым ушёл»). Представьте себе стопку тарелок: вы кладёте тарелки сверху и берёте их тоже сверху. В программировании стек предоставляет две основные операции:

  1. push — добавление элемента на вершину стека.
  2. pop — извлечение элемента с вершины стека.

В нашем примере мы создаём параметрический (обобщённый) тип Stack, который может работать с элементами любого типа. Давайте подробно рассмотрим, как это работает.

Функция Stack возвращает новый тип — структуру, настроенную для работы с элементами типа T. Наш стек состоит из следующих компонентов:

Метод init выполняет важную работу:

  1. Запрашивает память для хранения элементов стека через аллокатор.
  2. Устанавливает начальное количество элементов в ноль.
  3. Сохраняет ссылку на аллокатор, чтобы в дальнейшем можно было корректно освободить память.

Метод deinit отвечает за освобождение памяти, выделенной для хранения элементов стека, используя переданный аллокатор. Это важно для предотвращения утечек памяти.

Далее у нас есть два ключевых метода:

Во всех трёх методах, кроме deinit, мы используем тип T, что делает нашу структуру обобщённой. Это позволяет стеку работать с любыми типами данных, будь то целые числа, строки, структуры или даже другие сложные типы.

Как мы видим, создание обобщённых типов или функций в Zig выполняется довольно просто и интуитивно. Для этого не требуется изучать дополнительный язык (как, например, макросы в Rust) или осваивать сложные концепции. Всё, что нужно, — это использовать встроенные возможности Zig, такие как comptime и обобщённые типы, чтобы писать гибкий и типобезопасный код. Это делает Zig мощным и удобным инструментом для создания универсальных и эффективных структур данных.

Метапрограммирование

Метапрограммирование — это техника, при которой программы создают или модифицируют другие программы (или самих себя) во время выполнения или на этапе компиляции. Мы уже видели некоторые примеры генерации кода на этапе компиляции, когда рассматривали заполнение массива начальными значениями с помощью comptime-функций, а также когда разбирали пример использования каррированной функции.

const std = @import("std");

fn makeAdder(comptime n: i32) fn (i32) i32 {
    return struct {
        fn add(x: i32) i32 {
            return x + n;
        }
    }.add;
}

pub fn main() void {
    const addFive = makeAdder(5);
    const result = addFive(10); // result = 15

    std.debug.print("Result: {d}\n", .{result});
}

Самым простым примером использования comptime для генерации кода является возможность отключать платформозависимый код или части кода, например, используемые для отладки. Давайте рассмотрим, как это можно реализовать:

const builtin = @import("builtin");

fn myFunction() void {
    if (comptime builtin.os.tag == .macos) {
        // This code will only be included if the target OS is macOS.
        return;
    }

    // This code will be included for all other operating systems.
}

В этом примере мы используем проверку на версию ОС, чтобы принять решение включать код в программу или нет. В целом в данном коде нет ничего сложного, чтобы требовало бы объяснения. Но это не единственные случаи метапрограммирования в Zig. Например, мы можем использовать comptime функции для генерации кода на основе конфигурации или для создания типов на основе данных, полученных во время компиляции. Давайте рассмотрим следующую задачу - предположим у нас есть код обрабатывающий события в нашей программе и он выглядит следующим образом:

pub const Event = union(enum) {
  open_site: void,
  close_site: void,
  close_all_sites: void,
  open_config: void,
  reload_config: void,
  // и еще огромное количество других типов событий
};

pub fn printEvent(event: Event) void {
    std.debug.print("Event printed {}\n", .{event});
}

pub fn processEvent(event: Event) void {
  switch (event) {
    .open_site => ...,
    .close_site => ...,
    .close_all_sites => ...,
    .open_config => ...,
    .reload_config => ...,
  }
}

В нашем примере у нас есть маркированное объединение (tagged union), которое содержит события, происходящие в нашей программе. Также у нас есть функция, которая обрабатывает эти события, и функция, которая записывает выполненное событие в лог. Теперь предположим, что мы хотим обрабатывать события не только для всего приложения, но и для открытой страницы сайта. Для этого нам нужно заменить функцию processEvent на две отдельные функции: processAppEvent и processSiteEvent. Однако мы не хотим изменять остальной код, который уже работает с объединением Event.

Если не знать о возможностях comptime в Zig, то у нас есть два варианта:

Оба варианта имеют свои недостатки: либо нам надо написать много лишнего кода, либо мы нарушаем типобезопасность, как в случае с добавлением else. Давайте попробуем решить эту задачу, используя возможности comptime в Zig.

Для начала добавим перечисление Context, которое будет содержать список контекстов, в которых может выполняться событие. Затем расширим наше объединение Event, добавив в него функцию context. Эта функция будет определять, к какому контексту относится каждое событие, и “раскладывать” события по соответствующим контекстам:

pub const Context = enum { app, window };

pub const Event = union(enum) {
    open_site: void,
    close_site: void,
    close_all_sites: void,
    open_config: void,
    reload_config: void,
    scroll_lines: i16,
    close_window: void,
    activate_window: void,
    // и еще огромное количество других типов событий

    pub fn scope(event: Event) Scope {
        return switch (event) {
            .open_site, .close_site, .close_all_sites, .open_config, .reload_config => .app,
            .scroll_lines, .close_window, .activate_window => .window,
        };
    }
};

Теперь начинается основная магия возможностей Zig - нам нужно написать функцию, которая будет из нашего типа Event создавать тип, содержащий только варианты для конкретного контекста:

pub fn ContextEvent(comptime s: Context) type {
    const all_fields = @typeInfo(Event).@"union".fields;

    // Find all fields that are scoped to s
    var i: usize = 0;
    var union_fields: [all_fields.len]std.builtin.Type.UnionField = undefined;
    var enum_fields: [all_fields.len]std.builtin.Type.EnumField = undefined;
    for (all_fields) |field| {
        const action = @unionInit(Event, field.name, undefined);
        if (action.context() == s) {
            union_fields[i] = field;
            enum_fields[i] = .{ .name = field.name, .value = i };
            i += 1;
        }
    }

    // Build our union
    return @Type(.{ .@"union" = .{
        .layout = .auto,
        .tag_type = @Type(.{ .@"enum" = .{
            .tag_type = std.math.IntFittingRange(0, i),
            .fields = enum_fields[0..i],
            .decls = &.{},
            .is_exhaustive = true,
        } }),
        .fields = union_fields[0..i],
        .decls = &.{},
    } });
}

Давайте разберём, как работает наша “магическая” функция. С помощью уже знакомой встроенной функции @typeInfo мы получаем информацию о нашем типе. Эта информация включает, среди прочего, список полей, которые содержатся в нашем объединении (union).

Затем мы перебираем все поля объединения и проверяем, принадлежит ли каждое поле нашему контексту. Если поле принадлежит нужному контексту, мы сохраняем его.

Для создания значения объединения мы используем функцию @unionInit. Она создаёт значение объединения, используя известное на этапе компиляции имя поля и значение. В нашем случае значением является undefined (неинициализированная память), так как нам важно только имя поля (тег), а не само значение. Это связано с тем, что наша функция context анализирует только тег объединения, а не его содержимое.

Наконец, встроенная функция @Type создаёт новый тип. В данном случае мы создаём новое объединение, используя только те поля, которые мы отфильтровали и сохранили на предыдущих шагах.

Таким образом, наша функция динамически создаёт новое объединение, содержащее только те поля, которые соответствуют заданному контексту. Давайте теперь посмотрим на полную версию кода:

const std = @import("std");

pub const Context = enum { app, window };

pub fn printEvent(event: Event) void {
    std.debug.print("Event printed {}\n", .{event});
}

pub fn ContextEvent(comptime s: Context) type {
    const all_fields = @typeInfo(Event).@"union".fields;

    // Find all fields that are scoped to s
    var i: usize = 0;
    var union_fields: [all_fields.len]std.builtin.Type.UnionField = undefined;
    var enum_fields: [all_fields.len]std.builtin.Type.EnumField = undefined;
    for (all_fields) |field| {
        const action = @unionInit(Event, field.name, undefined);
        if (action.context() == s) {
            union_fields[i] = field;
            enum_fields[i] = .{ .name = field.name, .value = i };
            i += 1;
        }
    }

    // Build our union
    return @Type(.{ .@"union" = .{
        .layout = .auto,
        .tag_type = @Type(.{ .@"enum" = .{
            .tag_type = std.math.IntFittingRange(0, i),
            .fields = enum_fields[0..i],
            .decls = &.{},
            .is_exhaustive = true,
        } }),
        .fields = union_fields[0..i],
        .decls = &.{},
    } });
}

pub const Event = union(enum) {
    open_site: void,
    close_site: void,
    close_all_sites: void,
    open_config: void,
    reload_config: void,
    scroll_lines: i16,
    close_window: void,
    activate_window: void,
    // и еще огромное количество других типов событий

    pub fn context(event: Event) Context {
        return switch (event) {
            .open_site, .close_site, .close_all_sites, .open_config, .reload_config => .app,
            .scroll_lines, .close_window, .activate_window => .window,
        };
    }
};

pub fn processAppEvent(action: ContextEvent(.app)) void {
    switch (action) {
        .open_site => std.debug.print("Opening site...\n", .{}),
        .close_site => std.debug.print("Closing site...\n", .{}),
        .close_all_sites => std.debug.print("Closing all sites...\n", .{}),
        .open_config => std.debug.print("Opening config...\n", .{}),
        .reload_config => std.debug.print("Reloading config...\n", .{}),
    }
}

pub fn processWindowEvent(event: ContextEvent(.window)) void {
    switch (event) {
        .scroll_lines => |amount| std.debug.print("Scrolling {} lines...\n", .{amount}),
        .close_window => std.debug.print("Closing window...\n", .{}),
        .activate_window => std.debug.print("Activating window...\n", .{}),
    }
}

pub fn main() void {
    printEvent(.open_site);
    processAppEvent(.open_site);

    printEvent(.close_window);
    processWindowEvent(.close_window);
}

Если мы запустим этот код, то увидим:

Event printed main.Event{ .open_site = void }
Opening site...
Event printed main.Event{ .close_window = void }
Closing window...

Как мы видим, Zig предоставляет довольно обширные возможности для метапрограммирования. Самое приятное, что для выполнения даже сложных преобразований в коде вам нужно только разобраться с несколькими встроенными функциями — и всё! Вам не требуется изучать отдельный язык (как, например, макросы в Rust) или осваивать сложные концепции. Всё, что нужно, уже встроено в Zig и работает на основе ключевого слова comptime.

Рефлексия кода

Рефлексия кода — это возможность программы анализировать и модифицировать свою структуру во время выполнения или на этапе компиляции. Это открывает широкие возможности для анализа типов, кода и создания гибких, адаптируемых программ. С помощью рефлексии в Zig вы можете анализировать типы, структуры, функции или даже модули для того, чтобы выполнять дополнительные проверки кода, заполнять структуры на основании их анализа и создавать гибкие функции. Давайте рассмотрим несколько примеров как мы можем использовать рефлексию в Zig.

Для начала давайте представим, что у нас есть структура User с несколькими полями, и мы хотим написать код, который создаст экземпляр этой структуры, заполнив её поля значениями по умолчанию в зависимости от их типов. Давайте рассмотрим код, который решает эту задачу:

const std = @import("std");

fn createDefaultInstance(comptime T: type) T {
    var instance: T = undefined;
    const info = @typeInfo(T);

    if (info != .@"struct") {
        @compileError("Expected a struct type");
    }

    inline for (info.@"struct".fields) |field| {
        const field_type = field.type;
        if (field_type == bool) {
            @field(instance, field.name) = false;
        } else if (field_type == u8) {
            @field(instance, field.name) = 0;
        } else if (field_type == []const u8) {
            @field(instance, field.name) = "";
        } else if (field_type == u32) {
            @field(instance, field.name) = 0;
        }
    }

    return instance;
}

const User = struct {
    id: u32,
    name: []const u8,
    age: u8,
    active: bool,
};

pub fn main() void {
    const user = createDefaultInstance(User);
    std.debug.print("User: {}\n", .{user});
}

В нашем примере мы используем уже знакомую функцию @typeInfo, чтобы получить информацию о типе структуры User. Затем, анализируя тип каждого поля структуры, мы заполняем эти поля, используя встроенную функцию @field. Функция @field позволяет получить доступ к полю структуры по его имени и типу, а также задать значение для этого поля. Это делает код гибким и удобным для работы с различными типами данных. Аналогичным образом мы могли бы реализовать код, который бы заполнял поля нашей структуры на основе данных полученных из базы данных например.

Используя функции для рефлексии мы можем анализировать не только структуры, но и например модули. Мы еще не говорили про модули и как они устроены в zig, но давайте представим, что модули это просто файлы на диске вашей программы. Предположим мы пишем код в файле user.zig и хотим в методах нашей структуры добавить дополнительные проверки, но не хотим, чтобы этот код попадал в конечный код пользователю, а хотим чтобы мы могли его использовать в наших тестовых сборках. Для решения нашей задачи мы напишем свою функцию assert, оборачивающую стандартную функцию std.debug.assert:

pub fn assert(ok: bool) void {
  if (comptime assert_enabled) {
    std.debug.assert(ok);
  }
}

Но как нам получить доступ к параметру assert_enabled внутри нашего файла, чтобы не приходилось передавать его в каждый модуль вручную? Мы могли бы решить эту задачу, используя глобальную константу на уровне библиотеки. Однако хотелось бы, чтобы, если мы не хотим управлять проверками вручную, нам не приходилось хранить в корневом модуле глобальную константу.

В Zig есть встроенная функция @hasDecl, которая позволяет проверить, определён ли идентификатор (например, переменная или функция) в переданном объекте — будь то структура, модуль или другой тип. Это даёт нам возможность гибко управлять настройками без необходимости явно передавать параметры. Давайте изменим наш код в файле user.zig, чтобы использовать эту возможность.

const root = @import("root");

const assert_enabled = if (@hasDecl(root, "lib_assert")) root.lib_assert else false;
pub fn assert(ok: bool) void {
  if (comptime assert_enabled) {
    std.debug.assert(ok);
  }
}

В этом коде мы импортируем корневой модуль нашей библиотеки и проверям определена ли в нем переменная lib_assert. Если она определена, то мы используем ее значение, иначе мы используем значение false.

И, наконец, давайте рассмотрим ещё один пример использования рефлексии. Предположим, мы написали кеш, который реализован как универсальный тип данных. Мы хотим, чтобы при удалении элемента из кеша, если элемент реализует метод onRemove, этот метод автоматически вызывался.

Для проверки наличия метода у структуры в Zig есть встроенная функция std.meta.hasMethod. Она позволяет определить, содержит ли тип определённый метод. Давайте напишем код нашего кеша с использованием этой возможности:

const std = @import("std");

pub fn Cache(comptime T: type) type {
    return struct {
        cache: std.ArrayList(T),

        pub fn init() Cache(T) {
            return Cache(T){
                .cache = std.ArrayList(T).init(std.heap.page_allocator),
            };
        }

        pub fn add(self: *Cache(T), item: T) !void {
            try self.cache.append(item);
        }

        pub fn remove(self: *Cache(T), index: usize) !void {
            try self.cache.remove(index);
        }

        pub fn clear(self: *Cache(T)) void {
            while (self.cache.pop()) |item| {
                if (comptime std.meta.hasMethod(T, "onRemove")) {
                    T.onRemove(item);
                }
            }
        }
    };
}

const User = struct {
    id: u32,
    name: []const u8,

    pub fn onRemove(self: User) void {
        std.debug.print("User {d} removed from cache\n", .{self.id});
    }
};

pub fn main() !void {
    var cache = Cache(User).init();
    const alice = User{ .id = 1, .name = "Alice" };
    const bob = User{ .id = 2, .name = "Bob" };

    try cache.add(alice);
    try cache.add(bob);

    cache.clear();
}

Это вполне рабочий пример кода, который прекрасно работает как с встроенными типами, так и с пользовательскими типами. Но в нашем коде есть одна проблема - если использовать в качестве нашего типа указатель на структуру, то метод onRemove не будет вызван, так как он не будет доступен через указатель. Для того чтобы исправить эту проблему, нам потребуется уже известная нам функция @typeInfo. Давайте исправим наш код:

const std = @import("std");

pub fn Cache(comptime T: type) type {
    return struct {
        cache: std.ArrayList(T),

        pub fn init() Cache(T) {
            return Cache(T){
                .cache = std.ArrayList(T).init(std.heap.page_allocator),
            };
        }

        pub fn add(self: *Cache(T), item: T) !void {
            try self.cache.append(item);
        }

        pub fn remove(self: *Cache(T), index: usize) !void {
            try self.cache.remove(index);
        }

        pub fn clear(self: *Cache(T)) void {
            while (self.cache.pop()) |item| {
                if (comptime std.meta.hasMethod(T, "onRemove")) {
                    switch (@typeInfo(T)) {
                        .pointer => |ptr| ptr.child.onRemove(item.*),
                        else => T.onRemove(item),
                    }
                }
            }
        }
    };
}

const User = struct {
    id: u32,
    name: []const u8,

    pub fn onRemove(self: User) void {
        std.debug.print("User {d} removed from cache\n", .{self.id});
    }
};

pub fn main() !void {
    var cache = Cache(*User).init();
    var alice = User{ .id = 1, .name = "Alice" };
    var bob = User{ .id = 2, .name = "Bob" };

    try cache.add(&alice);
    try cache.add(&bob);

    cache.clear();
}

Теперь наш код успешно работает как и с типами данных, так и с указателями на типы.

Заключение

Выполнение кода на этапе компиляции — это одна из самых уникальных и мощных особенностей Zig. Он позволяет разработчикам писать гибкий, эффективный и безопасный код, который адаптируется под различные типы данных и условия. Благодаря comptime, Zig становится не только языком для системного программирования, но и мощным инструментом для метапрограммирования, что делает его отличным выбором для создания высокопроизводительных и надёжных приложений. Конечно мы рассмотрели далеко не все возможности которые предоставляет стандартная библиотека Zig, но целью этой главы было показывать вам всю силу comptime вычислений и заинтересовать вас в изучении и использовании comptime в Zig.

#comptime #zig #zigbook

0%