# Глава VII. Обобщённые структуры данных

В предыдущей главе мы сконструировали минималистичный динамический массив, который назывался IntList. Применение этой структуры состояло в том, чтобы хранить переменное количество целых чисел. И, хотя сами алгоритмы, которые мы использовали, сработали бы для любого типа данных, наша реализация была жёстко привязана к типу i64. Добро пожаловать в обобщённые структуры данных (generics), которые нужны для того, чтобы абстрагировать алгоритмы от конкретных типов данных.

В многих языках такого рода структуры реализованы при помощи специального синтаксиса и особых правил. В Zig, напротив, обобщённые структуры данных не столько специфическая особенность, сколько демонстрация того, что умеет делать компилятор. Конкретно, работа с обобщёными структурами данных предполагает умелое использование метапрограммирования при помощи comptime.

Начнём с (немного дурацкого) примера, только ради того, чтобы хоть немного освоиться с этим пока загадочным comptime:

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 }, что не особо интересно. А интересно тут то, что функция IntArray возвращает type, то есть тип. И поэтому, кстати, имя функции написано в PascalCase, а не в camelCase. Далее, эта функция возвращает не какой-то тип "вообще", этот тип основан на параметре, переданном в функцию, который помечен словом comptime. Этот код вообще только благодаря этому и работает. Такая запись (comptime length: usize) обязывает каждого, кто вызывает эту функцию передать значение, известное во время компиляции. Это необходимо, потому что наша функция возвращает тип, а типы должны быть известны во время компиляции (напомним, что Zig это язык со статической типизацией).

Это может показаться странным, но тип переменной arr тут это реально IntArray(3). Это такой же тип, как и любой другой.

Функция может возвратить любой тип, не только скалярные типы и массивы. Например, она может возвратить структуру:

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,
    };
}

Можно сделать ещё изящнее:

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,
            };
        }
    };
}

На первый взгляд, тут ничего "более изящного" нет. Однако, помимо того, что тут структура вложена в функцию и не имеет имени, она выглядит как любая другая структура, которые мы уже видели ранее, то есть у неё есть поля, есть методы. Ну, вы знаете, как говорят - если что-то выглядит как утка... тут то же самое, это что-то выглядит, плавает и крякает как обычная структура, потому что она и есть структура.

Мы идём таким извилистым путём ради того, чтобы получше освоиться с функциями, которые возвращают тип и соответствующим синтаксисом. Чтобы подобраться к какой-то более типичной обобщённой структуре данных, нам нужно сделать ещё одно небольшое изменение: помимо того, что наша функция возвращает тип, она должна также и принимать его в качестве параметра:

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),
            };
        }
    }
};

Наша новая структура почти идентична структуре IntList за исключением того, что i64 заменено на T. Именно такое название (T) может показаться чем-то особенным, но это просто имя аргумента функции и ничего более. Мы могли бы назвать её item_type, ничего бы по сути не изменилось. Но, если следовать соглашениям об именовании, то лучше было бы ItemType. Однако, традиционно для переменных с типом type используются однобуквенные названия, например K и V для типа ключа и типа значения применительно к, скажем, хэш-таблице.

Если вам ещё не до конца всё понятно, поглядите ещё раз на те места, где фигурирует это наше T: items: []T и allocator.alloc(T, 4). Когда мы хотим использовать наш обобщённый тип (для создания конкретизированного), мы пишем

var list = try List(u32).init(allocator);

Когда такой код компилируется, компилятор создаёт новый тип, находя каждое вхождение T и попросту заменяя его на u32. Если мы используем List(u32) ещё раз, компилятор использует тот тип, что он уже ранее создал, а если мы напишем какое-то другое значение для T, скажем, List(bool) или List(User), тогда будут созданы новые типы.

Чтобы завершить реализацию нашего обобщённого списка, мы можем дословно продублировать код из IntList, заменяя i64 на T. Вот полный рабочий пример:

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), функция add принимает аргумент *List(T), функция deinit принимает аргумент List(T). Для нашего простого "класса" писать полные имена типов всякий раз ещё более-менее терпимо, но для более сложных структур это становится слегка утомительным, особенно если у нас есть несколько параметров типа type (например, хэш-таблица, имеющая различные типы для ключа и значения). Здесь нам поможет встроенная функция @This(), которая возращает тип той сущности, в которой она вызывается. Поэтому, реальная реализация List, скорее всего, была бы сделана примерно так:

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 {
            // ... как и было
        }
    };
}

Как и T, Self не является каким-то особым словом, это просто имя (немутабельной) переменной. И так же, как и для T, для неё используется PascalCase, потому что это значение типа. Таким образом, вместо List(T) мы можем везде использовать Self(ну или какое-то другое наименование, которое нам больше нравится, например, This).

Мы могли бы сконструировать более сложные примеры, с несколькими параметрами-типами и с более сложными алгоритмами. Но, по большому счёту, базовая логика описания обобщённых структур данных никак не отличается от той, что мы видели в рассмотренном простом примере.