Перейти к основному содержимому

5.20. Типы данных

Разработчику Архитектору

Типы данных

Переменные: объявление и семантика

В Zig каждая переменная должна быть явно объявлена. Язык не допускает неявного создания переменных при первом использовании. Объявление начинается с ключевого слова var для изменяемых переменных или const для неизменяемых значений. Эта простота отражает философию Zig: всё, что может быть константой, должно быть константой. Изменяемость — это исключение, а не правило.

const x = 42;
var y = 10;

Здесь x — неизменяемое значение, установленное один раз и защищённое от последующих модификаций. y — изменяемая переменная, которую можно переприсваивать в рамках её области видимости.

Zig не требует указания типа при объявлении, если компилятор может вывести его из значения. Однако тип всегда существует и строго проверяется на этапе компиляции. Это статическая типизация без необходимости избыточного синтаксиса.

const message = "Hello, Zig!";
// Тип message — [*:0]const u8 (нуль-терминированная строка)

Если требуется явное указание типа, оно записывается после имени переменной через двоеточие:

var counter: u32 = 0;
const pi: f64 = 3.141592653589793;

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

Целочисленные типы

Zig предлагает полный набор целочисленных типов, каждый из которых имеет чётко определённый размер и знаковость. Все целочисленные типы в Zig начинаются с буквы i (для знаковых) или u (для беззнаковых), за которой следует количество бит: 8, 16, 32, 64, 128.

Примеры:

  • u8 — беззнаковое целое 8 бит (диапазон от 0 до 255)
  • i32 — знаковое целое 32 бита (от -2 147 483 648 до 2 147 483 647)
  • u128 — беззнаковое целое 128 бит, способное хранить очень большие значения

Эти типы не являются платформозависимыми. u32 всегда занимает ровно 32 бита, независимо от архитектуры процессора. Это устраняет неопределённость, характерную для таких языков, как C, где int может менять размер в зависимости от системы.

Zig также предоставляет псевдонимы usize и isize, которые соответствуют размеру указателя на текущей платформе. usize используется для индексации, размеров массивов и вычислений, связанных с адресацией памяти.

const buffer_size: usize = 1024;
var index: usize = 0;

Числа с плавающей точкой

Для представления дробных чисел Zig использует стандартные типы IEEE 754: f16, f32, f64, f128. На практике чаще всего применяются f32 (одинарная точность) и f64 (двойная точность).

const temperature: f32 = -12.5;
const gravity: f64 = 9.80665;

Zig не поддерживает неявных преобразований между целыми и вещественными типами. Любое приведение должно быть явным, что предотвращает случайные потери точности или неожиданное поведение.

const a: i32 = 10;
const b: f64 = @floatFromInt(a); // Явное преобразование

Функция @floatFromInt — одна из встроенных функций времени компиляции, обеспечивающих безопасное и контролируемое преобразование.

Логический тип

Логический тип в Zig называется bool и принимает два значения: true и false. Он используется в условиях, циклах и логических выражениях.

const is_ready = true;
var is_valid: bool = false;

Любое значение другого типа нельзя напрямую использовать как логическое. Например, число 0 не эквивалентно false. Это исключает распространённые ошибки, связанные с неявной интерпретацией значений.

Символы и строки

Zig не имеет отдельного типа для одиночного символа вроде char в C. Вместо этого символы представляются как целые числа, соответствующие кодовой точке Unicode. Чаще всего используется тип u21, так как он способен вместить любую кодовую точку UTF-8 (максимум 21 бит).

const euro_sign: u21 = 0x20AC; // €

Строки в Zig — это неизменяемые последовательности байтов в кодировке UTF-8. Они представлены как указатели на нуль-терминированные массивы ([*:0]const u8) или как срезы ([]const u8). Первый вариант используется для строковых литералов, второй — для динамических строк.

const greeting = "Привет, мир!"; // Тип: [*:0]const u8
const dynamic_text: []const u8 = "временный текст";

Строки в Zig не являются объектами высокого уровня с методами. Работа со строками осуществляется через функции из стандартной библиотеки, такие как std.mem.eql для сравнения или std.fmt.format для форматирования.

Массивы и срезы

Массив в Zig — это фиксированная по размеру коллекция элементов одного типа. Размер массива является частью его типа, что делает массивы в Zig строго типизированными.

var numbers: [5]i32 = [_]i32{ 1, 2, 3, 4, 5 };

Здесь [5]i32 — тип массива из пяти 32-битных целых. Конструкция [_]i32{ ... } позволяет компилятору вывести размер из количества элементов.

Срез (slice) — это динамическое представление части массива или другой последовательности. Он состоит из указателя на начало данных и длины. Срезы не владеют данными, а лишь ссылаются на них.

const slice: []const i32 = &numbers[1..4]; // {2, 3, 4}

Срезы играют центральную роль в передаче данных между функциями, так как они не копируют содержимое и не зависят от исходного размера массива.

Структуры

Структуры (struct) — основной способ группировки связанных данных. Каждое поле структуры имеет имя и тип.

const Point = struct {
x: f64,
y: f64,
};

const origin = Point{ .x = 0.0, .y = 0.0 };

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

const Circle = struct {
center: Point,
radius: f64,

fn area(self: Circle) f64 {
return std.math.pi * self.radius * self.radius;
}
};

const unit_circle = Circle{ .center = origin, .radius = 1.0 };
const area = unit_circle.area();

Указатели

Zig предоставляет прямой контроль над памятью через указатели. Указатель объявляется с помощью звёздочки перед типом. Существуют два основных вида указателей:

  • *T — указатель на одно значение типа T
  • [*]T — указатель на первый элемент массива неизвестной длины
var value: i32 = 100;
var ptr: *i32 = &value; // Адрес переменной
ptr.* = 200; // Разыменование и присваивание

Указатели в Zig безопасны по умолчанию: компилятор проверяет, что указатель не нулевой и указывает на действительную память. Нулевые указатели существуют, но требуют явного объявления как ?*T (опциональный указатель).

var maybe_ptr: ?*i32 = null;
maybe_ptr = &value;
if (maybe_ptr) |p| {
p.* = 300;
}

Эта система исключает разыменование нулевых указателей на этапе компиляции или делает его явным и контролируемым.

Опциональные типы

Zig не использует null как универсальное значение для всех типов. Вместо этого он вводит опциональные типы, обозначаемые вопросительным знаком: ?T. Значение такого типа либо содержит значение T, либо равно null.

const maybe_number: ?i32 = 42;
const no_number: ?i32 = null;

Работа с опциональными типами требует явной проверки. Это достигается через if с распаковкой:

if (maybe_number) |n| {
std.debug.print("Число: {}\n", .{n});
} else {
std.debug.print("Нет числа\n", .{});
}

Такой подход гарантирует, что разработчик никогда не забудет обработать отсутствие значения.

Перечисления

Перечисления (enum) в Zig — это типы, состоящие из конечного набора именованных значений. Каждое значение имеет уникальное имя и может быть связано с целочисленным представлением.

const Color = enum {
red,
green,
blue,
};

const background = Color.blue;

По умолчанию значения перечисления имеют тип usize, но можно указать другой целочисленный тип:

const HttpStatus = enum(u16) {
ok = 200,
not_found = 404,
internal_server_error = 500,
};

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


Объединения и алгебраические типы

Zig предоставляет мощный механизм для представления альтернативных состояний — объединения (union). Объединение может содержать значение одного из нескольких возможных типов, но не более одного одновременно. Это делает его идеальным инструментом для моделирования вариативных данных, таких как результат операции (успех или ошибка), разные виды сообщений в системе или различные формы геометрических фигур.

const Shape = union(enum) {
circle: f64,
rectangle: struct { width: f64, height: f64 },
triangle: struct { a: f64, b: f64, c: f64 },
};

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

Для работы с объединениями используется синтаксис switch, который обязан обрабатывать все возможные варианты:

fn area(shape: Shape) f64 {
return switch (shape) {
.circle => |r| std.math.pi * r * r,
.rectangle => |rect| rect.width * rect.height,
.triangle => |t| blk: {
const s = (t.a + t.b + t.c) / 2.0;
break :blk std.math.sqrt(s * (s - t.a) * (s - t.b) * (s - t.c));
},
};
}

Каждый случай в switch распаковывает соответствующее значение, обеспечивая прямой доступ к данным без приведений или проверок времени выполнения. Такой подход сочетает выразительность и производительность, характерные для системных языков.

Пользовательские типы и псевдонимы

Zig позволяет создавать новые имена для существующих типов с помощью ключевого слова const. Это не создаёт новый тип, а лишь вводит удобное имя, улучшающее читаемость.

const UserId = u64;
const TemperatureCelsius = f32;

var user_id: UserId = 1001;
var temp: TemperatureCelsius = -5.3;

Хотя UserId и u64 взаимозаменяемы, использование семантически значимых имён помогает избежать путаницы между разными сущностями, которые технически имеют одинаковое представление.

Для создания действительно новых типов, несовместимых с исходными, используется struct с одним полем:

const SafeString = struct {
data: []const u8,
};

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

Управление памятью и время компиляции

Одной из отличительных черт Zig является глубокая интеграция времени компиляции и времени выполнения. Многие операции, обычно выполняемые во время выполнения в других языках, могут быть перенесены на этап компиляции. Это достигается с помощью ключевого слова comptime.

Переменные, объявленные в контексте comptime, существуют только во время компиляции и не занимают места в исполняемом файле:

const max_buffer_size = comptime blk: {
var size = 1024;
if (@import("builtin").cpu.arch == .x86_64) {
size *= 2;
}
break :blk size;
};

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

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

fn arrayFromComptime(comptime T: type, comptime len: usize) [len]T {
return [_]T{0} ** len;
}

const zeroes = arrayFromComptime(i32, 10); // [0, 0, ..., 0]

Такой подход лежит в основе метапрограммирования в Zig и заменяет макросы, шаблоны и другие механизмы, используемые в C++ или Rust.

Тип void и отсутствие значения

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

fn log(message: []const u8) void {
std.debug.print("{s}\n", .{message});
}

Переменные типа void допустимы, но редко используются:

const nothing: void = {};

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

Тип type и рефлексия

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

fn printType(comptime T: type) void {
@compileLog("Обрабатывается тип: ", @typeName(T));
}

printType(i32); // Вывод во время компиляции

Функция @typeName возвращает человекочитаемое имя типа, а @compileLog выводит сообщение в лог компилятора. Такие инструменты позволяют создавать гибкие и отлаживаемые системы без необходимости в отдельных препроцессорах.

Практический пример: безопасная работа с входными данными

Рассмотрим задачу парсинга пользовательского ввода, где число может быть представлено как целое или дробное. Используем объединение и строгую типизацию:

const ParsedNumber = union(enum) {
integer: i64,
floating: f64,
};

fn parseNumber(input: []const u8) !ParsedNumber {
if (std.fmt.parseInt(i64, input, 10)) |int_val| {
return ParsedNumber{ .integer = int_val };
} else |_| {
if (std.fmt.parseFloat(f64, input)) |float_val| {
return ParsedNumber{ .floating = float_val };
} else |_| {
return error.InvalidNumber;
}
}
}

// Использование
const result = try parseNumber("3.14");
switch (result) {
.integer => |n| std.debug.print("Целое: {}\n", .{n}),
.floating => |f| std.debug.print("Дробное: {:.2}\n", .{f}),
}

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

Заключение

Типы данных и переменные в Zig образуют основу языка, построенную на предсказуемости, контроле и минимализме. Каждый элемент — от простого u8 до сложного тегированного объединения — служит конкретной цели и не скрывает деталей реализации. Переменные чётко разделяются на изменяемые и неизменяемые, преобразования типов всегда явны, а управление памятью остаётся в руках разработчика, но без риска неопределённого поведения.

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