Оператор ? и когда panic уместен

Оператор ? — это не магия, а сахар над ранним возвратом Err с автоматическим преобразованием типа ошибки.

panic — аварийное завершение из-за нарушенного инварианта программы, а не способ сообщить вызывающему коду об ожидаемом сбое.

Вопрос: «Что делает оператор ? и во что он разворачивается?»

Что на самом деле проверяет интервьюер

Не заучили ли вы ? как «сокращение для unwrap». Разница принципиальная: unwrap роняет процесс, а ? отдаёт ошибку наверх, оставляя решение вызывающему.

use std::io;

fn read_config() -> Result<String, io::Error> {
    let text = std::fs::read_to_string("config.toml")?;
    Ok(text)
}

Эквивалент без сахара:

fn read_config() -> Result<String, io::Error> {
    let text = match std::fs::read_to_string("config.toml") {
        Ok(v) => v,
        Err(e) => return Err(From::from(e)),
    };
    Ok(text)
}

Тот же оператор работает и для Option: на None функция немедленно возвращает None.

fn first_char_upper(s: &str) -> Option<char> {
    let c = s.chars().next()?;
    Some(c.to_ascii_uppercase())
}

Где ? не работает

fn main() {
    let text = std::fs::read_to_string("config.toml")?;
    println!("{text}");
}

Ошибка компиляции:

error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option`
 --> src/main.rs:2:53
  |
1 | fn main() {
  | --------- this function should return `Result` or `Option` to accept `?`
2 |     let text = std::fs::read_to_string("config.toml")?;
  |                                                      ^ cannot use the `?` operator in a function that returns `()`
  |
  = help: the trait `FromResidual<Result<Infallible, std::io::Error>>` is not implemented for `()`

Лечится сменой сигнатуры:

use std::error::Error;

fn main() -> Result<(), Box<dyn Error>> {
    let text = std::fs::read_to_string("config.toml")?;
    println!("{text}");
    Ok(())
}

Если такой main вернёт Err, рантайм напечатает ошибку через Debug (не Display) и завершит процесс с кодом 1:

Вывод:

Error: Os { code: 2, kind: NotFound, message: "No such file or directory" }

Вопрос: «Как ? умеет превращать одну ошибку в другую?»

Что на самом деле проверяет интервьюер

Знаете ли вы, что за конверсией стоит обычный трейт From. Один ? собирает в одну функцию ошибки ввода-вывода и парсинга — при условии, что вы написали два impl From.

use std::fmt;
use std::num::ParseIntError;

#[derive(Debug)]
enum ConfigError {
    Io(std::io::Error),
    BadPort(ParseIntError),
}

impl fmt::Display for ConfigError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ConfigError::Io(e) => write!(f, "не удалось прочитать конфиг: {e}"),
            ConfigError::BadPort(e) => write!(f, "порт не является числом: {e}"),
        }
    }
}

impl std::error::Error for ConfigError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            ConfigError::Io(e) => Some(e),
            ConfigError::BadPort(e) => Some(e),
        }
    }
}

impl From<std::io::Error> for ConfigError {
    fn from(e: std::io::Error) -> Self {
        ConfigError::Io(e)
    }
}

impl From<ParseIntError> for ConfigError {
    fn from(e: ParseIntError) -> Self {
        ConfigError::BadPort(e)
    }
}

fn load_port(path: &str) -> Result<u16, ConfigError> {
    let raw = std::fs::read_to_string(path)?;
    let port = raw.trim().parse::<u16>()?;
    Ok(port)
}

Реализованный std::error::Error с методом source даёт цепочку причин — по ней инструменты логирования разворачивают «порт не является числом → invalid digit found in string».

Библиотека или приложение

В приложении удобно стирать тип: Box<dyn Error> принимает любую ошибку, реализующую Error, и вам не нужен собственный enum — всё равно всё закончится логом и кодом возврата. В библиотеке так делать нельзя: пользователь должен уметь отличить «файла нет» от «файл битый» и обработать варианты через match. Индустриальный стандарт ровно такой: thiserror для типизированных ошибок библиотек и anyhow для приложений, но обе крейта — это генераторы того кода, который выше написан руками.

Вопрос: «Когда panic! оправдан, а когда надо возвращать Result?»

Что на самом деле проверяет интервьюер

Проведёте ли вы границу «ожидаемый сбой против бага». Файл отсутствует, сеть отвалилась, пользователь ввёл ерунду — это ожидаемо, тут Result. Индекс вышел за границы среза, unwrap на None, целочисленное переполнение в debug-сборке, нарушенный инвариант структуры — это баг программиста, и продолжать работу бессмысленно.

fn percent(part: u32, total: u32) -> u32 {
    assert!(total > 0, "total не может быть нулём");
    part * 100 / total
}

unwind против abort

По умолчанию panic раскручивает стек (unwind): вызываются деструкторы, память освобождается, поток аккуратно умирает. В Cargo.toml это переключается на мгновенное завершение процесса:

[profile.release]
panic = "abort"

abort даёт меньший бинарник и убирает код раскрутки, но лишает вас std::panic::catch_unwind. Сам catch_unwind — не замена try/catch: он нужен на границе FFI (раскрутка через чужой ABI — UB) и в пулах потоков, чтобы упавшая задача не уронила воркер.

Отдельно помните про unwrap в библиотеке: паника внутри чужого кода отбирает у пользователя право решать. Библиотека возвращает Result, а паникует только там, где контракт нарушен явно, — и это документируется секцией «Panics».

Типичные ошибки кандидатов

  • Называть ? «сокращением unwrap» — на самом деле это ранний return Err, а не паника.
  • Не знать про From: «оно само как-то конвертирует» вместо «нужен impl From<IoError> for MyError».
  • Пытаться писать ? в main без Result и не узнавать ошибку E0277.
  • Тащить Box<dyn Error> в публичный API библиотеки, лишая пользователя разбора вариантов.
  • Считать catch_unwind аналогом try/catch и строить на нём бизнес-логику.
  • Забывать, что при panic = "abort" никакого перехвата не будет вовсе.
  • Паниковать в библиотечной функции на пользовательском вводе.

Как ответить кратко

Оператор ? разворачивается в match: на Ok достаёт значение, на Err делает return Err(From::from(e)), то есть попутно конвертирует тип ошибки — для этого нужен impl From. Он же работает с Option, возвращая None, и требует, чтобы функция возвращала подходящий тип: в () это ошибка E0277, поэтому main объявляют как Result<(), Box<dyn Error>>. В приложениях удобно стирать тип ошибки, в библиотеках — держать свой enum с Display и std::error::Error (на практике через thiserror и anyhow). А panic — сигнал о баге и недостижимом состоянии, а не способ сообщить об ожидаемом сбое: библиотека не должна паниковать за пользователя.

Проверьте себя
1. Во что разворачивается выражение `let v = f()?;` в функции, возвращающей Result с параметрами T и E?
AВ match, где ветка Err делает `return Err(From::from(e))`
BВ вызов unwrap с последующим panic при ошибке
CВ try/catch-блок, который ловит панику
DВ `let v = f().unwrap_or_default();`
2. Почему `?` нельзя использовать в функции main без указанного типа возврата?
Amain всегда компилируется в отдельную единицу трансляции
Bmain возвращает (), а для () не реализован FromResidual — ошибка E0277
CВ main запрещены любые ранние возвраты
DОператор ? работает только внутри блоков unsafe
3. Какой подход к типу ошибки считается уместным для библиотеки?
ABox dyn Error, чтобы пользователь не разбирал варианты
BString с текстом ошибки — проще всего логировать
CКонкретный enum, реализующий Display и std::error::Error
Dpanic! с понятным сообщением вместо возврата ошибки