Выполнениек кода на этапе компиляции (comptime)
Одной из самых мощных и уникальных особенностей языка программирования Zig является ключевое слово comptime
. Оно позволяет выполнять вычисления на этапе компиляции, динамически генерировать код и оптимизировать производительность программы. Если вы посмотрите на официальном сайте Zig, что делает этот язык уникальным, то заметите, что помимо простоты, Zig выделяется возможностью выполнения кода во время компиляции (comptime
). Это одна из ключевых особенностей, которая отличает Zig от других языков.
Что такое comptime
Comptime
— это выполнение кода на этапе компиляции, что позволяет программисту решать множество разнообразных задач. Это включает в себя использование шаблонных типов, генерацию кода на основе конфигурации и многое другое. В отличие от других языков программирования, в Zig выполнение кода на этапе компиляции — это выполнение того же самого кода на Zig, который используется во время выполнения программы. Это означает, что вам не нужно изучать какой-то отдельный специализированный язык, как, например, макросы в Rust, или разбираться с директивами препроцессора, как в языке C. Конечно, код, который выполняется на этапе компиляции, имеет свои ограничения, но в целом это тот же самый код, что и в основной части программы.
Как работает comptime
Comptime
работает за счет выполнения кода на этапе компиляции. Это означает, что код, помеченный как comptime
, будет выполнен во время компиляции, а не во время запуска программы. При этом компилятор Zig достаточно умный и не всегда требует явного указания comptime
. В некоторых случаях он самостоятельно определяет, что часть кода может быть выполнена на этапе компиляции, основываясь на анализе программы. Например, если вы объявляете переменную с начальным значением, компилятор Zig может понять, что это значение не будет изменяться во время выполнения, и автоматически пометить соответствующий код как comptime
.
Давайте теперь рассмотрим когда вы можете использовать выполнение кода во время компиляции:
-
Шаблонные функции
Шаблонные функции — это один из самых распространенных и простых способов использования comptime в Zig. Они позволяют создавать функции, которые могут работать с разными типами данных. Компилятор Zig автоматически генерирует код для каждого типа данных, который используется в шаблонной функции. Это похоже на поведение шаблонов в C++ или дженериков в Rust. Например, вы можете написать одну функцию, которая будет работать как с целыми числами, так и с числами с плавающей точкой, и компилятор создаст отдельные версии кода для каждого типа.
-
Генерации типов и кода на основе входных данных
Иногда возникает необходимость генерировать типы или код на основе входных данных. Например, вы можете создать функцию, которая динамически генерирует структуру данных на основе списка полей, переданных в качестве аргументов. Или вам может понадобиться создать несколько перечислений (enum) на основе списка значений. Еще один пример — отключение платформо-специфичного кода в зависимости от целевой платформы. Таких случаев множество: comptime позволяет гибко изменять код программы на этапе компиляции, основываясь на входных данных или условиях.
-
Проверки инвариантов и статические проверки
Чем больше проверок выполняет ваша программа, тем надежнее она работает. Однако выполнение проверок во время выполнения программы может негативно сказаться на производительности. Вместо этого можно использовать статические проверки, которые выполняются на этапе компиляции. Это позволяет обнаружить ошибки до запуска программы, что делает код более безопасным и предсказуемым. Например, вы можете проверить, что структура содержит определенные поля, или что массив, передаваемый в функцию, не превышает ожидаемый размер. Также можно убедиться, что другие разработчики не нарушили ваши ожидания относительно входных данных, например, не передали в функцию данные, которые не соответствуют вашим требованиям.
-
Оптимизация кода
Генерация кода на этапе компиляции позволяет оптимизировать выполнение программы. Например, можно встраивать вызовы функций непосредственно в места их использования (inline-оптимизация) или разворачивать циклы в последовательность операторов. Это особенно полезно для “горячих” участков кода, которые выполняются часто и требуют максимальной производительности. Однако не каждый цикл или вызов функции нужно оптимизировать — важно сосредоточиться на тех частях программы, которые действительно влияют на общую производительность.
Выполнение кода на этапе компиляции, конечно, имеет свои ограничения, которые важно учитывать. Например, в коде, который выполняется во время компиляции, не должно быть никаких побочных эффектов (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, «последним пришёл — первым ушёл»). Представьте себе стопку тарелок: вы кладёте тарелки сверху и берёте их тоже сверху. В программировании стек предоставляет две основные операции:
- push — добавление элемента на вершину стека.
- pop — извлечение элемента с вершины стека.
В нашем примере мы создаём параметрический (обобщённый) тип Stack
, который может работать с элементами любого типа. Давайте подробно рассмотрим, как это работает.
Функция Stack
возвращает новый тип — структуру, настроенную для работы с элементами типа T
. Наш стек состоит из следующих компонентов:
items: []T
— срез (массив с известной длиной), который хранит элементы типаT
.count: usize
— текущее количество элементов в стеке.allocator: std.mem.Allocator
— аллокатор памяти, который управляет выделением и освобождением ресурсов.
Метод init
выполняет важную работу:
- Запрашивает память для хранения элементов стека через аллокатор.
- Устанавливает начальное количество элементов в ноль.
- Сохраняет ссылку на аллокатор, чтобы в дальнейшем можно было корректно освободить память.
Метод deinit
отвечает за освобождение памяти, выделенной для хранения элементов стека, используя переданный аллокатор. Это важно для предотвращения утечек памяти.
Далее у нас есть два ключевых метода:
push
— добавляет элемент на вершину стека.pop
— извлекает элемент с вершины стека.
Во всех трёх методах, кроме 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 в каждую функцию: В каждой функции мы можем обработать только те события, которые нам интересны, а для остальных добавить ветку
else
, чтобы игнорировать их. Это простое решение, но оно может привести к дублированию кода и усложнению его поддержки. - Перечисление всех событий и использование unreachable: В каждой функции мы можем явно перечислить все события, а для тех, которые не нужно обрабатывать, добавить
unreachable
. Это гарантирует, что компилятор будет знать, что эти случаи никогда не должны происходить, но такой подход требует больше ручной работы и может быть подвержен ошибкам.
Оба варианта имеют свои недостатки: либо нам надо написать много лишнего кода, либо мы нарушаем типобезопасность, как в случае с добавлением 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.