Привет.

Спросите любого, что делает ?, и услышите примерно одно: «сахар для match, который при ошибке делает ранний return». В целом, тут есть правда. Разворачивается ? и правда в match с return, но прячется за этим match целый трейтовый механизм — он умеет конвертировать типы, работает с вашими собственными типами и не имеет ничего общего с try/catch, хоть и выглядит похоже.

Давайте посмотрим, что компилятор на самом деле пишет вместо вашего вопросика, что такое residual в нынешнем дизайне, почему ошибке нужен отдельный тип, и как прикрутить ? к своему типу. И сразу скажу: пользоваться ? на Result и Option можно давно и на стабильном Rust, а вот реализовать поддержку ? для своего типа — это до сих пор nightly. Дальше будет видно почему.

Что компилятор пишет вместо вашего ?

Возьмём обычное let x = expr?;. Компилятор разворачивает его примерно в такое:

match Try::branch(expr) {
    ControlFlow::Continue(value)   => value,
    ControlFlow::Break(residual)   => return FromResidual::from_residual(residual),
}

Уже тут видно, что простым «развернул match по Ok/Err» дело не ограничивается. Есть два трейта и один служебный enum.

Enum — это ControlFlow<B, C>, у него два варианта: Continue(C) (идём дальше) и Break(B) (обрываемся). Он стабильный и сам по себе полезный, например в try_fold у итераторов, но здесь он работает развязкой: branch возвращает именно его, и по нему match решает, продолжать или выходить.

Try описывает, как ваш тип разбирается на «продолжить» и «оборваться»:

pub trait Try: FromResidual {
    type Output;     // что достаём, если всё хорошо
    type Residual;   // что улетает в ранний return
    fn from_output(output: Self::Output) -> Self;
    fn branch(self) -> ControlFlow<Self::Residual, Self::Output>;
}

Метод branch берёт значение и раскладывает его на ControlFlow: либо Continue с распакованным успехом, либо Break с тем самым residual. Посмотреть, как это выглядит на знакомых типах, можно в одну строчку:

Ok::<_, String>(3).branch()    // ControlFlow::Continue(3)
Err::<String, _>(3).branch()   // ControlFlow::Break(Err(3))
Some(3).branch()               // ControlFlow::Continue(3)
None::<String>.branch()        // ControlFlow::Break(None)

Метод from_output нужен не для ?, а для try {}-блоков: он собирает успешный вариант обратно. А вот from_residual живёт в другом трейте, это важная часть истории, до неё дойдём.

Residual, или зачем ошибке отдельный тип

По-русски это «остаток»: то, что остаётся от значения, когда успеха в нём нет. Это не сама ошибка, а её отдельная упаковка.

Казалось бы, у Result<T, E> остаток должен быть просто E. А он не E, он Result<Infallible, E>:

Result<T, E>      ->  Residual = Result<Infallible, E>
Option<T>         ->  Residual = Option<Infallible>
ControlFlow<B, C> ->  Residual = ControlFlow<B, Infallible>

Infallible — это тип, который невозможно создать (населить его нечем). Так что Result<Infallible, E> — это коробка из-под Result, в которой бывает только Err, а место под Ok заколочено. Та же логика у Option<Infallible>: фактически это всегда None, потому что Some потребовал бы значение, а его взять негде.

Зачем такая возня вместо E?

Чтобы остатки разных типов не путались между собой. Остаток Result покрашен в резалт, остаток ControlFlow — в контролфлоу, и компилятор их различает.

Следовательно ? на ControlFlow нельзя случайно применить в функции, которая возвращает Result. Типы остатков разные, молчаливой конвертации между ними нет, и компилятор такое просто не пропустит. По той же причине ? на Option не поедет в функции, возвращающей Result — если хотите так, превращайте Option в Result руками через ok_or.

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

Это не try/catch

? выглядит как обработка исключений, но это совсем другое.

try/catch — это динамика. Исключение летит вверх по стеку, раскручивая кадры, пока его кто-нибудь не поймает по типу в рантайме. Куда оно прилетит, заранее по коду не видно, и сам механизм — это работа во время исполнения.

? — это статика целиком. Компилятор разворачивает его в обычный match плюс return, никакой раскрутки стека, никакой динамической диспетчеризации, никакого скрытого потока управления. Ошибка тут не исключение, а обычное значение, которое вы возвращаете из функции явным return.

? — это не фиксированная текстовая подстановка, а механизм на уровне типов. Что именно сделает expr?, зависит от типа expr и решается через трейты Try и FromResidual. Набор типов, с которыми работает ?, открытый: реализовали трейты для своего типа — и ? заработал на нём, как родной. Поэтому называть ? сахаром не совсем правильно. Сахар — это когда одна синтаксическая форма механически разворачивается в другую. А здесь форма разворачивается в обращение к обобщённому трейтовому аппарату, который ещё и конвертирует типы по ходу.

Откуда в ? берётся From для ошибок

Все знают, что ? сам конвертирует ошибку: можно вернуть Result<_, MyError> из функции, ткнуть ? в Result<_, IoError>, и оно соберётся, если есть From<IoError> for MyError. Вопрос — где именно живёт эта конвертация. Не в branch. Она живёт во втором трейте, FromResidual:

pub trait FromResidual<R = <Self as Try>::Residual> {
    fn from_residual(residual: R) -> Self;
}

from_residual обобщён по R — по типу остатка, который он принимает. Это и даёт Result возможность объявить несколько реализаций: одну на свой остаток и ещё одну, которая по дороге зовёт From для ошибки. Грубо говоря, для Result<T, F> есть реализация, принимающая Result<Infallible, E> при условии F: From<E>, и вся эта тема преобразования ошибки прячется ровно там. Проверить можно опять же одной строкой:

Result::<String, i64>::from_residual(Err(3_u8))   // Err(3) — u8 превратился в i64 через From

Вот почему Try требует FromResidual как супертрейт, но конвертацию выносит в него: branch у каждого типа один и занят только разбором на «дальше или выход», а вот способов собрать тип обратно из остатка может быть несколько, и каждый со своей логикой. Развести их по двум трейтам — единственный способ дать Result его фирменное преобразование ошибок и при этом не раздавать его всем подряд бесплатно.

Кстати, у такого разделения есть и побочная польза для скорости компиляции. Конвертация переехала в from_residual, десугаринг стал проще, а значит, рождается меньше промежуточного представления, и всему конвейеру дальше работы меньше. В serde, например, своя реализация проброса ошибок без From-конвертации, и они отмечали заметный выигрыш именно от того, что лишнего преобразования нет.

Свой тип, который дружит с ?

Теперь самое интересное — собрать тип, на котором ? работает. Возьмём маленькую трёхзначную штуку: либо значение, либо обрыв с сообщением.

#![feature(try_trait_v2)]
use std::ops::{Try, FromResidual, ControlFlow};
use std::convert::Infallible;

enum Step<T> {
    Go(T),
    Stop(String),
}

impl<T> Try for Step<T> {
    type Output = T;
    type Residual = Step<Infallible>;   // та самая "дырка" на месте значения

    fn from_output(v: T) -> Self {
        Step::Go(v)
    }

    fn branch(self) -> ControlFlow<Self::Residual, T> {
        match self {
            Step::Go(v)   => ControlFlow::Continue(v),
            Step::Stop(m) => ControlFlow::Break(Step::Stop(m)),
        }
    }
}

impl<T> FromResidual for Step<T> {
    fn from_residual(r: Step<Infallible>) -> Self {
        match r {
            Step::Stop(m) => Step::Stop(m),
            // ветку Go писать не нужно: Step<Infallible> не может быть Go,
            // потому что Infallible нечем населить
        }
    }
}

Output — это T, успех. Residual — это Step<Infallible>, то есть наш же Step, но с заколоченным местом под значение, поэтому в нём бывает только Stop. branch раскладывает Step на ControlFlow: значение уходит в Continue, обрыв — в Break. А from_residual собирает Step обратно из остатка, и здесь видно, зачем нужен Infallible: ветку Go в match можно вообще не писать, компилятор сам видит, что Step<Infallible> не бывает Go.

Дальше ? просто работает:

fn pipeline() -> Step<i32> {
    let a = Step::Go(2)?;            // a = 2, идём дальше
    let b = maybe_stop()?;           // если вернулось Stop — выходим из pipeline со Stop
    Step::Go(a + b)
}

Никакого match в теле, ? сам разбирает каждый шаг и при первом же Stop выходит из функции, вернув этот Stop. Те же четыре строки трейтов, что пишет для своих типов стандартная библиотека, и тот же результат.

Если делаете обёртку над сишным кодом возврата, где ноль — успех, можно взять residual в виде NonZeroI32. Конструктор NonZeroI32::new сам по себе и есть нужная проверка «не ноль, значит ошибка», а заодно благодаря нишевой оптимизации branch у такого типа схлопывается в ничто. Остаток не обязан быть Foo<Infallible>, он обязан лишь уметь донести причину обрыва.

Что из этого стабильно, а что нет

Немного истории. Сначала был макрос try! из самых ранних дней. Потом ? застабилизировали для Result (это версия 1.13, конец 2016-го), а вскоре и для Option. Под капотом тогда работал первый дизайн трейта, try_trait v1: там у Try были type Ok, type Error и методы вокруг into_result, а десугаринг шёл через Result и сразу подмешивал From. Жил он неплохо, но остатки торчали наружу как обычные Result, и это порождало кривоватые запросы вроде «пусть NoneError реализует Error».

Чтобы это вылечить, придумали второй дизайн — try_trait_v2. В нём и появились branch, ControlFlow, покрашенные остатки и отдельный FromResidual. Но как публичный API, доступный для реализации на ваших типах, он до сих пор за фиче-гейтом #![feature(try_trait_v2)] и на стабильный Rust к середине 2026 года не вышел.

Писать expr? на Result и Option — стабильно. А вот строить Step из примера выше, реализуя Try и FromResidual руками, можно только на nightly.


Самый маленький оператор с самой длинной биографией

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

Если уносить из текста одну мысль — ? не ловит ошибки, как catch, а провожает их до выхода. Разбирает значение на «дальше» и «стоп», конвертирует причину обрыва через From, возвращает её из функции. Всё на этапе компиляции.


Размещайте облачную инфраструктуру и масштабируйте сервисы с надежным облачным провайдером Beget.
Эксклюзивно для читателей Хабра мы даем бонус 10% при первом пополнении.

Воспользоваться