# Глава I. Обзор языка, часть первая

Язык программирования Zig это компилируемый язык со строгой статической типизацией. Он поддерживает обобщённые структуры данных, имеет мощные средства метапрограммирования. Управление памятью в Zig ручное, то есть в нём нет сборки мусора. Многие считают Zig современной альтернативой языку C, из которого Zig позаимствовал символ ; как символ, завершающий инструкции (statements), а также фигурные скобки как разграничители блоков кода.

Вот так выглядит исходный код на языке Zig:

const std = @import("std");

// Если тут убрать слово 'pub', то этот код не откомпилируется
pub fn main() void {
    const user = User{
        .power = 9001,
        .name = "Пётр",
    };

    std.debug.print("{s} обладает силой {d}\n", .{user.name, user.power});
}

pub const User = struct {
    power: u64,
    name: []const u8,
};

Сохраните этот исходный текст в файл с именем ex-ch01-01.zig и запустите его следующим образом:

zig run ex-ch01-01.zig 

Вы должны увидеть

Пётр обладает силой 9001

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

# Импортирование библиотек

Редко когда бывает, что исходные тексты программы содержатся в одном-единственном файле и при этом программа не использует никакие библиотеки. Наша первая программа также не является исключением: она использует стандартную библиотеку Zig для того, чтобы напечатать строчку, которую мы только что видели. Система импорта/экспорта в Zig достаточно проста и базируется на двух вещах:

  • @import - встроенная в компилятор функция
  • pub - ключевое слово для экспортирования различных определений

При импортировании модуля нужно указать его имя. Стандартная библиотека Zig доступна по имени std. Чтобы импортировать какой-то конкретный файл, нужно использовать путь относительно того файла, в котором делается импортирование. Например, мы могли бы поместить описание структуры User в отдельный файл (скажем, models/user.zig) и тогда бы в основном файле мы бы написали

const User = @import("models/user.zig").User;

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

Наш гипотетический models/user.zig, помимо структуры User, может экспортировать и другие вещи. Например, мы могли бы также экспортировать константу:

// models/user.zig
pub const MAX_POWER = 100_000;

pub const User = struct {
    power: u64,
    name: []const u8,
};

И тогда бы могли в главном файле импортировать обе сущности:

const user = @import("models/user.zig");
const User = user.User;
const MAX_POWER = user.MAX_POWER;

Скорее всего, на данный момент у вас больше вопросов, чем ответов. Что такое user в приведенном отрывке? Хотя мы ещё не знакомы с ключевым словом var, но тем не менее можно спросить, а что, если вместо const использовать var? Или, возможно, у вас возник вопрос по поводу использования сторонних библиотек. Это всё хорошие вопросы, но чтобы ответить на них, нам нужно глубже изучить Zig, а пока нам придётся обойтись тем, что мы уже знаем, а именно:

  • как импортировать стандартную библиотеку (const std = @import("std");)
  • как импортировать свои собственные файлы (const user = @import(models/user.zig))
  • как экспортировать те или иные определения (pub const ...)

# Комментарии

Следующая строчка в примере это комментарий:

// Если тут убрать слово 'pub', то этот код не откомпилируется

В Zig нет многострочных комментариев в стиле C (/* ... */), но зато есть три различных вида однострочных:

  • // - обычные комментарии в стиле C++
  • //! - комментарии "верхнего уровня", размещаются в начале файла
  • /// - документирующие комментарии, располагаются перед определениями

# Функции

После комментария в нашем примере идёт строчка

pub fn main() void {

Это начало главной функции программы. В каждом исполнимом файле должна существовать функция, с которой начинается выполнение, это входная точка программы. Если переименовать main во что-то другое, например, в doIt и попытаться запустить нашу программу, то мы получим сообщение об ошибке, в котором говорится, что в нашем файле нет функции main:

/opt/zig-0.11/lib/std/start.zig:559:45: error: root struct of file 'ex-ch01-01' has no member named 'main'

Если не принимать во внимание особую роль функции main как точки входа, то в остальном это обычная функция, то есть она может принимать параметры и возвращать какое-то значение. В данном случае наша main ничего не принимает (что обозначено отсутствием чего бы то ни было внутри круглых скобок) и ничего не возвращает, что обозначено ключевым словом void.

Следующий пример будет чуть-чуть более интересным:

const std = @import("std");

pub fn main() void {
    const sum = add(8999, 2);
    std.debug.print("8999 + 2 = {d}\n", .{sum});
}

fn add(a: i64, b: i64) i64 {
    return a + b;
}

Программисты, пишущие на языках C или C++, наверняка уже заметили, что Zig не требует объявления/определения функции до её использования, функция add вызывается раньше (по тексту), чем встречается её определение.

Далее отметим тип i64. Вот некоторые другие целочисленные/вещественные типы: u8, i8, u16, i16, u32, i32, u47, i47, u64, i64, f32 и f64. Наличие в списке u47 и i47 это вовсе не проверка того, что вы ещё не спите - Zig умеет работать с целыми числами произвольного размера (в битах). Возможно, вы не будете использовать такие типы (u1, s9 и т.п.), однако, иногда они удобны или даже необходимы. Тип, который вы точно будете часто использовать, это usize, целочисленный тип размером с указатель (для данной платформы) и этот тип представляет длину/размер чего-либо.

Давайте (без всякой на то причины) изменим реализацию функции add вот таким образом:

fn add(a: i64, b: i64) i64 {
    a += b;
    return a;
}

Мы получим ошибку в строчке, где a += b, "нельзя присваивать значение константе". Это важное наблюдение, к которому мы ещё вернёмся позже: параметры функций являются константами.

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

# Структуры

Далее в нашем примере идёт создание экземпляра структуры User, определение которой располагается после функции main и которое гласит:

pub const User = struct {
    power: u64,
    name: []const u8,
};

Поскольку эта структура у нас используется только в том же файле, где и определена, то слово pub тут необязательно. Оно было использовано для того, чтобы ознакомиться с экспортом определений.

Поля структуры оканчиваются запятой (в отличие от С) и им можно присвоить значение по умолчанию (также в отличие от С):

pub const User = struct {
    power: u64 = 0,
    name: []const u8,
};

Когда мы создаём экземпляр структуры, все её поля должны быть проинициализированы. Например, если использовать наше первоначальное определение структуры User (у которой поле power не имеет значения по умолчанию) и написать

const user = User{.name = "Пётр"};

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

Структуры могут иметь методы, они могут содержать определения (включая другие структуры), а также могут быть вообще пустыми (и в таком случае они работают как пространства имён). Пример структуры с методом:

pub const User = struct {
    power: u64 = 0,
    name: []const u8,

    pub const SUPER_POWER = 9000;

    fn diagnose(user: User) void {
        if (user.power >= SUPER_POWER) {
            std.debug.print("Вот это силища, больше, чем {d}!!!", .{SUPER_POWER});
        }
    }
};

Методы это просто обычные функции, которые можно вызвать, используя точку:

// вызвать функцию diagnose для некоторого экземпляра User
user.diagnose();

// предыдущая строчка это "синтаксический сахар" для вот такой полной формы:
User.diagnose(user);

Что касается инструкции if внутри функции diagnose, то она вполне самоочевидна. Мы исследуем инструкции управления потоком выполнения подробнее в следующей главе.

Итак, функция diagnose определена внутри типа User и принимает такую же структуру в качестве своего первого и единственного параметра. Соответственно, мы можем вызывать эту функцию, используя точку, в "сахарном" варианте. Однако, функции внутри структуры вовсе не обязаны следовать такому правилу. Один из распространённых примеров - это функция инициализации:

pub const User = struct {
    power: u64 = 0,
    name: []const u8,

    pub fn init(name: []const u8, power: u64) User {
        return User{
            .name = name,
            .power = power,
        };
    }
}

Использование именно такого имени (init) является просто соглашением, то есть в каких-то случаях более уместными могут показаться и другие имена, например, open. Если вы не программист на C/C++, то такой синтаксис инициализации полей (с точкой, .name = name) может показаться вам слегка странным, но со временем вы к этому привыкнете.

Когда мы создавали пользователя Пётр, мы объявили экземпляр user как константу (const):

const user = User{
    .power = 9001,
    .name = "Пётр",
};

Это означает, что мы не можем модифицировать эту "переменную". Чтобы иметь возможность модификации, нужно объявить экземпляр при помощи ключевого слова var, а не const. Возможно, вы так же заметили, что тип переменной user выводится из того, что в неё присваивается. Мы могли бы написать всё явно, то есть вот так:

const user: User = User{
    .power = 9001,
    .name = "Пётр",
};

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

const user: User = .{
    .power = 9001,
    .name = "Пётр",
};

Впрочем, такое использование не особо употребительно. Одно из мест, где это используется - возвращение структуры из функции, тут тип может быть выведен из типа возвращаемого значения функции. Более вероятно, что наша функция инициализации выглядела бы так:

pub fn init(name: []const u8, power: u64) User {
    // вместо того, чтобы возвращать User{...}
    return .{
        .name = name,
        .power = power,
    };
}

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

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

Мы могли бы обойти молчанием последнюю строчку нашего примера (name: []const u8), но, поскольку этот пример содержит две строковые константы ("Пётр" и "{s} обладает силой {d}\n"), то наверняка вы любопытствуете по поводу строк в Zig. Чтобы лучше понимать строки, нужно сначала ознакомиться с массивами и срезами.

Массивы имеют фиксированный размер, их длина (количество элементов) известна во время компиляции. Длина является составляющей частью типа, то есть массив из 4-х знаковых целых ([4]i32) и массив из 5-ти знаковых целых ([5]i32) - это разные типы.

Длина массива может быть выведена из его инициализатора. В следующем отрывке кода все три массива имеют тип [5]i32:

const a = [5]i32{1, 2, 3, 4, 5};

// мы уже видели синтаксис .{...} со структурами
// он также работает и с массивами
const b: [5]i32 = .{1, 2, 3, 4, 5};

// используйте _, тогда компилятор сам вычислит длину
const c = [_]i32{1, 2, 3, 4, 5};

С другой стороны, срез - это пара указатель плюс длина, при этом длина становится известной лишь при исполнении программы. Мы пройдёмся по указателям далее, а пока вы можете представлять себе срез как некое "окно" в массиве.

Однако, не всё так просто. Рассмотрим следующий отрывок кода:

const a = [_]i32{1, 2, 3, 4, 5};
const b = a[1..4];

С виду b это вроде как срез с началом, показывающим на a[1] и длиной 3 (правая граница интервала не включается). Но нет. Мы сделали срез массива a, используя границы "окна", которые известны во время компиляции, то есть 1 и 4. Длина среза (4 - 1 = 3) также известна во время компиляции, поэтому b это по факту не срез, а указатель на массив целых чисел длиной 3, то есть *const [3]i32. Таким образом, сообразительность Zig сбила наши планы сделать (настоящий) срез. Но сейчас мы его немножко обдурим, так сказать:

const a = [_]i32{1, 2, 3, 4, 5};
var end: usize = 4;
const b = a[1..end];

Теперь b это в полной мере срез, то есть тип переменной b это []const i32. Теперь длина не является частью типа, потому что она известна лишь во время исполнения (потому что мы поместили правую границу среза в переменную end). Отметим также, что при создании среза можно опустить правую/верхнюю границу, тогда срез продлится до конца того, от чего мы его делаем, например const c = b[2..];. И последнее: если бы мы написали const end: usize = 4;, а не var end: usize = 4;, то опять получили бы не срез (указатель + длина), а указатель на массив.

Изучение Zig приучает к мысли, что типы весьма содержательны. Это не просто целое, логическое или даже массив целых. Типы также содержат другую важную информацию. Мы уже говорили о том, что длина массива является составляющей частью типа. Многие примеры показывают, что неизменяемость (constness) также является составляющей типа. Например, в нашем последнем примере тип b это []const i32. Вы можете сами это наглядно увидеть при помощи вот такой программы:

const std = @import("std");

pub fn main() void {
    const a = [_]i32{1, 2, 3, 4, 5};
    var end: usize = 4;
    const b = a[1..end];
    std.debug.print("{any}", .{@TypeOf(b)});
}

Запустим её:

$ /opt/zig-0.11/zig run src/ex-ch01-03.zig
[]const i32

Если бы в этом примере мы попытались бы записать что-то в b, например b[2] = 1, мы бы получили ошибку компиляции:

$ /opt/zig-0.11/zig run src/ex-ch01-03.zig
src/ex-ch01-03.zig:8:6: error: cannot assign to constant
    b[2] = 1;
    ~^~~

Наверняка для исправления ситуации вам тут хочется поменять const на var при объявлении b:

// меняем const на var
var b = a[1..end];

Однако, к нашему удивлению, мы получим ту же самую ошибку. Как же так? Дело в том, что указатель в срезе показывает на какую-то часть конкретного массива и тип среза выводится из типа массива, из которого берется срез. Поэтому вне зависимости от того, как объявлен срез b, мутабельным (изменяемым, то есть как var) или нет (то есть как const), он делает срез с [5]const i32 и поэтому он обязан иметь точно такой же тип. Если мы хотим менять содержимое массива a через его срез b, нужно писать так:

const std = @import("std");

pub fn main() void {
    var a = [_]i32{1, 2, 3, 4, 5};
    var end: usize = 4;
    const b = a[1..end];
    b[2] = 99;
}

Это работает, поскольку теперь тип среза b это []i32, а не []const i32, как было до этого. Тут вы справедливо возразите - но b-то у нас всё равно const, как же тогда это может работать?!? Но всё просто - неизменяемость b относится к самому b (то есть к той самой паре указатель+длина), но не к данным, на которые он показывает, а показывает он на var a, то есть на что-то мутабельное/изменяемое.

Для ясности попробуйте откомпилировать

const std = @import("std");

pub fn main() void {
    var a = [_]i32{1, 2, 3, 4, 5};
    var end: usize = 4;
    const b = a[1..end];
    b = b[1..];
}

Это компилятор не пропустит, потому что мы попытались переназначить немутабельный срез. Если бы мы написали var b = a[1..end];, то всё было бы нормально.

# Строки

Эх, хотелось бы тут сказать, что "в Zig есть тип string и он просто офигенный", но, увы и ах, нет в Zig такого типа. А что же тогда есть? Строки в Zig - это просто последовательности (массивы или срезы) байтов (u8). Мы уже видели это в самом начале, когда описывали поле name в структуре User как name: []const u8.

По соглашению (и только по соглашению), такие строки должны содержать только корректные с точки зрения UTF-8 значения, при этом сами исходные тексты на Zig тоже кодируются при помощи UTF-8. Конечно же, нет никакой разницы, что у нас размещается в []const u8, произвольные "бинарные" данные или строка в кодировке UTF-8. Как бы то ни было, для всех случаев используется один и тот же тип, как для собственно строк, так и для всего прочего.

Из того, что мы уже знаем про массивы и срезы, мы могли бы заключить, что []const u8 это срез неизменяемого массива байтов, беззнаковых 8-ми битных целых чисел. Но при инициализации строк мы нигде никакие массивы не использовали, мы просто писали .name = "Пётр". Как это работает?

Длины строковых констант (strings literals, буквальные значения) известны во время компиляции. Компилятор знает, что строка "Пётр" имеет длину 8 байт (в UTF-8 на каждую букву кириллицы идёт 2 байта), так что мы можем предположить, что тип этой константы будет [8]const u8. Однако, строковые константы обладают парочкой особых свойств, а именно, для их хранения в исполнимом файле отведено специальное место, при этом дубликаты исключаются. Поэтому переменная, через которую доступна та или иная строковая константа, по идее, должна быть указателем в это специальное место. Это означает, что тип строки "Пётр" это скорее указатель вида *const [8]u8, то есть указатель на неизменяемый массив длиной 8 байт.

Но это ещё не всё. Строковые константы заканчиваются бинарным нулём (как в C). Собственно, так сделано как раз ради "взаимодействия" с кодом, уже когда-то написанным на языке C. Строка "Пётр" в памяти будет выглядеть как {0xD0, 0x9F, 0xD1, 0x91, 0xD1, 0x82, 0xD1, 0x80, 0x00} и тогда, возможно, вы подумаете, что её тип это *const [9]u8, Но это в лучшем случае будет неоднозначным, а в худшем - даже опасным. Поэтому для представления строк с признаком конца в виде \0 (null-terminated strings) в Zig есть специальный синтаксис - в общем, строка "Пётр" имеет тип *const [8:0]u8, то есть указатель на массив из 8-ми байт с дополнительным нулём на конце. Тут мы невольно сделали акцент именно на C-подобных строках, однако, этот синтаксис (то есть [LENGTH:SENTINEL] в общем виде) более общий, SENTINEL тут это (произвольное в рамках типа элементов массива) специальное значение, служащее для обозначения конца массива. Вот странный пример с непонятным потенциальным применением, но тем не менее, он вполне корректен с точки зрения Zig:

const std = @import("std");

pub fn main() void {
    // массив из трёх булевских значений с false на конце
    const a = [3:false]bool{false, true, false};

    // эта строка более сложная, объясняться не будет
    std.debug.print("{any}\n", .{std.mem.asBytes(&a).*});
}

Работает, как и задумано (то есть признак конца тоже печатается):

$ /opt/zig-0.11/zig run src/ex-ch01-04.zig
{ 0, 1, 0, 0 }

Весьма вероятно, что у вас остался ещё один вопрос. Если "Пётр" это *const [8:0]u8, а поле name это []const u8, то как так получается. что мы свободно присваиваем первое второму? Ответ простой - в этом случае Zig делает приведение типов (coercion) автоматически. Это означает, что если функция имеет параметр с типом []const u8 или поле структуры имеет тип []const u8, то при передаче параметра или присваивании значения полю можно использовать строковые константы. Приведение типов в этом случае не требует больших временных затрат, поскольку строки с нулём на конце это массивы, длина их известна на этапе компиляции, поэтому не нужно проходить по всей строке в поисках признака конца.

Итак, когда мы говорим о строках, то обычно мы имеем ввиду []const u8. Когда необходимо, мы явно указываем, что строка в стиле C, то есть с нулём на конце и помним, что она автоматически приводится к []const u8. Но опять же, мы помним, что []const u8 используется также для произвольных бинарных данных и поэтому в Zig нет специального типа для строк.

Конечно, в "настоящих" программах большинство строк/массивов неизвестны во время компиляции - данные могут поступать от пользователя, из сети и т.п. К этому вопросу мы ещё вернёмся когда будем рассматривать работу с оперативной памятью. А сейчас мы пока скажем следующее - для такого рода данных, которые возникают лишь в процессе работы программы, память выделяется динамически. При этом наши переменные всё равно будут иметь тип []u8 и указатель в этих срезах будет показывать на динамически (то есть в процессе выполнения программы) выделенную память.

# comptime и anytype

В нашем примере осталась ещё одна совершенно не исследованная строчка:

std.debug.print("{s} обладает силой {d}\n", .{user.name, user.power});

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

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

Вам уже, наверное, не терпится спросить, а какое отношение к этому имеет та самая строчка? Давайте посмотрим на сигнатуру функции print:

// обратите внимание на 'comptime' перед первым аргументом
pub fn print(comptime fmt: []const u8, args: anytype) void {

Ключевое слово comptime перед fmt означает, что строка формата должна быть известна во время компиляции. Причиной для этого является то, что print во время компиляции делает различные проверки. Что это за проверки? Ну, скажем, вы поменяли формат на "it's over {d}\n", но при этом оставили два аргумента. Тогда вы получите ошибку компиляции. Также делается проверка на соответствие типов: измените формат на "{s}'s power is {s}\n" и вы тоже получите ошибку про несоответствие u64 спецификатору {s}. Подобного рода проверки были бы невозможны во время компиляции, если бы строка формата была бы неизвестна во время этой самой компиляции, отсюда такое требование для параметра fmt.

Одна из ситуаций, где вы сразу же столкнётесь с comptime это типы по умолчанию для целочисленных констант и констант с плавающей точкой - comptime_int и comptime_float. Например, строчка var i = 0; не является корректной, компилятор в это месте скажет, что "переменная" типа comptime_int должна быть const или comptime. Любой код, который помечен как comptime, должен работать с данными, известными во время компиляции и, что касается целых и вещественных чисел, такие данные имеют специальные типы, comptime_int и comptime_float, соответственно. Однако, вряд ли вы будете большую часть времени писать код, предназначенный для выполнения во время компиляции, так что это не особо полезное умолчание. То, что вам нужно, это просто явно задать типы, например

var i: usize = 0;
var j: f64 = 0;

Отметим также, что если бы мы написали const i = 0 (а не var i = 0), то ошибки бы не было, поскольку вся её (ошибки) суть в том, что "переменные" типа comptime_int должны быть константой.

В одной из следующих глав мы познакомимся с comptime поближе, когда будем изучать обобщённые структуры данных ("generics").

Ещё одна особенная вещь, которая имеется в нашей строчке, это вот это странное {user.name, user.power}, которое, как следует из сигнатуры print, передаётся как anytype. Этот тип не следует путать с чем-то вроде Object из Java или any из Go. Здесь дело обстоит так - во время компиляции Zig сгенерирует специализированные варианты print для всех типов, которые вы передавали print в вашей программе.

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

const std = @import("std");
pub fn main() void {
    std.debug.print("{any}\n", .{@TypeOf(.{.year = 2023, .month = 8})});
}

Он напечатает

struct{comptime year: comptime_int = 2023, comptime month: comptime_int = 8}

Тут мы дали полям анонимной структуры имена (year и month). В изначальном варианте мы этого не делали, просто указывали значения. В таких случаях имена полей генерируются автоматически, они будут такие - "0", "1", "2" и т.д. Функция print ожидает структуру с такими полями и использует позиции спецификаторов в строке формата для получения соответствующего аргумента.

В Zig нет перегрузки функций ("function overloading"), а также нет функций с переменным числом аргументов ("variadic functions"). Вместо этого компилятор Zig умеет создавать специализированные варианты функций, основываясь на передаваемых в "универсальный" вариант типах, как выведенных, так и созданных самим компилятором.