Ця частина продовжується там, де зупинилася попередня: огляд мови. Ми розглянемо керування потоком виконання програми Zig і типи даних, про які ми ще не згадували. Разом із першою частиною ми охопимо більшість синтаксису мови, що дасть нам змогу краще зрозуміти мову та стандартну бібліотеку.
Керування потоком виконання
Оператори керування потоком у Zig, імовірно, видаватимуться вам знайомими, але з додатковою синергією з деякими аспектами мови, які нам ще потрібно вивчити. Ми почнемо з короткого огляду й повертатимемося до нього в міру того, як зустрічатимемо особливості поведінки інструкцій керування.
Ви помітите, що замість логічних операторів && і || ми використовуємо and та or. Як і в більшості мов, «and» та «or» керують потоком виконання: вони мають коротке замикання. Права сторона and не обчислюється, якщо ліва дає false, а права частина or не обчислюється, якщо ліва дає true. У Zig керування потоком виконується за допомогою ключових слів, тому використовуються and та or.
Крім того, оператор порівняння == не працює для зрізів, таких як []const u8 (тобто для рядків). У більшості випадків ви використовуватимете std.mem.eql(u8, str1, str2), який порівнює довжину, а потім байти двох зрізів.
У Zig if, else if та else нічим особливим не відрізняються:
// 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— це тип, у цьому випадкуu8. Це перша узагальнена функція, яку ми побачили. Докладніше ми розглянемо це пізніше.
Наведений вище приклад порівнює рядки ASCII і, імовірно, тут не потрібно враховувати регістр. Тож кращим варіантом буде std.ascii.eqlIgnoreCase(str1, str2).
Тернарного оператора немає, але ви можете використовувати if/else так:
const super = if (power > 9000) true else false;
switch схожий на if/else if/else, але має перевагу в тому, що повинні бути перелічені всі можливі варіанти. Цей код не компілюється:
fn anniversaryName(years_married: u16) []const u8 {
switch (years_married) {
1 => return "папір",
2 => return "бавовна",
3 => return "шкіра",
4 => return "квітка",
5 => return "дерево",
6 => return "цукор",
}
}
Компілятор нам каже: switch must handle all possibilities. Оскільки наш years_married — це 16-бітне ціле число, чи означає це, що нам потрібно обробити всі 64 тисячі випадків? Так, але, на щастя, є 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 "вибачте, потрібно трохи почекати";
}
// todo, щось дуже не так
return "ніколи";
},
}
}
Хоч switch корисний у багатьох випадках, його вичерпна природа (у сенсі «потрібно перелічити всі можливі варіанти») по-справжньому сяє при роботі зі змінними перелічувального типу (enums), про які ми поговоримо незабаром.
Цикл for у Zig використовується для перебору масивів, зрізів і діапазонів. Наприклад, щоб перевірити, чи містить масив певне значення, ми можемо написати:
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 — це не просто хороша оптимізація швидкодії коду, а необхідний запобіжник. Якщо ми його приберемо та передамо аргументи різної довжини, ми отримаємо паніку під час виконання: for loop over objects with non-equal lengths.
for (0..10) |i| {
std.debug.print("{d}\n", .{i});
}
У нашому прикладі зі
switchвикористано три крапки,3...6, тоді як у цьому — дві крапки,0..10. Це тому, щоcaseуswitchвключає обидва кінці, тоді якforне включає верхню межу.
Це особливо круто у поєднанні з однією (чи кількома!) послідовністю:
fn indexOf(haystack: []const u32, needle: u32) ?usize {
for (haystack, 0..) |value, i| {
if (needle == value) {
return i;
}
}
return null;
}
Це короткий огляд опціональних типів.
Кінець діапазону визначається довжиною haystack, хоч ми могли б ускладнити собі життя і написати: 0..haystack.len. Цикли for не підтримують загальнішу ідіому init; compare; step. Для цього ми покладаємося на while.
Оскільки while простіший і має форму while (умова) { }, ми маємо більше контролю над ітерацією. Наприклад, підраховуючи кількість керуючих послідовностей у рядку, нам потрібно збільшувати наш ітератор на 2, щоб уникнути подвійного підрахунку \:
var escape_count: usize = 0;
{
var i: usize = 0;
while (i < src.len) {
// зворотний слеш використовується як символ екранування, тому нам потрібно його екранувати...
// зворотнім слешем
if (src[i] == '\\') {
i += 2;
escape_count += 1;
} else {
i += 1;
}
}
}
Ми додали явний блок навколо нашої тимчасової змінної i та циклу while. Це звужує область видимості i. Такі блоки можуть бути корисними, хоч у цьому випадку це, мабуть, надмірно. Та все ж наведений вище приклад максимально близький до традиційного циклу for (init; compare; step), який є в Zig.
while може мати клаузу else, яка виконується, коли умова хибна. Він також приймає оператор для виконання після кожної ітерації. Може бути кілька операторів, розділених ;. Ця можливість зазвичай використовувалася до того, як for отримав підтримку кількох послідовностей. Наведене вище можна записати так:
var i: usize = 0;
var escape_count: usize = 0;
// ця частина
while (i < src.len) : (i += 1) {
if (src[i] == '\\') {
// +1 here, and +1 above == +2
i += 1;
escape_count += 1;
}
}
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 "в здоровому глузді";
if (tea_vote == coffee_vote) break :blk "ніякий";
if (tea_vote < coffee_vote) break :blk "небезпечний";
};
Такі блоки мають закінчуватися крапкою з комою.
Пізніше, коли ми досліджуватимемо об’єднання з тегами, об’єднання помилок та опціональні типи, ми побачимо, що ще можуть запропонувати ці структури потоку керування.
Переліки (Enums)
Переліки — це набір цілочисельних констант, кожній з яких надано ім’я. Вони визначаються подібно до структур:
// має бути публічним "pub"
const Status = enum {
ok,
bad,
unknown,
};
І, як і структура, може містити інші визначення, зокрема функції, які можуть приймати перелік як параметр, а можуть і не приймати:
const Stage = enum {
validate,
awaiting_confirmation,
confirmed,
err,
fn isComplete(self: Stage) bool {
return self == .confirmed or self == .err;
}
};
Якщо вам потрібно рядкове представлення переліку, ви можете скористатися вбудованою функцією
@tagName(enum).
Типи структур можна вивести на основі присвоюваного або поверненого типу за допомогою нотації .{...}. Вище ми бачимо, що тип enum виводиться на основі його порівняння з self, який має тип Stage. Ми могли б бути явними і написати: return self == Stage.confirmed or self == Stage.err;. Але, маючи справу з переліками, ви часто побачите, що тип переліку опускається через нотацію .$value. Це називається літералом переліку.
Вичерпна природа switch робить його гарним поєднанням з переліком (enum), бо вона гарантує, що ви обробили всі можливі випадки. Проте будьте обережні з гілкою else у switch: вона відповідатиме будь-яким нещодавно доданим значенням переліку, що може не відповідати бажаній поведінці.
Об’єднання з тегами (Tagged Unions)
Об’єднання визначає набір типів, які може мати значення. Наприклад, об’єднання Number може бути integer, float або nan (не число):
learning.zig
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, якщо ми потім спробуємо отримати доступ до n.float, ми отримаємо помилку. Одне з наших полів, nan, має тип void. Як же встановити його значення? Використовуйте {}:
const n = Number{.nan = {}};
Проблема з обʼєднаннями полягає в тому, щоб знати, яке поле встановлено. Ось тут і вступають у гру об’єднання з тегами. Об’єднання з тегами (tagged union) поєднує перелік (enum) із об’єднанням (union) і його можна використовувати в операторі switch. Розгляньмо такий приклад:
learning.zig
pub fn main() void {
const ts = Timestamp{.unix = 1693278411};
std.debug.print("{d}\n", .{ts.seconds()});
}
const TimestampType = enum {
unix,
datetime,
};
const Timestamp = union(TimestampType) {
unix: i32,
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: i32 = @rem(ts, 86400);
return @intCast(@rem(seconds_since_midnight, 60));
},
}
}
};
Зверніть увагу, що кожен case у нашому switch захоплює типізоване значення поля. Тобто dt — це Timestamp.DateTime, а ts — це i32. Це також перший раз, коли ми бачимо структуру, вкладену в інший тип. DateTime можна було б визначити і поза об’єднанням. Ми також бачимо дві нові вбудовані функції: @rem для отримання залишку та @intCast для перетворення результату на u16 (@intCast виводить, що ми хочемо u16, з нашого типу, що повертається).
Як ми бачимо з наведеного вище прикладу, об’єднання з тегами можна використовувати як інтерфейси, за умови, що всі можливі реалізації відомі заздалегідь і можуть бути вшиті в об’єднання з тегами.
Нарешті, тип переліку об’єднання з тегами можна вивести. Замість того, щоб визначати TimestampType, ми могли б зробити так:
const Timestamp = union(enum) {
unix: i32,
datetime: DateTime,
...
і Zig створив би неявний перелік на основі полів нашого об’єднання.
Опціональне значення (Optional)
Будь-яке значення можна оголосити опціональним, додавши перед типом знак питання ?. Опціональні типи можуть мати значення null або значення визначеного типу:
var home: ?[]const u8 = null;
var name: ?[]const u8 = "Leto";
Необхідність явного типу має бути зрозумілою: якби ми просто написали const name = "Leto";, виведений тип не був би опціональним []const u8.
.? використовується для доступу до значення за опціональним типом:
std.debug.print("{s}\n", .{name.?});
Але ми отримаємо паніку під час виконання, якщо використаємо .? для null. Оператор if може безпечно розгорнути опціональний тип:
if (home) |h| {
// h — це []const u8. тобто ми "розгорнули" опціональне значення
// і тут ми маємо реальне значення змінної home
} else {
// а тут у нас значення немає, і ми можемо це опрацювати
}
orelse можна використовувати для розгортання опціонального значення або для виконання коду. Це зазвичай використовують для значення за замовчуванням або повернення з функції:
const h = home orelse "unknown"
// або може бути
// вийти з нашої функції
const h = home orelse return;
Однак orelse також може мати блок і виконувати складнішу логіку. Опціональні типи також інтегруються з while і часто використовуються для створення ітераторів. Ми не будемо створювати ітератор, але, сподіваюся, цей фіктивний код має сенс:
while (rows.next()) |row| {
// зробити щось з нашим рядком
}
Неініціалізоване значення (Undefined)
Поки що кожна змінна, яку ми бачили, була ініціалізована якимсь значенням. Але іноді ми не знаємо значення змінної на момент її оголошення. Опціональні типи (optionals) — один із варіантів, але це не завжди має сенс. У таких випадках ми можемо встановити змінним значення undefined, щоб залишити їх неініціалізованими.
Одне місце, де це зазвичай робиться, — це створення масиву, який буде заповнений певною функцією:
var pseudo_uuid: [16]u8 = undefined;
std.crypto.random.bytes(&pseudo_uuid);
Наведене вище все одно створює масив із 16 байтів, але залишає пам’ять неініціалізованою.
Помилки та їх опрацювання
Zig має прості та практичні можливості обробки помилок. Усе починається з наборів помилок, які виглядають і поводяться як enum:
// Як і наша структура в частині 1, OpenError можна позначити як "pub"
// щоб зробити його доступним поза файлом, у якому він визначений
const OpenError = error {
AccessDenied,
NotFound,
};
Функції, включно з main, тепер можуть повертати цю помилку:
learning.zig
pub fn main() void {
return OpenError.AccessDenied;
}
const OpenError = error {
AccessDenied,
NotFound,
};
Якщо ви спробуєте запустити це, то отримаєте повідомлення про помилку: expected type ‘void’, found ‘error{AccessDenied,NotFound}’. Це має сенс: ми визначили main з типом повернення void, але ми щось повертаємо (помилку, звісно, але це вже не void). Щоб вирішити цю проблему, нам потрібно змінити тип, що повертається з функції.
pub fn main() OpenError!void {
return OpenError.AccessDenied;
}
Це називається типом об’єднання помилок (error union) і вказує на те, що наша функція може повертати або помилку OpenError, або void. Поки що ми були досить явними: ми створили набір помилок для можливих помилок, які може повертати наша функція, і використали цей набір у типі повернення нашої функції як об’єднання помилок. Але коли справа доходить до реальних проєктів, Zig має кілька хитрих прийомів у рукаві. По-перше, замість указувати об’єднання помилок як error set!return type, ми можемо дозволити Zig вивести набір помилок за допомогою: !return type. Тож ми могли б, і, ймовірно, визначили б наш main так:
pub fn main() !void
По-друге, Zig здатний неявно створювати для нас набори помилок. Замість створення нашого набору помилок ми могли б зробити:
pub fn main() !void {
return error.AccessDenied;
}
Наші повністю явні та неявні підходи не зовсім еквівалентні. Наприклад, посилання на функції з неявними наборами помилок вимагають використання спеціального типу anyerror. Розробники бібліотек можуть побачити переваги в більшій явності, як-от самодокументований код. Та все ж я вважаю, що і неявні набори помилок, і об’єднання помилок із виведеним типом є прагматичними; я активно використовую обидва підходи.
Справжня цінність об’єднань помилок — у вбудованій підтримці на рівні мови у формі catch і try. Виклик функції, яка повертає об’єднання помилок, може включати catch. Наприклад, бібліотека http-сервера може мати такий код:
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;
Це настільки поширене, що саме це й робить try. Замість наведеного вище ми робимо:
try action(req, res);
Це особливо корисно з огляду на те, що помилка має бути оброблена. Найімовірніше, ви оброблятимете її за допомогою try або catch.
Розробники Go помітять, що
tryпотребує менше натискань клавіш, ніжif err != nil { return err }.
Здебільшого ви використовуватимете try і catch, але об’єднання помилок також підтримуються в if і while, подібно до опціональних типів. У випадку while, якщо умова повертає помилку, виконується клауза else.
Існує спеціальний тип anyerror, який може містити будь-яку помилку. Хоч ми могли б визначити функцію як таку, що повертає anyerror!TYPE, а не !TYPE, ці дві функції не еквівалентні. Виведений набір помилок створюється на основі того, що може повернути функція. anyerror — це глобальний набір помилок, надмножина всіх наборів помилок у програмі. Тому використання anyerror у сигнатурі функції, ймовірно, означатиме, що ваша функція може повертати помилки, які насправді не повертатиме. anyerror використовується для параметрів функції або полів структур, які можуть працювати з будь-якою помилкою (уявіть собі бібліотеку логування).
Нерідко функція повертає об’єднання помилки з опціональним типом. Із встановленим набором помилок це виглядає так:
// завантажити останню збережену гру
pub fn loadLast() !?Save {
// TODO
return null;
}
Існують різні способи використання таких функцій, але найкомпактніший — використати try, щоб розгорнути нашу помилку, а потім orelse, щоб розгорнути опціональний тип. Ось робочий скелет:
learning.zig
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 має більшу глибину, а деякі мовні можливості — більший простір для застосування, те, що ми побачили в цих перших двох частинах, є значною частиною мови. Це слугуватиме основою, яка дасть нам змогу досліджувати складніші теми, не надто відволікаючись на синтаксис.