# Глава 8. Организация данных

Данные — это сердце любой программы. То, как вы их формируете и управляете своими данными, может сделать ваш код либо ясным, эффективным и удобным в сопровождении, либо наоборот. В этой главе рассматриваются фундаментальные техники, которые Zig предлагает для организации данных — будь то группировка связанных значений, определение точных структур памяти или создание гибких представлений ваших структур данных.

В этой главе мы рассмотрим следующие темы:

  • Массивы: коллекции фиксированного размера
  • Срезы: динамические представления массивов
  • Структуры: ваши собственные пользовательские данные (потому что вы особенные)

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

# Технические требования

Весь код, показанный в этой главе, можно найти в каталоге Chapter08 нашего репозитория Git: https://github.com/PacktPublishing/Learning-Zig/tree/main/Chapter08.

# Массивы: коллекции фиксированного размера

Пора погрузиться в массивы — простейшую структуру данных. Это просто старая добрая коллекция фиксированного размера, аккуратно упакованная, как множество кошек в спортивную сумку. Аналогия может показаться неудобной, но поверьте: как только вы поймёте массивы в Zig, вы будете мурлыкать от удовольствия (или шипеть от зависти — зависит от характера).

В Zig массивы просты и предсказуемы: у них фиксированный размер, известный на этапе компиляции, и вы не можете просто так взять и изменить их размер «на лету», потому что вам так захотелось. Если это для вас слишком ограничивает — что ж, возможно, массивы не то, что вы искали. Но некоторые из нас ценят простоту. Массивы хранят последовательность элементов одного типа, расположенных в непрерывном блоке памяти. Ничего вычурного — просто дисциплинированный ряд «солдат» данных, готовых к службе.

# Несколько хороших массивов

Объявление массивов в Zig — это не ракетостроение. Вы можете явно записать их, использовать вывод типов или даже вычислять значения на этапе компиляции. Например, вот «Привет» из мира массивов — буквально:

const message = [_]u8{ 'h', 'e', 'l', 'l', 'o' };

Синтаксис [_]u8 говорит Zig: «Эй, Zig, сам разберись с длиной этого массива». Он считает элементы, задаёт длину — и вуаля! У вас есть массив. Если вы любите всё контролировать, можете явно указать длину:

const alt_message: [5]u8 = .{ 'h', 'e', 'l', 'l', 'o' };

Если это вас не впечатляет, можно убедиться, что они одинаковы:

comptime {
    const std = @import("std");
    const mem = std.mem;
    const assert = std.debug.assert;
    assert(mem.eql(u8, &message, &alt_message));
}

Помните: если assert не проходит на этапе компиляции, ваш код просто не соберётся. Это как строгий, но заботливый наставник, который не даст вам опозориться.

Чтобы увидеть этот и следующие сниппеты в действии, мы используем zig test, как в следующей команде:

zig test array1.zig

Обратите внимание: здесь мы используем команду zig test исключительно для принудительной компиляции — в этом фрагменте нет тестов для запуска, что видно по выводу:

All 0 tests passed.

# Потому что размер имеет значение

Как только у вас есть массив, вы можете узнать его длину с помощью .len. Это удобно, когда подводит память:

comptime {
    assert(message.len == 5);
}

Никаких сюрпризов. Вы положили пять символов — получаете пять символов на выходе. Почти как будто массивы предсказуемы. Это мило.

# Итерация по массивам

Возможно, вы захотите сделать что-то дикое — например, просуммировать значения. Это можно сделать с помощью простого цикла for:

test "iterate over an array" {
    const message = "hello";
    var sum: usize = 0;

    for (message) |byte| {
        sum += byte;
    }

    try expect(sum == 'h' + 'e' + 'l' * 2 + 'o');
}

Да, вы можете проходить по массивам с помощью синтаксиса, который обманчиво приятен.

# Изменение массивов

Массивы не являются неизменяемыми. Не стесняйтесь «писать» по ним (если это переменные массивы, а не константные):

test "modify an array" {
    var some_integers: [100]i32 = undefined;
    for (&some_integers, 0..) |*item, i| {
        item.* = @intCast(i);
    }

    try expect(some_integers[10] == 10);
    try expect(some_integers[99] == 99);
}

Здесь undefined просто означает: «Zig, не утруждайся инициализировать это сейчас, я сделаю это сам». Если вы уверены, что заполните его правильно перед использованием (а вам лучше быть уверенным), то это изящный приём.

# Магия массивов на этапе компиляции

Zig обожает вычисления на этапе компиляции. Конкатенация массивов на этапе компиляции проста как никогда:

const part_one = [_]i32{ 1, 2, 3, 4 };
const part_two = [_]i32{ 5, 6, 7, 8 };
const all_of_it = part_one ++ part_two;
comptime {
    assert(mem.eql(i32, &all_of_it, &[_]i32{ 1, 2, 3, 4, 5, 6, 7, 8 }));
}

Это конкатенация на этапе компиляции. Даже не думайте пытаться сделать это динамически во время выполнения. Массивы — не ваш динамичный друг, для этого есть другие структуры. Но на этапе компиляции вы можете играть в Бога.

И помните, поскольку строки — это массивы, их конкатенация столь же проста:

const hello = "hello";
const world = "world";
const hello_world = hello ++ " " ++ world;
comptime {
    assert(mem.eql(u8, hello_world, "hello world"));
}

Смотрите, никакой лишней работы во время выполнения — только аккуратные гарантии этапа компиляции. Если вы чувствуете себя особенно авантюрно, ** можно использовать для повторения:

const pattern = "ab" ** 3;
comptime {
    assert(mem.eql(u8, pattern, "ababab"));
}

Потому что кому не нужен быстрый способ повторять шаблоны на этапе компиляции? (Не отвечайте.)

# Инициализация массивов нулями

Если вы хотите начать с места спокойствия, сделайте так:

const all_zero = [_]u16{0} ** 10;
comptime {
    assert(all_zero.len == 10);
    assert(all_zero[5] == 0);
}

Теперь у вас есть красивый массив, заполненный нулями. Это как тёплая ванна с пузырьками для ваших данных.

# Многомерные массивы: как массивы, только больше

Если одномерные массивы — это воспитанные кошки в сумке, то многомерные массивы — это стопки сумок внутри больших сумок: организованы, но визуально сложнее. Просто вкладывайте массивы вот так:

const mat4x4 = [4][4]f32{
    [_]f32{ 1.0, 0.0, 0.0, 0.0 },
    [_]f32{ 0.0, 1.0, 0.0, 1.0 },
    [_]f32{ 0.0, 0.0, 1.0, 0.0 },
    [_]f32{ 0.0, 0.0, 0.0, 1.0 },
};

Индексируйте их с помощью mat4x4[row][column] и обращайтесь с ними как с массивами массивов. Zig не волнует, что вы пытаетесь впечатлить его несколькими измерениями — ему вполне комфортно работать с такими вложенными структурами данных.

# Массивы с терминальным символом: потому что иногда нужен флаг

Массив с терминальным символом помещает специальное значение в конец массива, указывая границу. В Zig синтаксис [N:x]T означает «Выделить N + 1 элементов типа T, последний из которых равен x».

Рассмотрим строки с нулевым окончанием или другие данные, которым нужна известная точка остановки:

const array = [_:0]u8{ 1, 2, 3, 4 };
// Расположение в памяти: [1, 2, 3, 4, 0]
//                         ^данные     ^терминальный символ

Думайте о массивах с терминальным символом как о строках в стиле C с сеткой безопасности. Терминальный символ (обычно 0) действует как знак СТОП в конце ваших данных. В [_:0]u8{1, 2, 3, 4} вы получаете всего 5 ячеек памяти: [1, 2, 3, 4, 0], но .len по-прежнему сообщает о длине данных — то есть о четырёх.

Проверьте это:

test "0-terminated sentinel array" {
    const array = [_:0]u8{ 1, 2, 3, 4 };

    try expect(@TypeOf(array) == [4:0]u8);
    try expect(array.len == 4);
    try expect(array[4] == 0);
}

Терминальный элемент в array[4] равен нулю, как и обещано. Этот шаблон особенно полезен при работе с C API или любыми старыми подходами, которые полагаются на завершение специальным значением вместо передачи информации о длине. Только не забывайте: терминальный символ не магическим образом исправляет ошибки «на единицу» в вашем коде. С этим придётся справляться самостоятельно.

Хорошо! Давайте подытожим перед тем как двигаться дальше:

  • Массивы в Zig — это коллекции фиксированного размера элементов одного типа, известные на этапе компиляции.
  • Их легко объявлять, инициализировать и итерировать.
  • Строки — это просто массивы байтов — ничего особенного.
  • Конкатенация и инициализация на этапе компиляции позволяют делать забавные вещи с массивами ещё до запуска программы.
  • Многомерные массивы дают более сложные формы без потери рассудка.
  • Массивы с терминальным символом помогают элегантно работать со старыми C-шными подходами и условиями остановки.

Теперь, когда вы запихнули всех этих кошек в сумку, научились кормить их на этапе компиляции, нарезать их (фигурально!) и даже завершать их терминальным символом (не волнуйтесь, это просто ноль), вы на пути к тому чтобы стать мастером массивов. Если вам кажется это слишком ограничивающим — не волнуйтесь. Мы переходим к срезам — этим динамичным альтернативам для «заглядывания», которые могут взорвать ваш разум.

Или хотя бы взъерошить ваши усы.

# Срезы: динамические представления массивов

Если массивы — это набор данных, запертых в сейфе с комбинацией, установленной на этапе компиляции, то срезы — это гибкое окно, которое можно перемещать, чтобы видеть часть этих данных во время выполнения. Представьте массивы как чопорных аристократов, которые с рождения знают точный размер своей империи, а срезы — как ловких пройдох, которые могут проскользнуть по коридорам замка и выбрать, какие комнаты посмотреть в любой момент. У обоих есть своё место, но срезы часто дают свободу и гибкость времени выполнения там, где её нет у массивов.

Давайте разберёмся шаг за шагом с кодом без повторения «hello» и случайных целых чисел. Начнём со сценария с массивом и затем создадим из него срезы, постепенно усложняя задачу так, чтобы к концу вы кивали с пониманием и попивали кофе с самодовольством.

# Шаг 1. Массивы и их королевское упрямство

Сначала начнём с благородного массива. Допустим, мы пишем небольшую игру, где есть известное количество мистических камней, каждый со своим уровнем силы. После объявления мы не можем просто изменить размер этого массива — он фиксирован, как предопределённая судьба (или дедлайны вашего менеджера).

Допустим такой тестовый случай:

test "arrays and basic slicing" {
    // У нас есть 6 мистических камней с фиксированными уровнями силы, известными на этапе компиляции.
    const stone_powers = [_]u16{ 42, 17, 93, 58, 11, 99 };

    // Длина массива является частью его типа. Силы камней высечены в камне (игра слов).
    const total_stones = stone_powers.len;
    try std.testing.expect(total_stones == 6);

    // Доступ по индексу: массивы не выполняют проверки границ во время выполнения (по умолчанию),
    // но если вы тестируете в безопасном режиме, Zig всё равно паникует при выходе за границы.
    try std.testing.expect(stone_powers[0] == 42);
    try std.testing.expect(stone_powers[5] == 99);
}

Итак, у нас есть фиксированный массив. Это хорошо, но не очень гибко. Что если во время выполнения мы решим, что нам нужны только средние камни или хотим дать определённому игроку «представление» только о части этих камней?

# Шаг 2. Введение срезов (гибкость времени выполнения)

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

Давайте исследуем эту идею в следующем фрагменте:

test "creating slices from arrays 2" {
    var stone_powers = [_]u16{ 42, 17, 93, 58, 11, 99 };
    var known_at_runtime_zero: usize = 0;
    _ = &known_at_runtime_zero;

    const middle_stones = stone_powers[known_at_runtime_zero..stone_powers.len];

    try std.testing.expect(@TypeOf(middle_stones) == []u16);
    try std.testing.expect(middle_stones.len == 6);
    try std.testing.expect(middle_stones[0] == 42);
    try std.testing.expect(middle_stones[1] == 17);
}

Растерялись? Я вас прикрою. Наша тестовая функция создаёт срез из массива во время выполнения.

Изначально мы определяем stone_powers как массив типа [_]u16, то есть фиксированный размер беззнаковых целых по два байта.

Массив инициализируется значениями {42, 17, 93, 58, 11, 99}. Давайте поговорим о сценарии, который мы моделируем.

Игрок входит в подземелье и может видеть только подмножество («среднюю половину») этих камней во время выполнения. Здесь мы определяем переменную known_at_runtime_zero и присваиваем ей ноль. Эта переменная будет представлять индекс, известный во время выполнения. Даже если сейчас он равен нулю, в более реалистичном сценарии это значение может определяться во время выполнения программы.

Строка _ = &known_at_runtime_zero; по сути является «трюком компилятора», который заставляет переменную считаться неизвестной во время компиляции — даже если мы видим, что она равна нулю. Это похоже на помещение переменной в чёрный ящик: беря её адрес и отбрасывая результат, мы говорим компилятору «притворись, что ты не знаешь этого значения на этапе компиляции», что гарантирует: когда мы срезаем массив с использованием этой переменной, компилятор создаёт настоящий срез времени выполнения []u16, а не оптимизирует его до типа массива известного на этапе компиляции [6]u16.

Затем мы создаём срез из stone_powers с использованием этого индекса времени выполнения. Срез stone_powers[known_at_runtime_zero..stone_powers.len] фактически даёт нам срез, покрывающий весь массив в этом примере (с индекса от нуля до длины минус один).

Поскольку начальный и конечный индексы среза могут быть известны во время выполнения, это приводит к созданию среза неизвестного размера времени выполнения, который представляется как []u16. Мы подтверждаем, что тип middle_stones действительно является срезом []u16, а не массивом известного размера этапа компиляции.

Затем мы проверяем длину и содержимое среза, чтобы убедиться, что они соответствуют ожидаемым значениям. Если этот тест проходит — значит создание среза из массива во время выполнения сработало как задумано и был создан правильный срез.

Ещё один хороший аспект срезов заключается в том, что они защищают вас от самих себя. Давайте рассмотрим ещё один тестовый случай:

test "slices protect you from yourself" {
    var stone_powers = [_]u16{42,17,93,58,11,99};
    const subset = stone_powers[1..3];
    try std.testing.expect(subset.len ==2);
    // Попытка subset[99]=100; здесь вызвала бы панику времени выполнения из-за выхода за границы.
}

Длинное объяснение впереди. Готовы?

Эта новая тестовая функция демонстрирует: срезы (в отличие от массивов известных на этапе компиляции) обеспечивают проверку границ во время выполнения для предотвращения доступа за пределы массива. Мы начинаем с массива stone_powers, содержащего шесть элементов. Затем мы создаём срез subset, который представляет собой меньшее представление массива: stone_powers[1..3]. Этот срез представляет часть массива — а именно элементы с индексами один и два (всего два элемента).

Далее мы проверяем: этот срез имеет ожидаемую длину два. Поскольку subset — это срез с проверкой времени выполнения, попытка доступа к индексу за его пределами (например subset[99]) вызовет панику времени выполнения. Это обеспечивает безопасность от случайных или злонамеренных записей за пределами границ и предотвращает повреждение памяти. Хотя мы комментируем фактическую запись за пределами границ (чтобы избежать сбоя теста), это служит демонстрацией: срезы прикрывают вашу спину и будут паниковать вместо того чтобы молча приводить к неопределённому поведению.

Ключевой вывод: срезы предлагают страховочную сетку времени выполнения от ошибок индексации — обеспечивая душевное спокойствие и делая ваш код более надёжным.

Теперь мы установили: срезы являются окном в данные с информацией о длине и проверкой границ. Они безопаснее указателей благодаря этим возможностям.

# Шаг 3. Более сложный сценарий: фильтрация и подрезка

Предположим, мы хотим подготовить подмножество камней для ритуала заклинания. Правила следующие:

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

Мы не можем просто создать массивы неизвестной длины на этапе компиляции. Но мы можем подсчитать, сколько камней соответствует условию, во время выполнения, а затем сделать срез соответствующим образом. Давайте смоделируем это, подсчитав, сколько камней удовлетворяет условию, а затем создадим «квалифицированный» срез:

test "slices enabling runtime filtering" {
    var stone_powers = [_]u16{ 42, 17, 93, 58, 11, 99 };
    var count_greater_than_50: usize = 0;

    for (stone_powers) |power| {
         if (power > 50) count_greater_than_50 += 1;
    }

    try std.testing.expect(count_greater_than_50 == 3);
    const start_index: usize = 2;
    var length: usize = 3; // Предположим, что мы знаем во время выполнения, что эти хорошие камни начинаются с индекса 2
    _ = &length; // индекс, известный во время выполнения

    const good_stones = stone_powers[start_index..][0..length];

    try std.testing.expect(@TypeOf(good_stones) == []u16);
    try std.testing.expect(good_stones[0] == 93);
    try std.testing.expect(good_stones[1] == 58);
    try std.testing.expect(good_stones[2] == 11);
}

Давайте ещё раз разберёмся, что происходит. Помните: цель — подсчитать, сколько камней превышают определённый порог силы, а затем создать срез, который представляет именно эти камни.

Сначала мы проходим по массиву и подсчитываем, сколько камней имеют силу больше 50. Получив количество, мы моделируем сценарий, в котором знаем, какая часть массива содержит эти «мощные» камни, и хотим создать срез, ссылающийся только на них. Поскольку начальный индекс и длина известны только во время выполнения (в более динамичной программе), срез с такими значениями приводит к типу среза []u16.

Дважды используя срез — сначала с известным во время выполнения начальным индексом, а затем с известной длиной — мы демонстрируем, как вывод типов Zig может получить тип среза во время выполнения. Первая операция среза сужает массив с определённого начального индекса. Вторая операция среза использует длину, известную во время выполнения, подтверждая: Zig создаст тип среза ([]u16), который будет проверяться во время выполнения, обеспечивая безопасность и корректность.

Затем мы проверяем, что полученный срез содержит правильные камни и что его тип соответствует ожидаемому. Это показывает: срезы позволяют динамически фильтровать и подмножествовать массивы безопасным способом с проверкой во время выполнения.

Прежде чем продолжить — небольшое стихотворение, чтобы немного снять напряжение.

Точнее говоря, вот что говорит этот крошечный текст:

Чувствуете себя лучше? Тогда продолжим.

# Шаг 4. Работа со строками как со срезами

В Zig нет отдельного типа для строк: строки — это просто срезы u8. Можно представить их как срезы, «обёрнутые» в текст. Допустим, у нас есть название заклинания, и мы хотим выделить подстроку во время выполнения:

test "strings as slices" {
    // Название заклинания, представленное как срез UTF-8
    const spell_name: []const u8 = "Caldara's Blaze";

    // Хотим выделить подстроку "Blaze" во время выполнения.
    var start_of_substring: usize = 10; // Считаем: C(0)a(1)l(2)d(3)a(4)r(5)a(6)'(7)s(8) (пробел)(9)B(10)
    _ = &start_of_substring; // индекс, известный во время выполнения

    const substring = spell_name[start_of_substring..];

    try std.testing.expect(@TypeOf(substring) == []const u8);
    try std.testing.expect(std.mem.eql(u8, substring, "Blaze"));

    // Если ошибётесь с индексом вне диапазона, Zig сообщит об этом во время выполнения.
    // substring[99]; // вызовет панику, так как подстрока не такой длины.
}

Этот тест показывает, что строки в Zig — это, по сути, срезы байтов. Код начинается с UTF-8 строки "Caldara's Blaze", а затем с помощью индекса, известного во время выполнения (start_of_substring), выделяет подстроку "Blaze". Поскольку начальный индекс определяется во время выполнения, итоговая подстрока имеет тип []const u8.

Проверяя содержимое и тип подстроки, мы демонстрируем: Zig обеспечивает проверки безопасности времени выполнения. Попытка обратиться за пределы подстроки приведёт к панике во время выполнения, а не к неопределённому поведению. Это подчёркивает: подход Zig к безопасности памяти распространяется и на срезы строк.

Здесь срезы позволяют легко извлекать части строки во время выполнения без сложных функций для работы со строками. Просто «нарезайте» как вам удобно.

# Шаг 5. Срезы с терминальным символом (потому что некоторые API застряли в прошлом)

Некоторые старомодные API считают отличной идеей отмечать конец данных специальным терминальным символом, например, нулём (zero-terminated string). Система типов Zig может это отражать, чтобы вы случайно не забыли про терминатор. Только не обманывайте Zig: если вы говорите, что данные завершаются нулём, он действительно должен быть в конце.

test "sentinel-terminated slices" {
    const legacy_name: [:0]const u8 = "Elixir";
    try std.testing.expect(legacy_name.len == 6);
    try std.testing.expect(legacy_name[6] == 0);
    var data = [_]u8{ 70, 71, 72 }; // здесь нет завершающего нуля
    const good_slice = data[0..data.len]; _ = good_slice;

    // Попытка создать срез с терминальным символом из data приведёт к панике:
    // const bad_slice = data[0..data.len :0]; _ = bad_slice;
}

Срезы с терминальным символом ожидают определённое значение (часто 0) для обозначения конца последовательности. В этом коде legacy_name — это срез байтов с терминальным символом ([:0]const u8), представляющий строку с нулём на конце: "Elixir". Так как "Elixir" состоит из 6 символов плюс завершающий ноль, длина legacy_name — 6, а ноль находится в позиции 6. Это соответствует ожиданиям для строк с терминальным символом.

Напротив, если попытаться создать срез с терминальным символом из данных, не заканчивающихся ожидаемым символом, Zig обнаружит это несоответствие во время выполнения и вызовет панику. Такая строгая проверка гарантирует, что ваши срезы всегда соответствуют предполагаемому расположению данных в памяти, обеспечивая безопасность и корректность при работе с данными, завершающимися специальным символом.


# Итоги по срезам

  • Срезы обеспечивают гибкость времени выполнения: позволяют просматривать подмножества массивов без копирования данных.
  • Они содержат информацию о длине, что позволяет выполнять проверки границ и предотвращать ошибки.
  • Комбинируя срезы времени выполнения с длинами, известными на этапе компиляции, Zig иногда возвращает указатели на массивы для дополнительной эффективности — Zig никогда не упускает шанс оптимизировать.
  • Строки в Zig — это просто срезы байтов: просто и последовательно.
  • Срезы с терминальным символом позволяют безопасно взаимодействовать со старыми API, ожидающими специальных маркеров в конце.

Срезы — это ваше динамичное окно в статичный мир. Они уважают благородство лежащих в основе массивов, но не боятся показать вам только ту часть этого благородного наследия, которая вам действительно нужна. Освоив их, вы начнёте «нарезать» данные как шеф-повар и, надеюсь, не порежете себе руку.

Пока срезы помогают вырезать гибкие представления массивов, структуры позволяют определять собственные формы — объединять связанные поля и вносить порядок в хаос. Далее мы рассмотрим структуры и увидим, как они позволяют создавать собственные типы данных, направляя вас к более ясному, безопасному и выразительному коду.

# Структуры: ваши собственные пользовательские данные (потому что вы особенные)

Если массивы и срезы — это про то, как связывать кошек в мешке или подглядывать за ними через дырку, то структуры — это про то, чтобы давать этим кошкам имена, надевать на них шляпы и, возможно, даже наделять их экзистенциальными кризисами. Другими словами, структуры позволяют определять собственные типы данных, объединяя связанные значения, каждое из которых обладает своей индивидуальностью. Это ваш шанс поиграть в бога данных, создавая формы, которые лучше всего подходят для вашей предметной области.

И, в отличие от массивов или срезов, вам не нужно ограничиваться одним типом данных. Структура может содержать целые числа, числа с плавающей запятой, строки и даже вложенные структуры — любую странную смесь, которую только может придумать ваш разум. Это как собрать свой собственный набор инструментов вместо того, чтобы копаться в чужом ящике с хламом.

Давайте погрузимся в прогрессивный пример, начиная с чего-то простого и двигаясь к более сложным и тонким случаям использования.

# Шаг 1. Простая структура (основы)

Начнём с простой структуры Spell, которую можно встретить в фэнтезийной игре. У каждого заклинания есть имя, стоимость маны и некое значение силы.

Эти поля аккуратно упакованы вместе, давая вам красиво именованный «ком» данных:

const std = @import("std");
const expect = std.testing.expect;

test "defining and using a simple struct" {
    const Spell = struct {
            name: []const u8,
            mana_cost: u32,
            potency: f32,
    };

    // Инициализация литерала структуры — просто заполняем все поля:
    const fireball = Spell{
            .name = "Fireball",
            .mana_cost = 25,
            .potency = 0.75,
    };

    try expect(std.mem.eql(u8, fireball.name, "Fireball"));
    try expect(fireball.mana_cost == 25);
    try expect(fireball.potency == 0.75);
}

Достаточно просто. У вас есть именованный тип данных с полями. Компилятор не гарантирует порядок полей в памяти, но кого это волнует? Вам всё равно не стоит полагаться на порядок полей — просто доверьтесь компилятору в выборе оптимальной компоновки.

# Шаг 2. Добавление функций к структурам (методы, но не совсем)

Структуры могут содержать и функции, предоставляя удобное пространство имён для связанных операций. Давайте разовьём наш пример. Допустим, мы хотим определить Creature, которая может выполнять действия. Мы дадим ей метод «атаковать», который будет использовать структуру Spell, определённую ранее. Методы — это не магия, а просто функции, которые «живут» внутри структуры. Тем не менее, вызов их через точку синтаксиса ощущается тепло и уютно, как чашка какао зимним утром.

Вот полный пример:

const std = @import("std");
const expect = std.testing.expect;

const Spell = struct {
    name: []const u8,
    mana_cost: u32,
    potency: f32,
};

const Creature = struct {
    health: f32,
    mana: u32,
    pub fn attack(self: *Creature, spell: Spell) bool {
        if (self.mana < spell.mana_cost) {
            // Недостаточно маны для произнесения заклинания
            return false;
        }
        self.mana -= spell.mana_cost;
        return true;
    }
};

test "struct methods" {
    var goblin = Creature{ .health = 100.0, .mana = 50 };
    const frost_bolt = Spell{ .name = "Frost Bolt", .mana_cost = 20, .potency = 0.6 };
    try expect(goblin.attack(frost_bolt) == true);
    try expect(goblin.mana == 30);
    // Попробуем снова, пока гоблин не сможет больше колдовать
    try expect(goblin.attack(frost_bolt) == true);
    try expect(goblin.mana == 10);
    try expect(goblin.attack(frost_bolt) == false);
    // Маны больше нет
}

Методы наделяют ваши структуры поведением, позволяя связывать данные и функциональность. Это помогает поддерживать порядок и логичность кода. Только помните: здесь нет всякой ерунды с наследованием ООП — Zig остаётся простым и честным.

# Шаг 3. Пространства имён в структурах и пустые структуры

Структуры могут также содержать «константы» и другие объявления. Иногда вам просто нужно пространство имён для хранения связанных констант. Пустая структура может служить логической группировкой констант без каких-либо затрат во время выполнения:

const std = @import("std");
const expect = std.testing.expect;

test "struct constants and empty structs" {
    const Alchemy = struct {
        pub const BASE_MULTIPLIER = 2.5;
    };

    const EmptySpace = struct {
        pub const PI = 3.14159;
        // Нет полей вообще. Совершенно легально. Никаких затрат во время выполнения.
    };

    try expect(@sizeOf(EmptySpace) == 0);
    try expect(EmptySpace.PI == 3.14159);

    // Вы можете создать экземпляр пустой структуры, если хотите,
    // но это, по сути, ничего не делает. Это no-op.
    const nothing = EmptySpace{};
    _ = nothing;

    // Используйте константы по мере необходимости:
    const result = 10.0 * Alchemy.BASE_MULTIPLIER;
    try expect(result == 25.0);
}

Наш пример показывает, как структура может содержать публичные константы, доступные без создания экземпляра, и как можно определить полностью пустую структуру без полей, но при этом с константами.

Такие пустые структуры не несут затрат во время выполнения, что подтверждается проверкой @sizeOf(EmptySpace). Хотя создание экземпляра пустой структуры разрешено, это, по сути, приводит к значению нулевого размера, не выполняющему никаких действий. Используя эти константы напрямую, например, Alchemy.BASE_MULTIPLIER или EmptySpace.PI, код подтверждает: работа с константами, определёнными в структуре, не требует накладных расходов во время выполнения и упрощает некоторые аспекты проектирования программы.

# Шаг 4. Указатели на родительскую структуру (изящная рефлексия)

Zig предоставляет несколько изящных трюков, таких как @fieldParentPtr, чтобы вычислить указатель на родительскую структуру из указателя на поле. Нужна причина для этого безумия? Представьте, что вам нужно вернуться от поля к его владельцу. Это немного похоже на следование по хлебным крошкам обратно к дому ведьмы, но менее пугающе:

const std = @import("std");
const expect = std.testing.expect;

const Creature = struct {
    health: f32,
    mana: u32,
};

fn boostMana(mana_ptr: *u32, amount: u32) void {
    // Мы знаем, что mana_ptr — это поле структуры Creature. Давайте получим указатель на Creature!
    // const Creature = @typeInfo(@TypeOf(mana_ptr.*)).Pointer.parent; // Просто демонстрация концепции
    // На самом деле используем встроенную функцию Zig:
    const creature_ptr: *Creature = @fieldParentPtr("mana", mana_ptr);
    creature_ptr.mana += amount;
}

test "field parent pointer" {
    var elf = Creature{ .health = 150.0, .mana = 10 };
    boostMana(&elf.mana, 40);
    try expect(elf.mana == 50);
}

В функции boostMana мы получаем указатель на поле mana структуры Creature, а затем вызываем @fieldParentPtr("mana", mana_ptr). Таким образом, возвращается указатель на родительскую структуру Creature, содержащую поле mana, что позволяет коду изменять не только отдельное поле, но и состояние всей структуры.

# Шаг 5. Значения полей по умолчанию, сделанные правильно

Вы можете задавать значения по умолчанию для полей, но будьте осторожны с инвариантами. Не устанавливайте значения по умолчанию, которые нарушают вашу логику:

const std = @import("std");
const expect = std.testing.expect;

test "default field values" {
    const Potion = struct {
        strength: u16 = 10,
        flavor_rating: u8,
    };

    // Указываем только flavor_rating, strength по умолчанию равен 10.
    const brew = Potion{ .flavor_rating = 8 };
    try expect(brew.strength == 10);
    try expect(brew.flavor_rating == 8);
}

В структуре Potion полю strength было присвоено значение по умолчанию 10, в то время как поле flavor_rating не имело значения по умолчанию и должно было быть указано при инициализации. В тесте был создан экземпляр Potion с именем brew, для которого было задано значение только для поля flavor_rating. Поскольку для strength значение не было указано явно, оно автоматически приняло значение по умолчанию 10, как определено в структуре. Утверждения проверили, что brew.strength равно 10, а brew.flavor_rating равно 8, что подтвердило: значения полей по умолчанию в Zig работают как ожидается при инициализации структур.

# Шаг 6. Упакованные структуры (фокусы с размещением в памяти)

Если вам нужен полный контроль над размещением в памяти (например, при работе с бинарными протоколами или регистрами оборудования), вашим инструментом будут упакованные структуры. Они гарантируют известную побитовую компоновку. Это идеально подходит, когда вы чувствуете себя особенно педантичным или общаетесь со старыми C API, которые всё ещё живут в 90-х. Вот как их можно использовать:

const std = @import("std");
const expect = std.testing.expect;

test "packed struct example" {
    // Представьте упакованную структуру, которая хранит
    // два маленьких целых числа в одном байте:
    const TinyData = packed struct {
        high: u4,
        low: u4,
    };

    const data = TinyData{ .high = 0xA, .low = 0x5 };

    // Побитовая компоновка теперь предсказуема и стабильна:
    try expect(data.high == 0xA);
    try expect(data.low == 0x5);
    try expect(@sizeOf(TinyData) == 1);
}

Структура TinyData была определена с двумя 4-битными полями, high и low, упакованными в один байт с помощью ключевого слова packed. Это обеспечило компактное и предсказуемое представление в памяти.

Тест создал экземпляр TinyData, присвоив шестнадцатеричные значения 0xA полю high и 0x5 полю low. Затем он проверил, что эти значения были сохранены правильно в упакованной структуре, и что общий размер TinyData составил ровно один байт. Это один из примеров того, как упакованные структуры можно использовать для оптимизации использования памяти и обеспечения стабильных побитовых раскладок для таких задач, как низкоуровневое программирование или сериализация данных.

Вот сравнение обычной и упакованной структуры:

// Обычная структура — оптимизация компилятора
const Normal = struct {
    flag: bool, // Может использовать 1 байт
    value: u16, // Может добавить выравнивание, затем 2 байта
};

// Общий размер: возможно, 4 байта из-за выравнивания

// Упакованная структура — вы контролируете каждый бит
const Packed = packed struct {
    flag: u1, // Ровно 1 бит
    value: u15, // Ровно 15 бит
};

// Общий размер: ровно 2 байта (всего 16 бит)

Вы можете делать изящные вещи, такие как побитовые приведения и манипулировать полями, не выровненными по байтам. Только помните: с большой силой приходит большая ответственность. Zig не мешает вам сделать собственное ружьё, особенно если вы заблудитесь в вопросах выравнивания и упаковки.

# Шаг 7. Анонимные структуры и кортежи

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

Этот подход обеспечивает лёгкое и ситуативное инкапсулирование данных, как видно в следующем фрагменте:

const std = @import("std");
const expect = std.testing.expect;

test "anonymous structs" {
    // Анонимная структура с именованными полями, выводится по сигнатуре функции:
    try check(.{
        .max_health = 200,
        .respawn_enabled = true,
    });
}

fn check(settings: anytype) !void {
    try expect(settings.max_health == 200);
    try expect(settings.respawn_enabled);
}

Тест передал анонимную структуру, созданную «на месте» с определёнными полями, в функцию check. Анонимная структура содержала два поля: max_health (целое число, равное 200) и respawn_enabled (булево значение, равное true).

Нужно что-то проще? Кортежи (анонимные структуры без именованных полей) позволяют упаковать несколько значений вместе в безымянный «ком». Для быстрой и грязной группировки они отлично подходят.

Давайте рассмотрим следующий тестовый случай:

test "tuples" {
    const hero_stats = .{100, 50.5, "strong"};
    // hero_stats[0] = 100;
    // hero_stats[1] = 50.5;
    // hero_stats[2] = "strong";

    try expect(hero_stats.len == 3);
    try expect(hero_stats.@"0" == 100);
    try expect(hero_stats.@"2"[0] == 's');
}

В Zig кортежи — это коллекции значений, сгруппированных вместе, где индексы служат именами полей. В тесте был создан кортеж hero_stats, содержащий три элемента: целое число 100, число с плавающей запятой 50.5 и строку "strong". Кортежи в Zig легковесны и предоставляют доступ к своим элементам по индексу.

Пора подвести итог и систематизировать наши знания! Вот что мы рассмотрели в этом разделе:

  • Структуры позволяют определять собственные агрегаты данных с типизированными полями.
  • Они могут содержать константы, методы и даже вложенные структуры.
  • Значения полей по умолчанию удобны, но должны соответствовать инвариантам ваших данных.
  • Упакованные структуры дают полный контроль над размещением в памяти — идеально для сложных низкоуровневых задач.
  • Анонимные структуры и кортежи позволяют избегать именования, когда это не нужно, сохраняя краткость и гибкость.

Структуры — это основа структурированных данных в Zig. Это ваш шанс создавать логические группировки, которые имеют смысл для вашей предметной области, вместо того чтобы всё запихивать в массивы или указатели. В конце концов, вы уникальны, и ваши данные тоже — относитесь к ним соответственно.


# Итоги

В этой главе вы изучили основные инструменты, которые Zig предоставляет для структурирования и управления данными. Вы узнали, как массивы служат коллекциями фиксированного размера, известными на этапе компиляции, обеспечивая предсказуемое и эффективное хранение. Срезы расширили эту концепцию, позволяя создавать гибкие представления массивов, определяемые во время выполнения, при этом сохраняя безопасность благодаря проверкам границ. Затем мы перешли к структурам, где вы получили возможность создавать собственные типы данных, использовать значения полей по умолчанию и даже управлять размещением в памяти с помощью упакованных структур. Анонимные структуры и кортежи показали, как Zig сохраняет краткость и динамику, когда полные определения структур не требуются.

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

Теперь, когда ваши данные организованы, пора углубиться в управление памятью. В следующей главе вы освоите указатели, научитесь безопасно выделять и освобождать память, а также изучите продвинутые концепции, такие как типы нулевого размера и приведение типов. Инструменты Zig помогут вам писать безопасный и эффективный код, избегая распространённых ловушек вроде утечек памяти и висячих указателей.

# Закрепим знания!

  1. Какое значение по умолчанию у поля strength в следующей структуре?

    const Potion = struct {
        strength: u16 = 10,
        flavor_rating: u8,
    };
    const brew = Potion{ .flavor_rating = 8 };
    1. 0
    2. 10
    3. не определено
    4. 8
  2. Как получить длину массива в Zig?

    1. .size
    2. .count
    3. .len
    4. @lengthOf(array)
  3. Какое из следующих утверждений о срезах верно?

    1. Срезы требуют, чтобы длина была известна на этапе компиляции.
    2. Срезы — неизменяемые представления массивов.
    3. Срезы содержат информацию о длине для проверки границ во время выполнения.
    4. Срезы не могут перекрывать массивы.
  4. Каков тип следующей переменной?

    const hero_stats = .{100, 50.5, "strong"};
    1. [3]u32
    2. [3]anytype
    3. .len
    4. кортеж
  5. Что произойдёт при попытке доступа к индексу за пределами среза?

    1. Zig молча игнорирует обращение.
    2. Zig возвращает значение по умолчанию 0.
    3. Zig вызывает панику во время выполнения.
    4. Zig компилирует с предупреждением.
  6. Каков размер следующей упакованной структуры?

    const TinyData = packed struct {
        high: u4,
        low: u4,
    };
    1. 2 байта
    2. 4 байта
    3. 1 байт
    4. 8 байт
  7. В Zig чем являются строки?

    1. Массивы char.
    2. Неизменяемые по умолчанию.
    3. Завершаются нулём по умолчанию.
    4. Срезы u8.
  8. Как инициализировать массив нулями?

    1. [_]u16
    2. [_]u16{0}
    3. [_]u16{0} ** n
    4. [_]u16
  9. Какое ключевое слово позволяет пропустить явное объявление длины массива?

    1. @autoLength
    2. [_]
    3. const
    4. var
  10. Что делает @fieldParentPtr в Zig?

    1. Возвращает указатель на первое поле структуры.
    2. Возвращает указатель на родительскую структуру поля.
    3. Возвращает значение поля из его родительской структуры.
    4. Возвращает ссылку на массив структур.
  11. Что произойдёт, если не пройдёт утверждение на этапе компиляции?

    1. Программа аварийно завершится во время выполнения.
    2. Программа запишет предупреждение.
    3. Компиляция программы остановится.
    4. Программа продолжит работу со значением по умолчанию.
  12. Что из перечисленного НЕ является особенностью структур в Zig?

    1. Значения полей по умолчанию.
    2. Динамическое изменение размера полей.
    3. Содержат публичные константы.
    4. Группировка нескольких типов данных.
  13. Что делает следующее выражение на Zig?

    const hello_world = "hello" ++ " " ++ "world";
    1. Склеивает строки во время выполнения.
    2. Склеивает строки на этапе компиляции.
    3. Создаёт срез строк.
    4. Повторяет строку три раза.
  14. Что произойдёт, если у упакованной структуры возникнут проблемы с выравниванием?

    1. Zig вызовет ошибку времени выполнения.
    2. Zig вызовет ошибку на этапе компиляции.
    3. Программа продолжит работу, но с неопределённым поведением.
    4. Программа продолжит работу и запишет предупреждение.
  15. Каково терминальное значение в этом массиве?

    const array = [_:0]u8{1, 2, 3, 4};
    1. 1
    2. 2
    3. 3
    4. 0

# Правильные ответы

  1. b
  2. c
  3. c
  4. d
  5. c
  6. c
  7. d
  8. c
  9. b
  10. b
  11. c
  12. b
  13. b
  14. b
  15. d