Вивчаємо Zig Українською

У попередній частині ми створили простий динамічний масив під назвою 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 для типів параметрів ключа (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) стандартної бібліотеки.