У попередній частині ми створили простий динамічний масив під назвою IntList. Метою структури даних було зберігати динамічну кількість значень. Хоча використаний нами алгоритм працював би для будь-якого типу даних, наша реалізація була прив’язана до значень i64. Ласкаво просимо в узагальнені структури даних (generics), мета яких — абстрагувати алгоритми та структури даних від конкретних типів.
Багато мов реалізують узагальнені структури даних за допомогою спеціального синтаксису та специфічних правил. У Zig же узагальнені структури даних — не стільки окрема особливість, скільки прояв того, на що здатний компілятор. Зокрема, узагальнені структури даних використовують метапрограмування під час компіляції — comptime.
Ми почнемо з розгляду простенького прикладу, щоб зорієнтуватися:
learning.zig
const std = @import("std");
pub fn main() !void {
var arr: IntArray(3) = undefined;
arr[0] = 1;
arr[1] = 10;
arr[2] = 100;
std.debug.print("{any}\n", .{arr});
}
fn IntArray(comptime length: usize) type {
return [length]i64;
}
Наведене вище виводить { 1, 10, 100 }. Цікава частина полягає в тому, що у нас є функція, яка повертає type (зверніть увагу, що тому функція написана у PascalCase). Не будь-який тип, а тип, заснований на параметрі функції. Цей код працює лише тому, що ми оголосили length як comptime. Тобто ми вимагаємо від усіх, хто викликає IntArray, передавати відомий під час компіляції параметр length. Це необхідно, оскільки наша функція повертає type, а типи завжди мають бути відомі під час компіляції.
Функція може повертати будь-який тип, а не лише примітиви та масиви. Наприклад, з невеликою зміною ми можемо повернути структуру:
learning.zig
const std = @import("std");
pub fn main() !void {
var arr: IntArray(3) = undefined;
arr.items[0] = 1;
arr.items[1] = 10;
arr.items[2] = 100;
std.debug.print("{any}\n", .{arr.items});
}
fn IntArray(comptime length: usize) type {
return struct {
items: [length]i64,
};
}
Це може здатися дивним, але тип arr насправді є IntArray(3). Це такий же тип, як і будь-який інший тип, а arr — це значення, як і будь-яке інше значення. Якби ми викликали IntArray(7), це був би інший тип. Можливо, ми зможемо зробити речі акуратнішими:
learning.zig
const std = @import("std");
pub fn main() !void {
var arr = IntArray(3).init();
arr.items[0] = 1;
arr.items[1] = 10;
arr.items[2] = 100;
std.debug.print("{any}\n", .{arr.items});
}
fn IntArray(comptime length: usize) type {
return struct {
items: [length]i64,
fn init() IntArray(length) {
return .{
.items = undefined,
};
}
};
}
На перший погляд це може виглядати не акуратніше. Але крім того, що наша структура безіменна та вкладена у функцію, вона виглядає як будь-яка інша структура, що ми бачили досі. Тобто в неї є поля і є методи. Ви знаєте, як кажуть, якщо це схоже на качку…. Ну, це виглядає, плаває і крякає як звичайна структура, тому що так і є.
Ми вибрали цей шлях, щоб звикнути до функції, яка повертає тип, і до відповідного синтаксису. Щоб дійти до типовішої структури, нам потрібно зробити останню зміну: наша функція має приймати type. Насправді це невелика зміна, але type може здаватися абстрактнішим, ніж usize, тому ми підходимо до цього обережно. Зробімо ж стрибок і змінімо наш попередній IntList так, щоб він працював із будь-яким типом. Почнемо зі скелета:
fn List(comptime T: type) type {
return struct {
pos: usize,
items: []T,
allocator: Allocator,
fn init(allocator: Allocator) !List(T) {
return .{
.pos = 0,
.allocator = allocator,
.items = try allocator.alloc(T, 4),
};
}
};
}
Наведена вище struct майже ідентична нашому IntList, за винятком i64, заміненого на T. Це T може здатися особливим, але це лише назва змінної. Ми могли б назвати її item_type. Однак, дотримуючись угоди про іменування Zig, змінні типу type мають писатися в PascalCase.
Добре це чи погано, використання однієї літери для представлення параметра типу набагато старше, ніж сам Zig.
Tвикористовується за замовчуванням у більшості мов програмування, але ви побачите залежні від контексту варіації, такі як хеш-таблиці з використаннямKіVдля типів параметрів ключа (Key) та значення (Value).
Якщо ви не впевнені щодо нашого прикладу, розгляньте два місця, де ми використовуємо T: items: []T і allocator.alloc(T, 4). Якщо ми хочемо використовувати цей загальний тип, ми створимо екземпляр за допомогою:
var list = try List(u32).init(allocator);
Під час компіляції коду компілятор створює новий тип, знаходячи кожен T і замінюючи його на u32. Якщо ми знову використаємо List(u32), компілятор повторно використає тип, який він створив раніше. Якщо ми вкажемо інше значення для T, наприклад List(bool) або List(User), будуть створені нові типи.
Щоб завершити наш узагальнений List, ми можемо буквально скопіювати та вставити решту коду IntList і замінити i64 на T. Ось повний робочий приклад:
learning.zig
const std = @import("std");
const Allocator = std.mem.Allocator;
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();
var list = try List(u32).init(allocator);
defer list.deinit();
for (0..10) |i| {
try list.add(@intCast(i));
}
std.debug.print("{any}\n", .{list.items[0..list.pos]});
}
fn List(comptime T: type) type {
return struct {
pos: usize,
items: []T,
allocator: Allocator,
fn init(allocator: Allocator) !List(T) {
return .{
.pos = 0,
.allocator = allocator,
.items = try allocator.alloc(T, 4),
};
}
fn deinit(self: List(T)) void {
self.allocator.free(self.items);
}
fn add(self: *List(T), value: T) !void {
const pos = self.pos;
const len = self.items.len;
if (pos == len) {
// у нас закінчилось місце
// створюємо новий, вдвічі більший зріз
var larger = try self.allocator.alloc(T, len * 2);
// копіюємо в нього всі елементи з попереднього
@memcpy(larger[0..len], self.items);
self.allocator.free(self.items);
self.items = larger;
}
self.items[pos] = value;
self.pos = pos + 1;
}
};
}
Наша функція init повертає List(T), а наші функції deinit і add приймають List(T) і *List(T). У нашому простому класі це не страшно, але для великих структур даних написання повної узагальненої назви може стати трохи виснажливим, особливо якщо у нас є кілька параметрів типу (наприклад, хеш-таблиця, яка приймає окремий type для свого ключа та значення). Вбудована функція @This() повертає внутрішній type, звідки її було викликано. Найімовірніше, наш List(T) буде записаний так:
fn List(comptime T: type) type {
return struct {
pos: usize,
items: []T,
allocator: Allocator,
// додано
const Self = @This();
fn init(allocator: Allocator) !Self {
// ... той самий код
}
fn deinit(self: Self) void {
// .. той самий код
}
fn add(self: *Self, value: T) !void {
// .. той самий код
}
};
}
Self — це не спеціальне ім’я, а просто змінна, і вона використовує PascalCase, тому що її значенням є type. Ми можемо використовувати Self там, де раніше використовували List(T).
Ми могли б створити складніші приклади з кількома параметрами типу та досконалішими алгоритмами. Але, зрештою, основний код узагальненої структури нічим не відрізнятиметься від простих прикладів вище. У наступній частині ми знову торкнемося узагальнених структур, коли поглянемо на ArrayList(T) і StringHashMap(V) зі стандартної бібліотеки.