У попередній частині ми створили простий динамічний масив під назвою 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
, а types
завжди мають бути відомі під час компіляції.
Функція може повертати будь-який тип, а не лише примітиви та масиви. Наприклад, з невеликою зміною ми можемо повернути структуру:
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
для типів параметрів ключа (K
ey) та значення (V
alue).
Якщо ви не впевнені щодо нашого прикладу, розгляньте два місця, де ми використовуємо 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)
стандартної бібліотеки.