Кортежи
В прошлой главе мы рассмотрели основной тип используемых структур в Zig - структуры с именованными полями. Однако в Zig довольно часто можно встретить еще два типа структур - структуры в виде кортежей и пустые структуры.
Кортежи
Кортежи - это структуры, которые содержат только значения, но не имеют именованных полей. В Zig нет встроенного типа “кортеж”, как в Python или Rust, но их можно легко реализовать с помощью анонимных структур. Кортежи довольно часто используются для передачи или возвращения нескольких значений из функций.
Для того чтобы создать кортеж в языке Zig вам нужно создать анонимную структуру, которая содержит только значения. Например:
const std = @import("std");
pub fn main() !void {
const tuple: struct { u8, bool } = .{ 42, true };
std.debug.print("{any} {}\n", .{ tuple, @TypeOf(tuple) });
}
Этот код выведет следующее:
{ 42, true } struct { u8, bool }
Как мы видим из вывода программы тип нашей структуры определяется как struct { u8, bool }
, т.е. это анонимная структура с двумя полями: первое поле типа u8
и второе поле типа bool
.
Доступ к элементам кортежа
Для доступа к элементам кортежа в Zig есть два способа. Первый и самый простой способ - это использовать оператор []
, который мы использовали при работе с массивами или срезами:
const std = @import("std");
pub fn main() !void {
const tuple: struct { u8, bool } = .{ 42, true };
std.debug.print("Number {}\n", .{tuple[0]});
std.debug.print("Bool {}\n", .{tuple[1]});
std.debug.print("Tuple len {}\n", .{tuple.len});
}
Все поля внутри кортежа доступны по индексам, так же, как в массивах. Кроме того, кортежи в Zig, как и массивы, имеют свойство len, которое возвращает количество элементов. В целом, кортежи можно рассматривать как массивы, в которых элементы могут иметь разные типы.
Второй способ доступа к элементам кортежа - с помощью комбинации операторов .@
. В этом случае мы обращаемся к элементу кортежа используя не числовое индексирование, а используя имя числового индекса в кортеже:
const std = @import("std");
pub fn main() !void {
const tuple: struct { u8, bool } = .{ 42, true };
std.debug.print("Number {}\n", .{tuple.@"0"});
std.debug.print("Bool {}\n", .{tuple.@"1"});
}
Конечно, второй вариант выглядит менее естественно, и обычно для обращения к полям кортежа используют числовые индексы.
Для доступа к элементам кортежа мы также можем использовать цикл inline for
или деструктуризацию кортежа. Использовать обычный цикл for
с кортежами нельзя, так как для доступа к кортежам используется механизм рефлексии на этапе компиляции:
const std = @import("std");
pub fn main() !void {
const tuple: struct { u8, bool } = .{ 42, true };
inline for (tuple) |item| {
std.debug.print("Item {}\n", .{item});
}
const number, const boolean = tuple;
std.debug.print("Number {}\n", .{number});
std.debug.print("Bool {}\n", .{boolean});
}
Этот код выведет:
Item 42
Item true
Number 42
Bool true
Обращение к элементам через деструктуризацию наверно самый популярный способ использование кортежей и чаще всего вы будете встречать именно его в коде.
Если у вас есть указатель на кортеж, то как и в случае массивов вы можете использовать стандартный метод доступа к элементам кортежа через []
используя указатель:
const std = @import("std");
pub fn main() !void {
const tuple: struct { u8, bool } = .{ 42, true };
const ptr = &tuple;
std.debug.print("Number {}\n", .{ptr[0]});
std.debug.print("Bool {}\n", .{ptr[1]});
}
Выведет:
Number 42
Bool true
Объединение кортежей
Так как кортежи похожи на массивы мы также как и массивы можем объединять их с помощью оператора ++
:
const std = @import("std");
pub fn main() !void {
const tuple1: struct { u8, bool } = .{ 42, true };
const tuple2: struct { u8, bool } = .{ 24, false };
const combined = tuple1 ++ tuple2;
std.debug.print("Combined tuple {any}\n", .{combined});
}
Выведет:
Combined tuple { 42, true, 24, false }
Использование кортежей в функциях
Чаще всего кортежи используются для передачи или возвращения нескольких значений из функций. Например, если у вас есть функция, которая возвращает два значения, кортежи позволяют удобно работать с результатом.
Вы можете либо деструктурировать кортеж, присваивая его значения отдельным переменным, либо использовать его как единое целое, если все элементы логически связаны между собой:
const std = @import("std");
pub fn main() !void {
const point = getPoint();
std.debug.print("X {}\n", .{point[0]});
std.debug.print("Y {}\n", .{point[1]});
}
fn getPoint() struct { u8, u8 } {
return .{ 42, 33 };
}
Выведет:
Number 42
Bool true
Использование кортежей при передаче значений в функцию может быть также полезно, если параметры кортежа имеют одну общую логическую связь. Например, если функция принимает координаты точки в виде кортежа (x, y)
, то использование кортежей позволяет избежать необходимости передавать два отдельных аргумента:
const std = @import("std");
pub fn main() !void {
const point = .{42, 33};
moveToPoint(point);
}
fn moveToPoint(point: struct { u8, u8 }) void {
...
}
Еще один популярный способ использование кортежей при передачи значений в функцию это имитация возможности передачи произвольного количества аргументов в функцию:
const std = @import("std");
pub fn main() !void {
print(.{ 42, true, 33 });
}
fn print(values: anytype) void {
const info = @typeInfo(@TypeOf(values));
if (info != .@"struct") {
std.debug.print("Not a tuple\n", .{});
return;
}
if (!info.@"struct".is_tuple) {
std.debug.print("Not a tuple\n", .{});
return;
}
inline for (values) |value| {
std.debug.print("{}, ", .{value});
}
std.debug.print("\n", .{});
}
В данном примере мы впервые видим тип переменной anytype
- это тип, который может быть любым типом данных. По сути это тоже самое что и тип any
в языке Go, или тип Any
в Rust. При использовании этого типа стирается вся информация о типе при передаче переменной в функцию и нам нужно использовать функции рефлексии чтобы снова восстановить знания о типе переданной переменной, что мы и делаем. Используя функции @TypeOf
и @typeInfo
мы достаем информацию о типе, а затем проверяем, что нам действительно передали кортеж. После всех необходимых проверок мы используем inline for
для перебора значений кортежа и вывода их на экран.
Пустые структуры
Пустая структура - это структура, которая не содержит полей. В Zig это можно создать с помощью ключевого слова struct
и пустых фигурных скобок {}
. Например:
const EmptyStruct = struct {};
Может показаться, что это довольно бесполезная конструкция, но на самом деле у нее есть несколько практических применений. Один из первых вариантов применения пустой структуры это использования ее в качестве значений в HashMap структуре. Мы еще не рассматривали HashMap структуру данных и детально рассмотрим ее позднее, а сейчас давайте рассмотрим пример использования пустой структуры в качестве значений в HashMap.
Предположим, нам нужно хранить где-то список пользователей, которые приходили к нам на сайт, и быстро проверять, был ли уже пользователь на сайте. Когда я говорю быстро, я имею ввиду за константное время (O(1)), не зависимо от того, сколько пользователей у нас на сайте. Для таких случаев удобно использовать структуру данных HashMap
, где ключем будет id нашего пользователя, а значением - некий признак того, что пользователь уже был на сайте. Если решать эту задачу в лоб, первое, что приходит в голову — хранить в HashMap булевый флаг true в качестве значения:
const std = @import("std");
pub fn main() !void {
const gpa = std.heap.page_allocator;
var set = std.AutoHashMap(i32, bool).init(gpa);
defer set.deinit();
try set.put(42, true);
try set.put(99, true);
if (set.contains(42)) {
std.debug.print("42 найдено!\n", .{});
}
}
Однако такое использование, с булевой переменной, тратит целый байт памяти, что иногда бывает не очень эффективно с точки зрения использования памяти, если таких пользователей у вас тысячи или сотни тысяч. В этом случае можно использовать пустую структуру {}
в качестве значения, чтобы сэкономить память:
const std = @import("std");
pub fn main() !void {
const gpa = std.heap.page_allocator;
var set = std.AutoHashMap(i32, struct {}).init(gpa);
defer set.deinit();
try set.put(42, .{});
try set.put(99, .{});
if (set.contains(42)) {
std.debug.print("42 найдено!\n", .{});
}
std.debug.print("Size of empty struct: {}\n", .{@sizeOf(struct {})});
}
В этом случаем мы храним в значениях HashMap пустые структуры, которые как мы видим занимаю 0 байт в памяти, что более эффективно расходует нашу память.
Второй вариант - использование пустой структуры как маркера типа, когда нам важно различать тип, но не хочется тратить память на хранения дополнительной информации. Давайте рассмотрим пример:
const std = @import("std");
const ConsoleLogger = struct {}; // Маркер для логирования в консоль
const FileLogger = struct {}; // Маркер для логирования в файл
fn log(comptime LoggerType: type) void {
if (LoggerType == ConsoleLogger) {
std.debug.print("Лог в консоль\n", .{});
} else if (LoggerType == FileLogger) {
std.debug.print("Лог в файл\n", .{});
}
}
pub fn main() void {
log(ConsoleLogger);
log(FileLogger);
}
В этом примере мы используем пустую структуру {}
в качестве маркера типа, что позволяет компилятору выбирать нужную стратегию, но при этом не несет никаких накладных расходов по памяти.
И еще один полезный пример использования пустой структуры как маркера типа - это использование их в качестве маркеров для различных состояний в системе на базе union-типов. Например, мы можем использовать пустые структуры для маркеров состояний в системе, таких как “ожидание”, “работа”, “завершение” и т.д.
const std = @import("std");
const State = union(enum) {
idle: struct {}, // Ожидание (нет данных)
working: i32, // Идет процесс (с прогрессом)
err: []const u8, // Ошибка (с сообщением)
};
pub fn main() void {
var s: State = State{ .idle = .{} }; // Состояние ожидания
s = State{ .working = 50 }; // Рабочее состояние с прогрессом
if (s == .idle) {
std.debug.print("Состояние: ожидание\n", .{});
}
}
В данном примере состояние idle присутсвует в перечислении, но при этом не занимает память.