В последнее время в блоге автора выходило несколько публикаций о Zig (http.zig, log.zig и websocket.zig). Автор полагает, что ему в этой области ещё учиться и учиться, и часто сталкивается с вещами, которые остаются для него удивительными или непонятными. Пожалуй, автор такой не один, поэтому было бы полезно разобрать причуды Zig.
Возможно, для многих читателей некоторые из этих вещей просто очевидны.
1 - const Self = @This()
Если вы начнёте просматривать исходный код Zig, то достаточно скоро увидите:
const Self = @This();
Начнём с того, что, если функция начинается с @
, например @This()
, то это встроенная функция – такие функции предоставляются компилятором. @This()
возвращает тип самого глубокого экземпляра структуры/перечисления/объединение. Например, следующий код выводит в консоль «true»:
const Tea = struct {
const Self = @This();
};
pub fn main() !void {
// о синтаксисе .{} поговорим позже!
// выводит "true"
std.debug.print("{}\n", .{Tea == Tea.Self});
}
Такой код часто используется в методе для указания получателя:
const Tea = struct {
const Self = @This();
fn drink(self: *Self) void {
...
}
}
Но, при всей употребительности, такой вариант применения — поверхностный. Мы могли с тем же успехом написать: fn drink(self: *Tea) void {...}
.
Такой вариант по-настоящему полезен в случаях, когда у нас есть анонимная структура:
fn beverage() type {
return struct {
full: bool = true,
const Self = @This();
fn drink(self: *Self) void {
self.full = false;
}
};
}
pub fn main() !void {
// beverage() возвращает тип, экземпляр которого создаётся при помощи {}
// "full" по умолчанию имеет значение "true", поэтому здесь нам не обязательно его указывать
var b = beverage(){};
std.debug.print("Full? {}\n", .{b.full});
b.drink();
std.debug.print("Full? {}\n", .{b.full});
}
Этот код выведет в консоль «true», а затем «false».
Этот пример надуманный: зачем же нам может понадобиться здесь анонимная структура? Не должна, но именно на таком подходе основана реализация дженериков в Zig. Для работы с дженериками нам понадобится код, похожий на вышеприведённый (плюс передача типа type
для нашей функции). Поэтому нам понадобится @This()
, чтобы ссылаться на анонимную структуру изнутри анонимной структуры.
Файлы — это структуры
В Zig файлы — это структуры.
Допустим, нам нужна структура Tea
. Мы могли бы создать файл под названием «tea.zig» и добавить следующий код:
// содержимое tea.zig
pub const Tea = struct{
full: bool = true,
const Self = @This();
pub fn drink(self: *Self) void {
self.full = false;
}
};
Тогда вызывающая сторона может использовать нашу структуру Tea
примерно так:
const tea = @import("tea.zig");
...
var t = tea.Tea{};
Либо с такими косметическими изменениями:
// если мы будем использовать эту структуру Tea только из tea.zig,
// то, может быть, предпочтительнее будет поступить так.
const Tea = @import("tea.zig").Tea;
...
var t = Tea{};
Поскольку файлы — это структуры, наш Tea
, фактически, вложен в неявно созданную структуру-файл. В качестве альтернативы полное содержимое tea.zig может быть таким:
full: bool = true,
const Self = @This();
pub fn drink(self: *Self) void {
self.full = false;
}
Что можно импортировать так:
const Tea = @import("tea.zig");
Выглядит странно, но, если вообразить, что содержимое обёрнуто в pub const default = struct { ... };
то смысл просматривается. При первой встрече с таким кодом можно всерьёз запутаться.
3 – Соглашения об именованиях
Вообще:
Функции записываются в верблюжьем регистре (camelCase)
Типы записываются в Паскаль-регистре (PascalCase)
Переменные записываются в змеином регистре (lowercase_with_underscores)
Основное исключение из этого правила — функции, возвращающие типы (чаще всего используются с дженериками). Они записываются в PascalCase.
Имена файлов, как правило, записываются в змеином регистре (lowercase_with_underscore). Но те файлы, которые предоставляют тип напрямую (как в нашем последнем чайном примере), следуют тому же соглашению об именовании, что и типы. Следовательно, файл должен был бы называться «Tea.zig».
Таких правил придерживаться легко, но они более цветистые, чем вы могли привыкнуть.
4 — .{...}
В коде Zig то и дело попадается .{...}
. Это анонимная структура. Следующий код компилируется и выводит в консоль "keemun"::
pub fn main() !void {
const tea = .{.type = "keemun"};
std.debug.print("{s}\n", .{tea.type});
}
На самом деле, в этом примере 2 анонимные структуры. Первая — та, которую мы присвоили переменной tea
. Другая — это второй параметр, который мы передали print
: т.e. {tea.type}
. Вторая версия — это особый тип анонимной структуры с неявными именами полей. Имена полей — «0», «1», «2», ... в Zig это называется «кортеж». Можно проверить неявные имена полей, обратившись к ним напрямую:
pub fn main() !void {
const tea = .{"keemun", 10};
std.debug.print("Type: {s}, Quality: {d}\n", .{tea.@"0", tea.@"1"});
}
Синтаксис @"0"
необходим, поскольку 0 и 1 не являются стандартными идентификаторами (т.е., они не начинаются с буквы) и, следовательно, должны заключаться в кавычки.
Также можно встретить синтаксис с .{...}
в тех случаях, когда структура может быть выведена. Как правило, такое происходит в функции init
некоторой структуры:
pub const Tea = struct {
full: bool,
const Self = @This();
fn init() Self {
// тип структуры выводится по возвращаемому типу функции
return .{
.full = true,
};
}
};
Также обратите внимание, какой здесь параметр функции:
var server = httpz.Server().init(allocator, .{});
Второй параметр httpz.Config
, и он выводим средствами Zig. В Zig требуется, чтобы каждое поле было инициализировано, но в httpz.Config
заданы значения по умолчанию для каждого поля, поэтому пустой инициализатор структуры вполне подойдёт. Также можно явно указать одно или более полей:
var server = httpz.Server().init(allocator, .{.port = 5040});
В Zig .{...}
словно сообщает компилятору: сделай, чтобы это поместилось.
5 — .field = значение
В вышеприведённом коде мы пользовались .full = true
и .port = 5040
. Именно так задаются поля, когда инициализируется структура. Не знаю, намеренно ли это было сделано, но, в принципе, согласуется с тем, как вообще устанавливаются поля.
Думаю, следующий пример демонстрирует, почему синтаксис .field = value
резонен:
var tea = Tea{.full = true};
// эй, посмотрите, ну ведь похоже!
tea.full = false;
6 — Приватные поля в структурах
Что касается полей структур — известно, что они всегда публичные. Структуры и функции по умолчанию приватные, и существует опция сделать их публичными. Но поля структур могут быть только публичными. Рекомендуется документировать допустимые/правильные варианты использования каждого поля.
Не хотелось бы пускаться в этом посте в лишние разглагольствования, но такая ситуация уже привела к вполне ожидаемым проблемам, а в мире 1.x их количество, скорее всего, только возрастёт.
7 — const *tmp
До версии Zig 0.10 первая строка этого кода:
const r = std.rand.DefaultPrng.init(0).random();
std.debug.print("{d}\n", .{r.uintAtMost(u16, 1000)});
была бы эквивалентна
var t = std.rand.DefaultPrng.init(0);
const r = t.random();
Но в 0.10 и выше первая из приведённых строк эквивалентна:
const t = std.rand.DefaultPrng.init(0);
const r = t.random();
Обратите внимание, что t
превратилась из var
в const
. Эта разница важна, так как для random()
требуется изменяемое значение. Иными словами, код в оригинальном виде более работать не будет. Вы получите ошибку, в которой будет сообщено, что программа рассчитывала на *rand.Xoshiro256
, а вместо этого нашла *const rand.Xoshiro256
. Чтобы этот код заработал, его оригинальный вариант нужно разделить и явно ввести временную переменную как var
:
var t = std.rand.DefaultPrng.init(0);
const r = t.random();
8 — comptime_int
В Zig есть мощная фича «comptime», позволяющая разработчикам делать многие вещи во время компиляции. Логично предположить, что выполнение во время компиляции применимо только с теми данными, которые известны во время компиляции. Для поддержки таких операций в Zig предусмотрены типы comptime_int
и comptime_float
. Рассмотрим следующий пример:
var x = 0;
while (true) {
if (someCondition()) break;
x += 2;
}
Этот код не скомпилируется. Тип x
выводится как comptime_int
, поскольку значение 0 известно во время компиляции. Проблема здесь заключается в том, что comptime_int
обязано быть const
. Конечно же, если изменить объявление на const x = 0
; то мы получим другую ошибку, так как в данном случае попытаемся приплюсовать 2 к const
.
Решение: явно определить x
как usize
(или другой целочисленный тип для времени выполнения, например, u64
):
var x: usize = 0;
9 — std.testing.expectEqual
Возможно, первый написанный вами тест приведёт к удивительной ошибке компиляции. Рассмотрим код:
fn add(a: i64, b: i64) i64 {
return a + b;
}
test "add" {
try std.testing.expectEqual(5, add(2, 3));
}
Если я покажу вам сигнатуру expectEqual
, можете ли вы объяснить, почему она не скомпилируется?
pub fn expectEqual(expected: anytype, actual: @TypeOf(expected)) !void
Может быть, это и сложно уловить, но «фактическое» значение принудительно приводится к тому же типу, что и «ожидаемое». Приведённый выше тест на «сложение» не скомпилируется, поскольку i64
невозможно принудительно привести к comptime_int
.
Есть простое решение — поменять параметры:
test "add" {
try std.testing.expectEqual(add(2, 3), 5);
}
И это работает, и многие так поступают. Основной недостаток такого подхода в том, что в сообщении об отказе перемешиваются ожидаемые и фактические значения.
Вот как правильно решается этот случай: ожидаемое значение приводится к фактическому типу, с использованием встроенного @as()
:
test "add" {
try std.testing.expectEqual(@as(i64, 5), add(2, 3));
}
Возвращаемое значение get
равно ?[]const u8
, а это опциональная (она же сводимая к нулю) строка. Но ожидаемое значение [верно] равно нулю, и ?[]const u8
невозможно принудительно привести к null
. Чтобы исправить это, необходимо принудительно привести null
к ?[]const u8
:
try std.testing.expectEqual(@as(?[]const u8, null), m.get("teg"));
10 — Затенение
В документации Zig постулировано, что «Идентификаторам никогда не разрешается «скрывать» другие идентификаторы, прибегая к одноимённости». Поэтому если в верхней части файла у вас есть const reader = @import("reader.zig");
, то в том же файле больше не может быть ничего под названием reader.
Приходится творчески подходить к выдумыванию новых переменных, так, чтобы они не затеняли уже имеющихся (что зачастую означает пользоваться всё более мудрёными именами).