Ця частина продовжується там, де зупинилася попередня: огляд мови. Ми розглянемо потік керування виконанням програми Zig і типи даних, про які ми ще не згадували. В першій частині ми охопили більше синтаксис мови, що дозволить нам зрозуміти краще мову та стандартну бібліотеку.
Керування Потоком Виконання
Оператори керування потоком у Zig, ймовірно, покажуться вам знайомими, але з додатковими синергізмами з деякими аспектами мови, які нам ще потрібно вивчити. Ми почнемо з короткого огляду та будемо повертатися по мірі того, як нам будуть зустрічатись особливості поведінки інструкціій керування.
Ви помітите, що замість логічних операторів &&
і ||
ми використовуємо and
та or
. Як і в більшості мов, «і» та «або» керують процесом виконання: вони замикаються. Права сторона “і” не оцінюється, якщо ліва сторона має значення “false”, а права частина “або” не оцінюється, якщо ліва сторона має значення “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
корисний у ряді випадків, його вичерпна природа (в сенсі “потрібно перелічити всі можливі варіанти”) справді сяє при роботі з змінними перелічувального типу (iterators), про які ми поговоримо незабаром.
Цикл 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
. Це тому, що регістрswitch
включає обидва числа, тоді якfor
не включає верхню межу.
Це особливо круто у поєднанні з однією (чи кількома!) послідовністю:
fn indexOf(haystack: []const u32, needle: u32) ?usize {
for (haystack, 0..) |value, i| {
if (needle == value) {
return i;
}
}
return null;
}
Це короткий огляд nullable типів.
Кінець діапазону визначається довжиною haystack
, хоча ми могли б ускладнити життя себі та написати: 0..hastack.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 або 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));
},
}
}
};
Зверніть увагу, що кожен регістр у нашому 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;
}
Це називається типом об’єднання помилок і вказує на те, що наша функція може повертати помилку 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
використовується для параметрів функції або полів структури, які можуть працювати з будь-якою помилкою (уявіть собі бібліотеку журналювання).
Нерідко функція повертає помилку необов’язкового типу union. Зі встановленою помилкою це виглядає так:
// завантажити останню збережену гру
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 має більшу глибину, а деякі мовні функції мають більші можливості, те, що ми побачили в цих перших двох частинах, є значною частиною мови. Це слугуватиме основою, що дозволить нам досліджувати складніші теми, не надто відволікаючись на синтаксис.