#
Глава II. Обзор языка, часть вторая
В этой главе мы рассмотрим то, что было опущено в предыдущей - инструкции управления выполнением программы, а также типы данных, о которых мы ещё не упоминали - перечисления, объединения и ошибки, как особый вид перечислений.
#
Управление потоком выполнения
Операторы управления потоком исполнения в Zig, безусловно, покажутся вам знакомыми, однако, имеется ряд моментов, касающихся их взаимодействия с некоторыми другими аспектами языка, которые нам пока лишь предстоит изучить. Мы начнём с краткого обзора и будем возвращаться по мере того, как нам будут встречаться особенности в поведении инструкций управления.
Далее вы заметите, что для логических операторов "И" и "ИЛИ" в Zig
используются обозначения and
и or
, а не &&
и ||
. Как и во многих
других языках, эти операторы влияют на ход исполнения программы,
поскольку при компиляции выражений, содержащих эти операторы, применяется
оптимизация, состоящая в том, что выражение справа от знака операции
может вообще не вычисляться - выражение справа от and
не вычисляется,
если выражение слева есть false
, выражение справа от or
не
вычисляется, если выражение слева равно true
. В Zig управление потоком
осуществляется с помощью ключевых слов, именно поэтому для логических
операций используются слова and
и or
, а не "комбинации закорючек".
Далее, оператор сравнения (==
) не работает для срезов, например, для
строк ([]const u8
). В большинстве случаев для сравнения используется
функция std.mem.eql(u8, str1, str2)
, которая сначала сравнивает длины
срезов, а потом побайтно их содержимое.
Операторы if
, else if
, else
в Zig ничем особо не примечательны:
// std.mem.eql осуществляет побайтное сравнение
// для строки такое сравнение будет чувствительным к регистру
if (std.mem.eql(u8, method, "GET") or std.mem.eql(u8, method, "HEAD")) {
// обрабатываем запрос GET
} else if (std.mem.eql(u8, method, "POST")) {
// обрабатываем запрос POST
} else {
// ...
}
Первый аргумент функции std.mem.eql
является типом. Это первая
обобщенная функция, которую мы видим. Более подробно мы исследуем этот
вопрос далее.
Приведённый пример сравнивает ASCII-строки и, по идее, здесь не нужно
учитывать регистр, поэтому лучшим вариантом будет
std.ascii.eqlIgnoreCase(str1, str2)
.
В Zig нет тернарного оператора (const is_super = power > 9000? true : false
), но можно использовать if/else
, вот таким вот образом:
const is_super = if (power > 9000) true else false;
То есть управляющие конструкции являются выражениями, они могут возвращать значение и, соответственно, могут быть использованы в правой части оператора присваивания.
Оператор switch
в какой-то степени подобен каскаду if/else if
, но
имеет то преимущество, что должны быть указаны все возможные варианты.
Вот этот код не пройдёт компиляцию:
fn anniversaryName(years_married: u16) []const u8 {
switch (years_married) {
1 => return "бумажная",
2 => return "хлопковая",
3 => return "кожаная",
4 => return "цветочная",
5 => return "деревянная",
6 => return "сахарная",
}
}
Компилятор нам скажет, что switch
должен обрабатывать все возможные
значения для years_married
. Поскольку этот параметр имеет тип u16
, то
возможных значений у нас 65536 и писать их все представляется крайне
непрактичным. К счастью, у оператора switch
есть оборот else
:
6 => return "сахарная",
else => return "столько вместе не живут",
Для обозначения возможных случаев можно использовать перечисления (через запятую), диапазоны (с помощью трёх точек), а также использовать блоки для кода со сложной в каком-то смысле логикой:
fn arrivalTimeDesc(minutes: u16, is_late: bool) []const u8 {
switch (minutes) {
0 => return "прибыл",
1, 2 => return "почти прибыл",
3...5 => return "прибудет в течении 5 минут",
else => {
if (!is_late) {
return "извините, придётся немного подождать";
}
// что-то пошло вообще не так
return "никогда не прибудет";
},
}
}
Хотя switch
и бывает иногда полезным для перебора по числам, его
исчерпывающее поведение (в смысле "нужно перечислить все возможные
варианты") проявляет себя во всей красе тогда, когда мы работаем с
переменными перечислимого типа, о котором мы вскоре поговорим.
Для итерирования по массивам, срезам и диапазонам в Zig используется цикл
for
. Например, для проверки того, содержится ли какое-либо значение в
массиве, можно написать такой код:
fn contains(haystack: []const u32, needle: u32) bool {
for (haystack) |value| {
if (needle == value) {
return true;
}
}
return false;
}
Циклы for
могут проходить по нескольким последовательностям
одновременно, разумеется, при условии, что их длины равны. Выше мы
использовали функцию сравнения, std.mem.eql
. Вот так эта функция (ну,
примерно) выглядит:
pub fn eql(comptime T: type, a: []const T, b: []const T) bool {
// если они разной длины, они никак не могут быть равными
if (a.len != b.len) return false;
for (a, b) |a_elem, b_elem| {
if (a_elem != b_elem) return false;
}
return true;
}
Начальный if
это не просто некая оптимизация по производительности, эту
проверку делать необходимо - если мы передадим аргументы разной длины, а
этой проверки не будет, то наша программа "упадёт" (runtime panic)
Циклы for
также можно использовать с диапазонами:
for (0..10) |i| {
std.debug.print("{d}\n", .{i});
}
Тут мы вернёмся к оператору switch
. Там для обозначения диапазонов мы
использовали три точки, а тут две. Дело в том, что интервал, обозначенный
тремя точками, является закрытым с обеих концов, а интервал вида 0..n
открыт с правого конца, то есть само n
туда не входит.
Особенно замечательно это смотрится в комбинации со срезами (или с несколькими!):
fn indexOf(haystack: []const u32, needle: u32) ?usize {
for (haystack, 0..) |value, i| {
if (needle == value) {
return i;
}
}
return null;
}
Обратите внимание на тип возвращаемого значения (?usize
), а именно, на
знак вопроса. Таким образом в Zig обозначается необязательность значения
(подобно nullable
полям в реляционных базах данных). Так же и тут -
если ничего не найдено, то возвращаем null
. Подробнее про такие типы
см. ниже, в соответствующем разделе этой главы.
Также обратите внимание на то, что правый конец интервала не написан, он
будет автоматически определён исходя из фактической длины аргумента
haystack
. Впрочем, ничто не мешает написать явно, то есть
0..hastack.len
.
Циклы for
не поддерживают более общую идиому вида (init; compare; next)
. Для этого следует использовать циклы while
. Поскольку цикл
while
в некотором смысле проще, чем цикл for
, при использовании его
формы в виде while (condition) { }
у нас больше контроля за процессом
итерирования. Например, при подсчёте количества экранирующих символов в
строке нам нужно особенным образом учесть случай \\
- именно, нужно
увеличить индекс на 2, чтобы избежать двойного учёта:
var i: usize = 0;
var escape_count: usize = 0;
while (i < src.len) {
if (src[i] == '\\') {
i += 2;
escape_count += 1;
} else {
i += 1;
}
}
Циклы while
могут иметь оборот else
, который исполняется тогда, когда
условие оказывается сразу ложным. Также у цикла while
может
присутствовать дополнительная инструкция, которая будет исполняться
в конце каждой итерации, она отделяется от условия двоеточием:
var i: usize = 0;
var escape_count: usize = 0;
while (i < src.len) : (i += 1) {
if (src[i] == '\\') {
// +1 тут, и +1 выше, итого +2
i += 1;
escape_count += 1;
}
}
В циклах while
и for
можно использовать break
, для досрочного
выхода из цикла и continue
, для досрочного перехода к следующей
итерации.
Если циклы вложены, то они могут быть помечены, а метки могут быть
использованы вместе с break
и continue
для указания того, какой
конкретно цикл имеется ввиду. Надуманный пример:
outer: for (1..10) |i| {
for (i..10) |j| {
if (i * j > (i+i + j+j)) continue :outer;
std.debug.print("{d} + {d} >= {d} * {d}\n", .{i+i, j+j, i, j});
}
}
У break
, помимо использования в циклах, есть ещё одно интересное
применение, возвращение значения из блока кода:
const personality_analysis = blk: {
if (tea_vote > coffee_vote) break :blk "sane";
if (tea_vote == coffee_vote) break :blk "whatever";
if (tea_vote < coffee_vote) break :blk "dangerous";
};
Блоки подобного рода нужно оканчивать точкой с запятой.
Позже, когда мы будем изучать маркированные объединения, объединения с ошибками и типы с необязательным значением, мы узнаем, какие ещё возможности предоставляют управляющие структуры.
#
Перечисления (enums)
Перечисление это набор целочисленных констант, каждой из которых дано имя. Определяются они примерно как структуры, то есть:
// непубличное перечисление, снаружи недоступно
const Status = enum {
ok,
bad,
unknown,
};
Так же, как и структуры, перечисления могут содержать другие определения, включая функции, которые в качестве аргумента могут использовать перечисление, в которое они входят (а могут и не использовать):
const Stage = enum {
validate,
awaiting_confirmation,
confirmed,
completed,
err,
fn isComplete(self: Stage) bool {
return self == .confirmed or self == .err;
}
};
Чтобы получить строковое представление для элементов перечисления, можно
использовать встроенную функцию @tagName
.
Вспомним, что при использовании записи вида .{...}
типы структур
выводятся исходя из типа переменной, которой присваивается анонимная
структура или из типа возвращаемого значения функции. В примере выше мы
видим, что при сравнениях тип перечисления тоже выводится, исходя из типа
параметра (Stage
). Можно написать явно, то есть return self == Stage.confirmed or self == Stage.err;
, но, как правило, тип опускается и
пишется только точка и значение.
То, что в операторе switch
нужно указывать все возможные варианты (ну,
если нет оборота else
), просто замечательно сочетается с перечислениями:
если мы что-то забыли, компилятор нам подскажет. И будьте осторожны,
если у вас в switch
есть оборот else
: если вы вдруг добавите новые
элементы в перечисление, то они все попадут в этот else
и, скорей
всего, программа не будет работать так, как вы задумывали.
#
Объединения (union)
Объединение задаёт набор типов, которые может иметь переменная. Например, экземпляры следующего объединения могут быть или целыми числами, или числами с плавающей точкой, или быть "не-числом" (NaN):
const std = @import("std");
pub fn main() void {
const n = Number{.int = 32};
std.debug.print("{d}\n", .{n.int});
}
const Number = union {
int: i64,
float: f64,
nan: void,
};
В каждый момент времени переменная-объединение может иметь только одно
установленное поле. Если мы попытаемся обратиться к неустановленному
полю, то это будет ошибкой. Поскольку в примере мы выставили поле int
,
то, если бы мы попытались использовать поле float
, мы получили бы
ошибку. Одно из наших полей (nan
) имеет тип void
. Что это значит? Как
такому полю присвоить значение? Используйте {}
const n = Number{.nan = {}};
#
Маркированные объединения
Большая проблема с объединениями - это определение того, какое именно поле выставлено (активировано) в данный момент. И тут на выручку к нам приходят маркированные объединения (tagged unions). Маркированное объединение это как бы гибрид перечисления и обычного объединения. Рассмотрим такой вот пример:
const std = @import("std");
pub fn main() void {
const ts = Timestamp{.unix = 1699689923};
std.debug.print("{d}\n", .{ts.seconds()});
}
const TimestampType = enum {
unix,
datetime,
};
const Timestamp = union(TimestampType) {
unix: i64,
datetime: DateTime,
const DateTime = struct {
year: u16,
month: u8,
day: u8,
hour: u8,
minute: u8,
second: u8,
};
fn seconds(self: Timestamp) u16 {
switch (self) {
.datetime => |dt| return dt.second,
.unix => |ts| {
const seconds_since_midnight: i64 = @rem(ts, 86400);
return @intCast(@rem(seconds_since_midnight, 60));
},
}
}
};
Обратите внимание, что в операторе switch
для каждого из двух вариантов
значение "захватывается" в некую как бы переменную, это делается при
помощи |val|
. В нашем примере dt
это Timestamp.DateTime
, а ts
это
i32
. В этом примере мы также впервые видим вложенное определение -
структура DateTime
определяется внутри объединения TimeStamp
. Но
ничто не мешает определить её и вне этого объединения. Также мы тут видим
две новых встроенных функции: @rem
для вычисления остатка от деления и
@intCast
для преобразования типа от i64
к u16
(это следует из типа
возвращаемого значения функции seconds
).
Как мы видим из приведённого примера, маркированные объединения можно использовать как своего рода интерфейсы при условии, что все возможные реализации известны заранее и поэтому могут быть включены в наше маркированное объединение.
Ну, и наконец, перечисление для маркированного объединения может быть выведено автоматически. Можно смело писать
const Timestamp = union(enum) {
unix: i32,
datetime: DateTime,
и тогда компилятор Zig создаст неявное перечисление, основываясь на именах полей этого объединения.
#
Необязательные значения (Optionals)
Любое значение может быть объявлено как необязательное, для этого нужно
перед типом написать знак вопроса. Переменные такого типа могут иметь
"значение" null
, то есть вовсе не иметь никакого значения или же они
могут иметь значение, соответствующее типу, который обозначен после знака
вопроса:
var home: ?[]const u8 = null;
var name: ?[]const u8 = "Leto";
Необходимость явно указывать тип явно тут вполне очевидна - если бы
написали просто const name = "Leto";
, то выведенный тип был бы []const u8
, то есть не являлся бы опциональным.
Для доступа к значениям также используется знак вопроса, но с точкой перед ним:
std.debug.print("{s}\n", .{name.?});
Однако, если окажется, что в name
на момент печати не будет иметь
значения, то в этом месте программа "панически" завершится. Чтобы этого
избежать, можно делать вот так:
if (home) |h| {
// h имеет тип []const u8, то есть мы как бы "развернули" опциональное значение
// и тут у нас есть реальное значение переменной home
} else {
// а тут у нас нет значения и мы можем как-то это обработать
}
Также для "разворачивания" необязательного значения можно использовать
бинарный оператор orelse
. Обычно он используется для задания значений
по умолчанию или для возврата из функции, например:
// если h == null, будет "unknown"
const h = home orelse "unknown"
// выход из функции если h == null
const h = home orelse return;
Однако, после orelse
можно использовать и произвольные блоки кода для
какой-то более сложной логики. Опциональные типы также тесно
интергированы с циклами while
, что часто используется для создания
итераторов. Здесь мы не будем реализовать какой-то полноценный итератор,
просто приведём примерчик, который, можно надеяться, имеет некий смысл:
while (rows.next()) |row| {
// делаем что-то с нашей строкой
}
#
Инициализатор undefined
До сих мы всегда инициализировали переменные (или поля структур) каким-то
более-менее разумным значением. Но иногда мы вообще не знаем, каким
должно быть начальное значение. Одним из возможных вариантов являются
только что изученные нами опциональные значения, но это тоже не всегда
имеет смысл. И что делать? Здесь нам поможет специальный инициализатор,
undefined
.
Одна из ситуаций, когда это используется - заполнение массива какой-то функцией:
var pseudo_uuid: [16]u8 = undefined;
std.crypto.random.bytes(&pseudo_uuid);
Тут у нас есть массив из 16-ти байт, но он заполнен, скорее всего, "мусором".
#
Ошибки и их обработка
Zig имеет простую и весьма прагматичную систему обработки ошибок. Прежде
всего, в Zig имеется специальный тип (error
), который по виду и по
поведению вполне аналогичен перечислениям:
// чтобы это определение было доступно "снаружи,
// нужно перед 'const' добавить 'pub'
const OpenError = error {
AccessDenied,
NotFound,
};
Теперь функции могут возвращать такие ошибки:
pub fn main() void {
return OpenError.AccessDenied;
}
const OpenError = error {
AccessDenied,
NotFound,
};
Попробуем запустить:
$ /opt/zig-0.11/zig run src/ex-ch02-03.zig
src/ex-ch02-03.zig:3:21: error: expected type 'void', found 'error{AccessDenied,NotFound}'
return OpenError.AccessDenied;
~~~~~~~~~^~~~~~~~~~~~~
src/ex-ch02-03.zig:2:15: note: function cannot return an error
pub fn main() void {
^~~~
Как видим, что-то с нашим кодом не так. Тип возвращаемого значения у
функции main
- void
, то есть она вроде как ничего не возвращает, но
по факту она возвращает ошибку. Надо подправить вот так:
pub fn main() OpenError!void {
return OpenError.AccessDenied;
}
Комбинация <error-set>!<return-type>
это такой составной тип,
объединение с ошибкой (error union type) и это означает, что наша main
может вернуть или ошибку или что-то (ну, в данном случае ничто,
void
). До этого момента мы были предельно явными: мы описали
специальный набор возможных ошибок и использовали его в возвращаемом
значении. Однако, когда дело касается ошибок, у Zig имеется пара классных
"трюков". Во-первых, вместо того, чтобы писать
<error-set>!<return-type>
, можно написать просто !<return-type>
и
тогда компилятор сам выведет набор ошибок, который функция может в
принципе вернуть. Так что можно писать так:
pub fn main() !void
Во-вторых, Zig умеет создавать неявные наборы ошибок, так что пример можно сократить вплоть до:
pub fn main() !void {
return error.AccessDenied;
}
На самом деле оба подхода (с явным указанием всего и без такового) не
вполне эквивалентны. Например, ссылки на функции с неявным набором ошибок
требуют специального типа, anyerror
. Разработчики библиотек, возможно,
предпочитают быть более явными, так код становится самодокументирующимся.
Так или иначе, оба подхода имеют право на существование и вы можете
использовать оба по своему усмотрению.
Реальная ценность объединений с ошибками проявляется при обработке
ошибок, для которой в Zig есть ключевые слова catch
и try
(но только
это совсем не такие try/catch/finally
, как в других языках
программирования). После вызова функции, которая может вернуть ошибку,
можно использовать оборот с catch
, например:
action(req, res) catch |err| {
if (err == error.BrokenPipe or err == error.ConnectionResetByPeer) {
return;
} else if (err == error.BodyTooBig) {
res.status = 431;
res.body = "Request body is too big";
} else {
res.status = 500;
res.body = "Internal Server Error";
// todo: log err
}
};
Более идиоматичным является вариант с оператором switch
:
action(req, res) catch |err| switch (err) {
error.BrokenPipe, error.ConnectionResetByPeer) => return,
error.BodyTooBig => {
res.status = 431;
res.body = "Request body is too big";
},
else => {
res.status = 500;
res.body = "Internal Server Error";
}
};
Всё это выглядит весьма витиевато, но, сказать по правде, чаще всего в
catch
мы просто "пробрасываем" ошибку "наверх" с тем, чтобы её
обработала вызывающая функция:
action(req, res) catch |err| return err;
Это настолько часто используется, поэтому в Zig есть сокращённая запись с
try
:
try action(req, res);
Это полезно в частности потому, что ошибки должны быть обработаны. По
большей части вы будете использовать для этого catch
или try
, но
объединения с ошибками также поддерживаются операторами if
и while
,
вполне аналогично тому, как они работают с опциональными значениями. В
случае while
, если условие сразу же даёт ошибку, исполняется оборот
else
.
Имеется также специальный тип anyerror
, в котором может содержаться
любая ошибка. Хоть мы и можем задать тип возвращаемого из функции
значения как в виде anyerror!<type>
, так и в виде !<type>
, эти две
формы не являются эквивалентными. Выведенный набор ошибок создаётся на
основе конкретных ошибок, возвращаемых функций, в то время как
anyerror
- это глобальный набор ошибок, множество всех ошибок,
фигурирующих в программе. Следовательно, использование anyerror
в
сигнатуре функции это как бы обозначение того, что ваша функция
номинально может вернуть такие ошибки, какие в реальности она вернуть не
может. Тип anyerror
используется для параметров функций или полей
структур, передаваемых в функцию, когда эта функция способна обрабатывать
любые возможные ошибки (ну, например, функция записи в журнал работы
программы)
Довольно часто используется комбинация объединения с ошибкой с опциональным типом. Если набор ошибок выводится, то это записывается вот так:
// загружаем последнюю сохранённую игру
pub fn loadLast() !?Save {
// TODO
return null;
}
Существуют различные способы обрабатывать вызов таких функций, но
наиболее компактный это сначала использовать try
, чтобы извлечь ошибку
и затем orelse
, чтобы развернуть необязательное значение, вот рабочий
скелет:
const std = @import("std");
pub fn main() void {
const save = (try Save.loadLast()) orelse Save.blank();
std.debug.print("{any}\n", .{save});
}
pub const Save = struct {
lives: u8,
level: u16,
pub fn loadLast() !?Save {
//todo
return null;
}
pub fn blank() Save {
return .{
.lives = 3,
.level = 1,
};
}
};
Хотя в Zig имеются некоторые особенности, которые предоставляют нам ещё более широкие возможности, то, что мы видели в первых двух главах, уже составляет значительную часть языка. И это будет служить нам базой при исследовании более сложных тем без отвлечения на синтаксис.