Вивчаємо Zig Українською

Ця частина продовжується там, де зупинилася попередня: огляд мови. Ми розглянемо потік керування виконанням програми 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 має більшу глибину, а деякі мовні функції мають більші можливості, те, що ми побачили в цих перших двох частинах, є значною частиною мови. Це слугуватиме основою, що дозволить нам досліджувати складніші теми, не надто відволікаючись на синтаксис.