# Глава 6. Обработка ошибок

Ошибки случаются. Это факт жизни (и программирования). Но не бойтесь, дорогой ученик, потому что Zig вас не подведёт. В этой главе рассматривается одна из центральных тем Zig: обработка ошибок, которая заставит вас задуматься, почему другие языки до сих пор не переняли этот подход. Приготовьтесь удивиться (и, возможно, немного раздражаться) тому, как уверенно Zig говорит: «Я же говорил».

В этой главе мы рассмотрим следующие темы:

  • философия обработки ошибок в Zig;
  • объединения (unions);
  • ошибки: превращение сбоев в управляемые события;
  • трассировки возврата ошибок: хлебные крошки на случай, если всё пойдёт не так.

Представьте: вы глубоко в процессе кодирования, всё идёт гладко, пока... БАМ! Начинают появляться ошибки. Ошибки — как незваные гости, которые нарушают гармонию вашей программы. В Zig ошибки — это не прерывания, это неотъемлемая часть языка, предназначенная для повышения устойчивости вашего кода.

# Технические требования

Весь код, показанный в этой главе, можно найти в каталоге Chapter06 нашего репозитория Git: https://github.com/PacktPublishing/Learning-Zig/tree/main/Chapter06.

# Философия обработки ошибок в Zig

В большинстве языков программирования ошибки — как нежеланные гости: они прерывают поток выполнения вашей программы без предупреждения. Вы выбрасываете исключение или возвращаете общий код ошибки и надеетесь, что кто-то вспомнит прибрать за собой. Но в Zig нельзя спрятаться за исключениями или скрытыми сигналами. Ошибки полностью интегрированы в систему типов, прямо в сигнатуре функции, практически крича: «Обработай меня!» Всё дело в прозрачности: ошибки получают место за столом как полноправные граждане.

В Zig, если функция может завершиться неудачей, она обязана возвращать значение ошибки. Никакой двусмысленности «может сработает, может нет» — Zig гарантирует, что вы точно знаете, на что идёте, заставляя ошибки быть частью возвращаемого типа. Таким образом, каждая потенциальная ошибка неизбежна, она на виду, в возвращаемом типе функции. Это способ Zig сказать: «Притворяться, что ошибок не существует? Не в мою смену».

# Что такое ошибки в понимании Zig?

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

Прежде чем погрузиться в захватывающую бездну обработки ошибок в Zig, необходимо, а для кого-то и милосердно, познакомиться с несколькими основополагающими концепциями: перечислениями (enums), типами-объединениями (union types) и размеченными объединениями (tagged unions). Знаю, о чём вы думаете: «Перечисления? Размеченные что?». Потерпите.

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

# Перечисления: когда магические числа портят вам жизнь

Давайте признаем: разбрасывание магических чисел по всей кодовой базе — отличный способ обеспечить, чтобы вы в будущем возненавидели самого себя. На помощь приходят перечисления — конструкция языка, позволяющая присваивать человеко-читаемые имена наборам связанных значений. Zig, в своей бесконечной мудрости, предоставляет перечисления, чтобы спасти вас от собственных вредных привычек.

Создание перечисления в Zig максимально просто, если вы способны перечислить элементы без синтаксической ошибки. Вот как можно объявить перечисление, представляющее дни недели:

const Day = enum {
    Monday,
    Tuesday,
    Wednesday,
    Thursday,
    Friday,
    Saturday,
    Sunday,
};

Отлично. Вы только что создали перечисление под названием Day, представляющее дни недели. Чтобы использовать ваше новое перечисление, можно присваивать значения переменным. Например, давайте проверим, сегодня ли среда (постарайтесь сдержать восторг):

const std = @import("std");

pub fn main() void {
    const today = Day.Wednesday;
    if (today == Day.Wednesday) {
        std.debug.print("Guess what? It's hump day!\n", .{});
    }
}

Под капотом перечисления в Zig имеют целочисленные представления, начиная с нуля. Если вам обязательно нужно знать целое значение, возможно, из ностальгии, вы можете получить его с помощью встроенной функции @intFromEnum. Вот как можно вывести целое значение дня:

const std = @import("std");

pub fn main() void {
    const today = Day.Wednesday;
    const dayNumber = @intFromEnum(today);
    std.debug.print("It's day number {d} of the week.\n", .{dayNumber});
}

Это сообщит вам, что среда — это день номер 2.

Если вам нравится создавать головоломки для других (или для себя), вы можете присваивать членам перечисления собственные целые значения. Например, давайте определим коды состояния HTTP:

const HttpStatus = enum(i32) {
    OK = 200,
    NotFound = 404,
    InternalServerError = 500,
};

Теперь, когда кто-то читает ваш код, ему не нужно помнить, что HttpStatus.OK — это на самом деле 200. Очень предусмотрительно. Вот как вы можете это использовать:

const std = @import("std");

pub fn main() void {
    const status = HttpStatus.NotFound;
    const code = @intFromEnum(status);
    std.debug.print("HTTP Status Code: {d}\n", .{code});
}

Верите или нет, но у перечислений в Zig могут быть методы. Да, методы у перечислений, потому что теперь это так работает. Давайте определим перечисление светофора с методом:

const std = @import("std");

const TrafficLight = enum {
    Red,
    Yellow,
    Green,

    pub fn isSafeToGo(self: TrafficLight) bool {
        return self == TrafficLight.Green;
    }
};

Теперь вы можете вызывать isSafeToGo у значения TrafficLight:

pub fn main() void {
    const light = TrafficLight.Red;
    if (light.isSafeToGo()) {
        std.debug.print("Go ahead.\n", .{});
    } else {
        std.debug.print("Stop right there!\n", .{});
    }
}

# Оператор switch: когда нужен ещё больший контроль потока

Перечисления и операторы switch сочетаются так же, как баги и ПО. Вот как можно использовать switch с перечислением:

const std = @import("std");

pub fn main() void {
    const day = Day.Friday;

	const message = switch (day) {
        Day.Monday => "Ugh, back to work.",
        Day.Friday => "TGIF!",
        Day.Saturday, Day.Sunday => "Weekend vibes.",
        else => "Just another day.",
    };

    std.debug.print("{s}\n", .{message});
}

Этот код выведет «TGIF!»1, потому что, видимо, люди всё ещё так говорят.

# Литералы перечислений: экономия нескольких микросекунд набора

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

const std = @import("std");

pub fn main() void {
    const day: Day = .Saturday;
    if (day == .Saturday) {
        std.debug.print("Enjoy your weekend!\n", .{});
    }
}

Посмотрите — вы сэкономили себе от набора Day дважды. Используйте это время с умом.

Примечание: без явного указания типа это стало литералом перечисления. В этом примере это стал член Day. Другими словами, const day: Day = .Saturday; — это член Day, а const anotherDay = .Friday; — просто литерал перечисления.

# Неисчерпывающие перечисления: оставляйте себе пространство для манёвра

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

Рассмотрим такой сценарий:

// Код библиотеки — версия 1
const ErrorCode = enum {
    Success,
    Failure,
};

// Ваш код приложения
pub fn handleError(code: ErrorCode) void {
    switch (code) {
        .Success => std.debug.print("Operation succeeded.\n", .{}),
        .Failure => std.debug.print("Operation failed.\n", .{}),
    	// Случай по умолчанию не нужен и не допускается — Zig знает, что все случаи покрыты
    }
}

// Позже библиотека обновляется до версии 2
const ErrorCode = enum {
    Success,
    Failure,
    NetworkTimeout, // Новое значение добавлено!
};

Теперь ваш код приложения перестанет компилироваться! Оператор switch больше не является исчерпывающим, потому что не обрабатывает новый случай NetworkTimeout.

Zig решает эту проблему с помощью неисчерпывающих перечислений, включая специальное поле _ и указывая числовой тип тега с запасом для роста:

const std = @import("std");

const ErrorCode = enum(u16) {
    Success = 0,
    Failure = 1,
    _, // Этот символ подчёркивания критически важен — он сообщает Zig: «могут быть и другие значения»
};

Когда вы объявляете перечисление как неисчерпывающее:

  • Вы должны указать тип тега (например, u16), который может содержать больше значений, чем вы определили.
  • Компилятор знает, что могут появиться значения за пределами ваших явных объявлений.
  • В операторах switch вы ОБЯЗАНЫ включать универсальный (catch-all) случай.

Вот как правильно обрабатывать неисчерпывающее перечисление:

pub fn main() void {
    const code: ErrorCode = .Success;
    switch (code) {
        .Success => std.debug.print("Operation succeeded.\n", .{}),
        .Failure => std.debug.print("Operation failed.\n", .{}),
        _ => std.debug.print("Unknown error code.\n", .{}), // Обязательно для неисчерпывающих перечислений!
    }
}

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

# Рефлексия: когда хочется поиграть в детектива в собственном коде

Zig предоставляет встроенные функции, такие как @typeInfo и @tagName, чтобы вы могли инспектировать перечисления во время выполнения.

Например, вот как можно получить имя значения перечисления:

const std = @import("std");

pub fn main() void {
    const day = Day.Tuesday;
    const dayName = @tagName(day);
    std.debug.print("Today is {s}.\n", .{dayName});
}

Это выведет: «Today is Tuesday.» Потому что иногда забываешь, какой сегодня день.

# Информация о типе перечисления: погружаемся глубже, если осмелитесь

Если у вас есть неутолимая потребность копаться в деталях, вы можете использовать @typeInfo, чтобы исследовать структуру вашего перечисления. Вот как:

pub fn main() void {
    const info = @typeInfo(Day);
    const enumInfo = info.@"enum";
    std.debug.print("Enum tag type: {s}\n", .{@typeName(enumInfo.tag_type)});
    inline for (enumInfo.fields) |field| {
        std.debug.print("Field: {s}\n", .{field.name});
    }
}

Это выведет тип тега перечисления и список всех его полей. Полезно, когда нужно написать код, адаптирующийся к структуре перечисления — ведь хардкод уже не в моде.

Теперь, когда мы смело покорили перечисления и пережили захватывающую встречу с восклицательным знаком !, пора погрузиться в мутные воды объединений. Не пугайтесь терминологии: объединения — это просто ещё один инструмент в арсенале Zig, который может сделать ваш код либо более элегантным, либо более запутанным, в зависимости от того, как вы им владеете. Мы не спеша прогуляемся по объединениям, размеченным объединениям и увидим, как они могут быть и благословением, и проклятием. Вскоре вы, возможно, даже начнёте использовать их намеренно.

# Объединения

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

Вот как можно определить объединение:

const Data = union {
    intValue: i32,
    floatValue: f64,
    textValue: []const u8,
};

В этом объединении Data может хранить i32, f64 или срез строки ([]const u8), но только один из них в любой момент времени.

Посмотрим, как можно использовать объединение в функции main. Предположим, вы пишете программу, которая обрабатывает разные виды входных значений:

const std = @import("std");

pub fn main() void {
    var inputData = Data{ .intValue = 42 };
    std.debug.print("Integer value: {d}\n", .{inputData.intValue});

    // Меняем объединение, чтобы хранить число с плавающей точкой
    inputData = Data{ .floatValue = 3.14 };
    std.debug.print("Floating-point value: {d}\n", .{inputData.floatValue});

    // Теперь меняем на строку
    inputData = Data{ .textValue = "Hello, Zig!" };
    std.debug.print("Text value: {s}\n", .{inputData.textValue});
}

В этом примере мы сначала присваиваем переменной inputData целое число. Затем мы переназначаем inputData, чтобы она хранила число с плавающей точкой, и, наконец, присваиваем ей текстовое значение. Каждый раз мы создаём новый экземпляр Data с нужным полем.

Важно помнить, что в «голом» объединении может быть активно только одно поле одновременно. Если попытаться обратиться к неактивному полю, Zig вызовет ошибку времени выполнения.

const std = @import("std");

pub fn main() void {
    var data = Data{ .intValue = 100 };

    // Неправильное обращение к неактивному полю
    std.debug.print("Floating-point value: {d}\n", .{data.floatValue});
}

Запуск этого кода приведёт к панике с сообщением вроде: access of union field 'floatValue' while field 'intValue' is active. Чтобы этого избежать, всегда следите, что вы обращаетесь только к активному полю.

Чтобы изменить активное поле объединения, нужно присвоить переменной новое значение. Вот как это делается:

data = Data{ .floatValue = 2.718 };

Это заново инициализирует data с активным полем floatValue. Теперь к нему можно безопасно обращаться .floatValue.

Объединения полезны, но они не отслеживают, какое поле сейчас активно. Для этого существуют маркированные объединения.

# Маркированные объединения: добавление тега для безопасности

Маркированное объединение сочетает в себе объединение и перечисление (тег), которое отслеживает, какое поле активно в данный момент. Это добавляет уровень безопасности, так как вы можете проверить тег перед обращением к полю.

Вот как можно определить маркированное объединение:

const Result = union(enum) {
    Success: i32,
    Error: []const u8,
};

В этом типе Result есть две возможности: Success со значением i32 или Error с сообщением об ошибке.

Давайте используем тип Result в функции, которая выполняет вычисление и может завершиться с ошибкой:

const std = @import("std");

fn calculate(input: i32) Result {
    if (input >= 0) {
        // Возвращаем Success с вычисленным значением
        return Result{ .Success = input * 2 };
    } else {
        // Возвращаем Error с сообщением об ошибке
        return Result{ .Error = "Input must be non-negative" };
    }
}

pub fn main() void {
    const res = calculate(-5);
    // Используем switch для обработки каждого возможного случая
    switch (res) {
        .Success => |value| std.debug.print("Calculation result: {d}\n", .{value}),
        .Error => |errMsg| std.debug.print("Error: {s}\n", .{errMsg}),
    }
}

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

Иногда может потребоваться изменить данные внутри маркированного объединения. Это можно сделать, получив изменяемую ссылку на полезную нагрузку внутри оператора switch. Вот как:

pub fn main() void {
    var res = calculate(10);

    // Изменяем полезную нагрузку, если это Success
    switch (res) {
        .Success => |*value| value.* += 5, // Увеличиваем значение на 5
        .Error => |errMsg| std.debug.print("Error: {s}\n", .{errMsg}),
    }

    // Проверяем обновлённый результат
    switch (res) {
        .Success => |value| std.debug.print("Updated result: {d}\n", .{value}),
        .Error => |errMsg| std.debug.print("Error: {s}\n", .{errMsg}),
    }
}

В этом коде мы сначала вызываем calculate(10), что должно завершиться успешно. В операторе switch мы используем |*value|, чтобы получить изменяемый указатель на полезную нагрузку, что позволяет нам её изменить. Затем мы выводим обновлённый результат.

Как и структуры и перечисления, объединения в Zig могут иметь методы. Это помогает инкапсулировать поведение, связанное с объединением.

Вот пример:

const std = @import("std");

const Response = union(enum) {
    Data: i32,
    Message: []const u8,

    pub fn isData(self: Response) bool {
        return @tagName(self) == "Data";
    }
};

pub fn main() void {
    const resp = Response{ .Data = 100 };

    if (resp.isData()) {
        std.debug.print("Received data: {d}\n", .{resp.Data});
    } else {
        std.debug.print("Received message: {s}\n", .{resp.Message});
    }
}

В этом примере мы определяем объединение Response с методом isData, который проверяет, является ли активное поле Data. В main мы используем этот метод, чтобы решить, как обрабатывать resp.

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

Вот как их можно использовать:

const std = @import("std");

const Number = union {
    intValue: i32,
    floatValue: f64,
};

pub fn main() void {
    // Создаём Number с целочисленным значением с помощью анонимного литерала объединения
    const num = .{ .intValue = 256 };
    std.debug.print("Integer number: {d}\n", .{num.intValue});

    // Создаём Number с числом с плавающей точкой
    const numFloat = .{ .floatValue = 1.618 };
    std.debug.print("Floating-point number: {f}\n", .{numFloat.floatValue});
}

В этом коде мы создаём экземпляры Number, не указывая тип явно, полагаясь на вывод типов Zig.

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

Продолжая наше путешествие по системе типов Zig, настало время столкнуться с неизбежным: ошибками.

# Ошибки: превращение сбоев в управляемые события

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

# Наборы ошибок: перечисление возможных неприятностей

В Zig набор ошибок (error set) похож на enum, но предназначен специально для ошибок. Он позволяет определить набор возможных ошибок, которые может вернуть функция, делая поведение вашего кода более предсказуемым и простым в обработке.

Например, давайте определим простой набор ошибок для сетевых операций:

const NetworkError = error {
    ConnectionLost,
    Timeout,
    InvalidResponse,
};

Здесь NetworkError — это набор ошибок, содержащий конкретные ошибки, связанные с сетевым взаимодействием. Таким образом, вы можете точно указать, что может пойти не так при работе с сетевыми операциями.

Чтобы увидеть наборы ошибок в действии, давайте напишем функцию, которая пытается получить данные из сетевого ресурса и возвращает ошибку в случае сбоя:

const std = @import("std");

fn fetchData(url: []const u8) ![]const u8 {
    // Заглушка логики для получения данных
    if (url.len == 0) {
        return error.InvalidResponse;
    }

    // Представьте здесь успешное получение данных
    return "Sample Data";
}

Возвращаемый тип ![]u8 указывает, что fetchData может вернуть либо ошибку из своего набора ошибок, либо массив байтов ([]u8) в случае успеха. Указывая возможные ошибки, вы ясно сообщаете всем, кто использует вашу функцию, что может пойти не так.

# Обработка ошибок с помощью try и catch

Для обработки ошибок Zig предоставляет выражения try и catch. Ключевое слово try пытается выполнить функцию, которая может завершиться с ошибкой, и если это происходит, ошибка возвращается из текущей функции.

Вот как можно использовать fetchData:

pub fn main() !void {
    const data = try fetchData("https://example.com");

    // Продолжаем выполнение программы, если данные успешно получены
    std.debug.print("Data received: {s}\n", .{data});
}

Если fetchData возвращает ошибку, она будет передана вверх в main, и программа обработает её соответствующим образом.

В качестве альтернативы можно использовать catch для обработки ошибок непосредственно в месте вызова:

pub fn main() void {
    _ = fetchData("https://example.com") catch |err| {
        std.debug.print("Failed to fetch data: {s}\n", .{@errorName(err)});
    };
}

В этом примере, если fetchData завершается с ошибкой, выполняется блок catch, что позволяет корректно обработать сбой.

# Объединения ошибок: комбинирование ошибок со значениями

Иногда может потребоваться, чтобы функция возвращала либо значение, либо ошибку. Для этого и существуют объединения ошибок. Объединение ошибок комбинирует набор ошибок с возвращаемым типом, указывая, что функция может вернуть либо значение, либо ошибку.

Давайте напишем функцию, которая преобразует строку в число с плавающей точкой:

const std = @import("std");

const ParseError = error {
    InvalidFormat,
    Overflow,
};

fn parseFloat(input: []const u8) ParseError!f64 {
    const result = std.fmt.parseFloat(f64, input) catch |err| {
        return switch (err) {
            std.fmt.ParseFloatError.Overflow => error.Overflow,
            else => error.InvalidFormat,
        };
    };
    return result;
}

В этой функции parseFloat возвращает объединение ошибок под названием ParseError!f64, что означает: она может вернуть значение типа f64 или ошибку из своего набора ошибок.

# Обработка объединений ошибок

Когда вы вызываете функцию, возвращающую объединение ошибок, вам нужно обработать оба возможных варианта. Вот как можно использовать parseFloat:

const std = @import("std");

pub fn main() void {
    const result = std.fmt.parseFloat(f64, "3.1415") catch |err| {
        std.debug.print("Failed to parse float: {s}\n", .{@errorName(err)});
        return;
    };
    std.debug.print("Parsed float: {}\n", .{result});
}

В этом примере, если parseFloat завершается успешно, мы выводим распознанное число с плавающей точкой. Если происходит ошибка, мы обрабатываем её в блоке catch.

# Сведение наборов ошибок

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

Вот как это можно сделать:

const GeneralError = error {
    NotFound,
    PermissionDenied,
    DiskFull,
    ConnectionLost,
};

const NetworkError = error {
    ConnectionLost,
};

fn connectToServer() NetworkError!void {
    // Имитация сетевой ошибки
    return error.ConnectionLost;
}

fn performTask() GeneralError!void {
    try connectToServer(); // NetworkError приводится к GeneralError
    // Дополнительная логика...
}

В performTask мы можем использовать try с connectToServer, потому что NetworkError может быть приведён к GeneralError.

Иногда может потребоваться предоставить значение по умолчанию, если функция возвращает ошибку. Для этого можно использовать catch:

const value = std.fmt.parseFloat(f64, "not a number") catch 0.0;
std.debug.print("Value: {}\n", .{value});

В этом примере, если parseFloat завершается с ошибкой, значением будет 0.0.

Если вы абсолютно уверены, что функция не вернёт ошибку — и готовы поставить стабильность своей программы на эту уверенность — вы можете игнорировать ошибку с помощью catch unreachable:

const number = std.fmt.parseFloat(f64, "42.0") catch unreachable;
// Продолжаем уверенно (или безрассудно)

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

# Оператор errdefer: уборка после ошибок

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

Рассмотрим функцию, которая выделяет память и должна освободить её при ошибке:

const std = @import("std");

fn allocateResource(allocator: *std.mem.Allocator) !*u8 {
    const buffer = try allocator.alloc(u8, 1024);
    errdefer allocator.free(buffer);

    // Выполняем операции, которые могут завершиться с ошибкой
    performOperation(buffer) catch |err| {
        // Буфер будет автоматически освобождён, если произойдёт ошибка
        return err;
    };

    return buffer;
}

В этом примере errdefer гарантирует, что allocator.free(buffer) будет вызван, если до успешного завершения функции произойдёт ошибка.

# Почему Zig приветствует ошибки

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

# Почему важны наборы ошибок?

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

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

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

# Трассировка возврата ошибок: хлебные крошки на случай, когда всё идёт не так

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

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

# Анатомия трассировки ошибок

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

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

pub fn main() !void {
    try topFunction();
}

fn topFunction() !void {
    try midFunction();
}

fn midFunction() !void {
    try bottomFunction();
}

fn bottomFunction() !void {
    return error.FileNotFound;
}

Когда bottomFunction завершается с ошибкой error.FileNotFound, ошибка не просто всплывает в main и ожидает, что вы догадаетесь, как она туда попала. Благодаря трассировкам возврата ошибок вы получаете карту того, как именно ошибка переместилась из bottomFunction вверх через midFunction и topFunction, наконец оказавшись в main. Каждый шаг записывается, так что вам не придётся играть в «угадай происхождение», когда что-то идёт не так.

# Трассировки ошибок лучше, чем трассировки стека

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

С трассировкой вы получаете повествование: «Функция A вызвала функцию B, которая вызвала функцию C, и C решила уронить мяч с ошибкой». Вместо того чтобы исправлять симптомы, вы можете устранить первопричины, потому что видите каждую остановку, которую сделала ошибка на своём пути.

Рассмотрим практический пример. Вы запускаете свой код на Zig, возникает ошибка, и трассировка ошибки сообщает вам следующее:

Error: FileNotFound
in function bottomFunction
  called by midFunction
  called by topFunction
  called by main

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

# Включение и использование трассировки ошибок

По умолчанию Zig включает трассировку ошибок в сборках Debug и ReleaseSafe, где они наиболее полезны. В производственных режимах, таких как ReleaseFast и ReleaseSmall, трассировки возврата отключены для экономии производительности. Но во время разработки трассировки ошибок — ваш лучший друг, позволяющий быстро находить и устранять баги.

Не беспокойтесь о сборках и производственных режимах сейчас. Мы рассмотрим эти темы в главе 11.

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

# Почему вы действительно полюбите трассировки ошибок

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

Короче говоря, трассировки ошибок Zig гарантируют, что когда что-то идёт не так, вы видите не только взрыв — вы видите, кто зажёг фитиль.

С помощью значений ошибок, объединений, объединений ошибок и набора инструментов try-catch-defer вы готовы к программированию с устойчивостью к ошибкам. Следующие лучшие практики помогут вам эффективно управлять ошибками:

  • Используйте явные типы ошибок: определяйте чёткие, конкретные значения ошибок для разных случаев сбоя. Избегайте общих ошибок, когда это возможно.
  • Используйте defer и errdefer: применяйте defer для общей очистки и errdefer для очистки при ошибках, обеспечивая последовательное управление ресурсами.
  • Обрабатывайте ошибки изящно: планируйте действия по откату с помощью catch или предсказуемо распространяйте ошибки с помощью try.
  • Оставайтесь организованными с маркированными объединениями: используйте маркированные объединения для добавления структуры к обработке ошибок, делая ваши проверки ошибок более читаемыми и поддерживаемыми.

Система обработки ошибок Zig мощная, структурированная и явная. Рассматривая ошибки как значения, позволяя объединения ошибок и предлагая размеченные объединения, Zig предоставляет полный набор инструментов для управления ошибками. В сочетании с try, catch и errdefer вы можете справиться с любым сценарием ошибки с ясностью и уверенностью.

Обработка ошибок в Zig — это не про избегание сбоев, это про грамотное управление ими. С инструментами Zig вы готовы сделать свои программы устойчивыми, предсказуемыми и стабильными, независимо от того, с какими неожиданными данными или ситуациями сталкивается ваш код. Так что примите модель обработки ошибок Zig — это меняет правила игры для создания надёжного программного обеспечения.

# Итоги

Поздравляем! Вы прошли лабиринт перечислений, объединений и ошибок в Zig, не потеряв (надеемся) рассудок. Мы вместе прошли путь от подводных камней жёстко закодированных магических чисел до элегантности перечислений, которые придают вашему коду ясность и самодокументированность.

Затем мы погрузились в гибкий мир объединений — тех изменяющих форму типов, которые позволяют одной переменной примерять разные роли по мере необходимости. Освоив использование как «голых», так и маркированных объединений, вы добавили в свой арсенал универсальный инструмент, позволяющий писать более адаптивный и поддерживаемый код.

Наконец, мы столкнулись с неизбежностью ошибок — не как с нежеланными сюрпризами, а как с неотъемлемой частью наших программ. Приняв наборы ошибок и объединения ошибок, вы научились изящно обрабатывать сбои, превращая потенциальные точки отказа в управляемые и предсказуемые события. Вы увидели, как try, catch и errdefer помогают писать код, который не только работает, но и убирает за собой, когда что-то идёт не так.

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

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

# Закрепление понимания перечислений, объединений и ошибок в Zig

Для каждого вопроса выберите правильный ответ или заполните пропуски по мере необходимости.

  1. Перечисления в Zig используются для определения коллекции ______ значений.
    1. изменяемых
    2. связанных именованных
    3. числовых
    4. случайных
  2. Чтобы присвоить членам перечисления собственные целочисленные значения, вы указываете их ______.
    1. явно
    2. неявно
    3. случайно
    4. через функцию
  3. Какая встроенная функция используется для получения базового целочисленного значения члена перечисления?
    1. @enumToInt
    2. @intFromEnum
    3. @intCast
    4. @enumValue
  4. Перечисления в Zig могут иметь ______, что позволяет определять функции, связанные с ними.
    1. переменные
    2. методы
    3. константы
    4. операторы
  5. «Голое» объединение может содержать одновременно ______ из нескольких указанных типов.
    1. все
    2. два
    3. один
    4. ни одного
  6. Чтобы безопасно изменить активное поле в «голом» объединении, вы должны ______.
    1. изменить существующее поле
    2. переназначить всё объединение
    3. использовать указатель
    4. привести объединение к типу
  7. Размеченное объединение объединяет объединение с ______, чтобы отслеживать активное поле.
    1. структурой
    2. тегом перечисления
    3. целым числом
    4. функцией
  8. При использовании оператора switch с размеченным объединением вы сопоставляете с ______ объединения.
    1. адресом памяти
    2. значением активного поля
    3. тегом
    4. размером
  9. В Zig восклицательный знак ! в возвращаемом типе функции указывает, что функция может ______.
    1. вызвать панику
    2. возвращать несколько значений
    3. возвращать ошибку
    4. вызываться рекурсивно
  10. Для обработки функции, возвращающей объединение ошибок, с помощью try, вы пишете:
    1. try functionCall();
    2. functionCall() try;
    3. functionCall() catch;
    4. try { functionCall(); }
  11. Набор ошибок определяется с помощью ключевого слова ______.
    1. enum
    2. error
    3. union
    4. set
  12. Объединение ошибок объединяет набор ошибок с ______ типом.
    1. логическим
    2. возвращаемым
    3. переменной
    4. указателем
  13. Используя catch, вы можете предоставить ______ значение при обработке объединения ошибок.
    1. по умолчанию
    2. максимальное
    3. случайное
    4. null
  14. Чтобы обрабатывать разные ошибки по-разному, вы можете использовать оператор ______.
    1. for
    2. while
    3. switch
    4. defer
  15. Оператор errdefer используется для выполнения кода, когда функция ______.
    1. возвращает успешно
    2. возвращает ошибку
    3. начинает выполнение
    4. является рекурсивной
  16. errdefer отличается от defer тем, что выполняется только при возврате ошибки ______.
    1. пойманной
    2. проигнорированной
    3. возвращённой
    4. выброшенной (thrown)
  17. Чтобы гарантировать правильное освобождение ресурсов в циклах, вы должны ______.
    1. использовать errdefer вне цикла
    2. избегать использования циклов
    3. использовать глобальные переменные
    4. игнорировать ошибки
  18. Вы создаёте анонимный литерал объединения, указывая поле без ______.
    1. значения
    2. типа
    3. имени типа (type name)
    4. ключевого слова (keyword)
  19. Одним из преимуществ использования анонимных литералов объединений является уменьшение ______.
    1. производительности (performance)
    2. ясности кода (code clarity)
    3. многословности (verbosity)
    4. функциональности (functionality)
  20. Встроенная функция @typeInfo позволяет вам проверять структуру типа во время ______.
    1. выполнения (runtime)
    2. компиляции (compile time)
    3. компоновки (link time)
    4. выполнения (execution time)
  21. @tagName возвращает ______ активного поля в перечислении или объединении.
    1. значение (value)
    2. тип (type)
    3. имя (name)
    4. размер (size)
  22. Неисчерпывающее перечисление включает заполнитель ______ для будущих дополнений.
    1. else (иначе)
    2. _ (подчёркивание)
    3. default (по умолчанию)
    4. null (нулевой указатель)
  23. При переключении по неисчерпающему перечислению вы обрабатываете неуказанные случаи с помощью ______.
    1. случая по умолчанию (default case)
    2. предложения else (else clause)
    3. шаблона _ (подчёркивание)
    4. всё вышеперечисленное (all of the above)
  24. Трассировки возврата ошибок помогают в отладке, показывая последовательность вызовов функций, которые привели к ______.
    1. исключению (exception)
    2. возврату ошибки (error being returned)
    3. бесконечному циклу (infinite loop)
    4. успешному выполнению (successful execution)
  25. Трассировки возврата ошибок включены по умолчанию в сборках типа ______.
    1. ReleaseFast (быстрый выпуск)
    2. ReleaseSmall (малый выпуск)
    3. Debug и ReleaseSafe (отладка и безопасный выпуск)
    4. All (все)
  26. В операторе switch вы можете использовать литералы перечислений, добавляя к варианту префикс ______.
    1. :
    2. .
    3. @
    4. #

27 Пропуск обработки возможного значения перечисления в операторе switch может привести к ______. a. улучшению производительности b. ошибкам компилятора c. автоматической обработке d. отсутствию эффекта 28 Вы используете catch unreachable, когда уверены, что вызов функции не может ______. a. вернуть b. завершиться успешно c. вернуть ошибку d. скомпилироваться 29 В режиме Debug Mode достижение оператора unreachable приведёт к тому, что программа будет ______. a. продолжать работу молча b. вызывать панику c. записывать предупреждение в журнал d. оптимизировать код 30 Следующая тема после изучения перечислений, объединений и ошибок — это ______. a. управление памятью b. автоматизированные тесты c. конкурентность d. работа с сетью 31 Автоматизированные тесты важны, потому что они помогают убедиться, что ваш код ______. a. работает быстрее b. хорошо документирован c. работает как задумано d. использует меньше памяти

# Ответы

    1. связанные именованные
    1. явно
    1. @intFromEnum
    1. методы
    1. одно
    1. переназначить всё объединение
    1. тег перечисления
    1. тег
    1. вернуть ошибку
    1. try functionCall();
    1. error
    1. возвращаемый
    1. по умолчанию
    1. switch
    1. возвращает ошибку
    1. возвращена
    1. использовать errdefer вне цикла
    1. тип
    1. многословности
    1. во время компиляции
    1. имя
    1. _
    1. всё вышеперечисленное
    1. возврат ошибки
    1. Debug и ReleaseSafe
    1. .
    1. ошибки или предупреждения компилятора
    1. вернуть ошибку
    1. вызвать панику
    1. автоматизированные тесты
    1. работает как задумано

  1. Аббревиатура «TGIF» («Thank God it's Friday») означает «Спасибо, что сегодня пятница».