Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Паниковать или не паниковать

Как же решить, когда вызывать panic!, а когда возвращать Result? Если код паникует, восстановиться уже нельзя. Можно вызывать panic! при любой ошибке, есть ли способ восстановиться или нет, — но тогда вы сами решаете за вызывающий код, что ситуация неисправима. А возвращая Result, вы даёте вызывающему коду выбор. Он может попробовать восстановиться так, как подходит в его ситуации, а может решить, что эта ошибка неисправима, вызвать panic! и тем самым превратить исправимую ошибку в неисправимую. Поэтому, если функция может не сработать, возвращать Result — хороший выбор по умолчанию.

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

Примеры, прототипы и тесты

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

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

Если в тесте не сработал вызов какого-то метода, весь тест должен провалиться, даже если этот метод — не то, что тестируется. Тест помечается как проваленный именно через panic!, так что вызов unwrap или expect — ровно то, что нужно.

Когда вы знаете больше компилятора

Вызывать expect уместно и тогда, когда какая-то логика гарантирует, что в Result будет Ok, но компилятор этой логики не понимает. Значение Result всё равно придётся обработать: вызываемая операция в общем случае может не сработать, даже если в вашей конкретной ситуации это логически невозможно. Если, проверив код вручную, вы уверены, что варианта Err никогда не будет, вполне допустимо вызвать expect и объяснить в тексте аргумента, почему Err не будет. Вот пример:

fn main() {
    use std::net::IpAddr;

    let home: IpAddr = "127.0.0.1"
        .parse()
        .expect("Hardcoded IP address should be valid");
}

Мы создаём экземпляр IpAddr, разбирая строку, вписанную прямо в код. Мы видим, что 127.0.0.1 — корректный IP-адрес, поэтому expect здесь допустим. Но то, что строка задана в коде и корректна, не меняет возвращаемый тип метода parse: мы всё равно получаем Result, и компилятор всё равно заставит нас обработать Result так, будто Err возможен, — ему не хватает ума понять, что эта строка всегда будет корректным IP-адресом. Если бы строка с адресом приходила от пользователя, а не была вписана в программу, и ошибка была бы возможна, мы бы, конечно, обработали Result надёжнее. А упоминание в сообщении, что IP-адрес задан в коде, подскажет нам заменить expect на нормальную обработку ошибок, если когда-нибудь адрес придётся брать из другого источника.

Рекомендации по обработке ошибок

Пусть ваш код паникует, если он может оказаться в плохом состоянии. Здесь плохое состояние — это когда нарушено какое-то допущение, гарантия, договор или инвариант: например, в код переданы недопустимые, противоречивые или отсутствующие значения. И при этом выполняется хотя бы одно из условий:

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

Если кто-то вызывает ваш код и передаёт бессмысленные значения, лучше по возможности вернуть ошибку, чтобы пользователь библиотеки сам решил, что делать. Но если продолжать работу небезопасно или вредно, лучше вызвать panic! и сообщить тому, кто пользуется вашей библиотекой, о баге в его коде, — чтобы он исправил его ещё на этапе разработки. Точно так же panic! часто уместен, если вы вызываете внешний код, который вам неподконтролен, и он возвращает некорректное состояние, исправить которое вы никак не можете.

А вот когда сбой ожидаем, лучше вернуть Result, чем вызывать panic!. Например, парсер получил некорректные данные или HTTP-запрос вернул статус, означающий, что вы превысили лимит запросов. В таких случаях Result показывает, что неудача — ожидаемая возможность, и решать, как её обработать, должен вызывающий код.

Если ваш код выполняет операцию, которая при недопустимых значениях может подвергнуть пользователя риску, код должен сначала проверить значения и запаниковать, если они недопустимы. В основном это нужно ради безопасности: работа с некорректными данными может открыть уязвимости. Именно поэтому стандартная библиотека вызывает panic! при попытке обратиться к памяти за пределами границ: доступ к памяти, которая не принадлежит текущей структуре данных, — распространённая проблема безопасности. У функций часто есть договоры (contracts): их поведение гарантировано, только если входные данные отвечают определённым требованиям. Паниковать при нарушении договора разумно: нарушение договора всегда означает баг на стороне вызывающего, и это не та ошибка, которую вызывающий код должен явно обрабатывать. Вызывающий код всё равно не может разумно восстановиться: исправлять нужно сам код, и сделать это должен программист, который его написал. Договор функции, особенно если его нарушение приводит к панике, стоит описывать в документации к её API.

Впрочем, если в каждой функции будет куча проверок на ошибки, код станет многословным и неприятным. К счастью, многие проверки за вас может сделать система типов Rust (то есть проверка типов, которую выполняет компилятор). Если параметр функции имеет определённый тип, можно спокойно писать логику, зная, что компилятор уже гарантировал корректность значения. Например, если у вас конкретный тип, а не Option, программа ожидает что-то, а не ничего. Тогда коду не нужно обрабатывать два случая для вариантов Some и None — только один, когда значение точно есть. Код, который попытается передать в вашу функцию «ничего», просто не скомпилируется, так что проверять этот случай во время выполнения не нужно. Другой пример — беззнаковый целочисленный тип вроде u32, который гарантирует, что параметр никогда не будет отрицательным.

Собственные типы для проверки значений

Разовьём идею о том, чтобы гарантировать корректность значения с помощью системы типов Rust, и посмотрим, как создать для проверки собственный тип. Вспомните игру из главы 2, где код просил пользователя угадать число от 1 до 100. Мы ни разу не проверили, что догадка пользователя лежит в этих пределах, прежде чем сравнивать её с загаданным числом, — проверили лишь, что она положительная. Последствия тут не страшные: вывод «Too high» или «Too low» всё равно будет верным. Но было бы полезно подсказывать пользователю, какие догадки допустимы, и по-разному реагировать на число вне диапазона и, скажем, на буквы вместо числа.

Можно, например, разбирать догадку не как u32, а как i32, чтобы допустить отрицательные числа, а затем добавить проверку, что число в диапазоне:

Файл: src/main.rs
use std::cmp::Ordering;
use std::io;

use rand::prelude::*;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::rng().random_range(1..=100);

    loop {
        // --пропуск--

        println!("Please input your guess.");

        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: i32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        if guess < 1 || guess > 100 {
            println!("The secret number will be between 1 and 100.");
            continue;
        }

        match guess.cmp(&secret_number) {
            // --пропуск--
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}

Выражение if проверяет, не вышло ли значение за пределы диапазона, сообщает пользователю о проблеме и вызывает continue, чтобы начать следующую итерацию цикла и попросить новую догадку. После if можно сравнивать guess с загаданным числом, зная, что guess лежит между 1 и 100.

Но это не идеальное решение. Если программе абсолютно необходимо работать только со значениями от 1 до 100 и таких функций у неё много, повторять проверку в каждой функции утомительно (да и на производительности это может сказаться).

Вместо этого можно создать новый тип в отдельном модуле и поместить проверку в функцию, создающую экземпляр этого типа, — а не повторять проверку везде. Тогда функции смогут смело использовать новый тип в своих сигнатурах и доверять получаемым значениям. В листинге 9-13 показан один из способов определить тип Guess, экземпляр которого создаётся, только если функция new получила значение от 1 до 100.

Файл: src/guessing_game.rs
#![allow(unused)]
fn main() {
pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 || value > 100 {
            panic!("Guess value must be between 1 and 100, got {value}.");
        }

        Guess { value }
    }

    pub fn value(&self) -> i32 {
        self.value
    }
}
}
Листинг 9-13: Тип Guess, который работает только со значениями от 1 до 100

Обратите внимание: этот код в src/guessing_game.rs предполагает, что в src/lib.rs добавлено объявление модуля mod guessing_game; — здесь мы его не показываем. В файле нового модуля мы определяем структуру Guess с полем value типа i32. В нём и будет храниться число.

Затем реализуем для Guess ассоциированную функцию new, которая создаёт значения Guess. Функция new принимает один параметр value типа i32 и возвращает Guess. Код в её теле проверяет, что value лежит между 1 и 100. Если проверка не пройдена, мы вызываем panic! — это сообщит программисту, пишущему вызывающий код, что у него баг, который нужно исправить: создание Guess со значением value вне диапазона нарушило бы договор, на который опирается Guess::new. Условия, при которых Guess::new может паниковать, стоит описать в её публичной документации к API; о соглашениях, как отмечать в документации возможность panic!, мы поговорим в главе 14. Если же проверка пройдена, мы создаём новый Guess, записываем в поле value параметр value и возвращаем Guess.

Дальше мы реализуем метод value, который заимствует self, не принимает других параметров и возвращает i32. Такие методы иногда называют геттерами: их задача — получить данные из полей и вернуть их. Этот публичный метод нужен потому, что поле value структуры Guess приватное. И важно, чтобы оно было приватным: тогда код, использующий структуру Guess, не сможет задать value напрямую. Код за пределами модуля guessing_game обязан создавать экземпляры Guess через функцию Guess::new, и значит, у Guess никак не может оказаться значения value, не прошедшего проверку в Guess::new.

Теперь функция, которая принимает или возвращает только числа от 1 до 100, может объявить в сигнатуре, что принимает или возвращает Guess, а не i32, и никаких дополнительных проверок в её теле не понадобится.

Итоги

Средства обработки ошибок в Rust помогают писать более надёжный код. Макрос panic! сигнализирует, что программа оказалась в состоянии, с которым не может справиться, и позволяет остановить процесс, а не продолжать работу с некорректными значениями. Перечисление Result с помощью системы типов показывает, что операции могут завершиться неудачей, от которой код может восстановиться. Возвращая Result, вы сообщаете вызывающему коду, что ему нужно обработать и успех, и возможную неудачу. Если использовать panic! и Result там, где они уместны, код станет надёжнее перед лицом неизбежных проблем.

Вы увидели, как стандартная библиотека с пользой применяет обобщения в перечислениях Option и Result. Теперь поговорим о том, как устроены обобщения и как пользоваться ими в своём коде.