В каких случаях отсутствие значения следует представить через Option, а не через Result?

В каких случаях отсутствие значения следует представить через Option, а не через Result?

Проходите собеседования с ИИ помощником Hintsage

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

Option<T> выбирают, когда отсутствие значения является нормальным и ожидаемым исходом операции, а причина отсутствия не является ошибкой. Result<T, E> нужен, когда операцию нельзя считать успешно завершённой без объяснения причины сбоя.

Например, отсутствие записи в поиске обычно моделируется как Option<T>, а ошибка подключения к базе данных — как Result<T, E>. Если возможны оба исхода, применяют Result<Option<T>, E>: внешний тип описывает ошибку операции, внутренний — допустимое отсутствие результата.

Исторический контекст

В языках с null отсутствие значения часто смешивается с ошибкой, поэтому вызывающий код вынужден угадывать смысл null и рискует получить ошибку во время выполнения. В Rust эту неоднозначность разделили на явные типы: Option для наличия или отсутствия значения и Result для успеха или ошибки.

Такой подход переносит обработку исходов в типовую систему. Компилятор заставляет явно разобрать варианты Some и None, а также Ok и Err, вместо неявного обращения к потенциально отсутствующему значению.

Постановка проблемы

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

Если вернуть только Option<User>, информация о недоступности базы будет потеряна или придётся использовать побочные эффекты вроде журналирования. Если вернуть только Result<User, E>, то отсутствие пользователя придётся кодировать как ошибку, хотя это может быть штатным исходом бизнес-операции.

Подробное решение

Выбирайте Option<T>, когда операция завершилась корректно, но полезного значения нет: поиск не нашёл элемент, коллекция пуста, необязательное поле отсутствует. В Option<T> нет места для причины отсутствия, потому что она не считается ошибкой данной операции.

Выбирайте Result<T, E>, когда нужно различать успешное выполнение и сбой, передать контекст ошибки или дать вызывающему коду возможность выбрать стратегию восстановления. Тип E должен описывать ошибки, которые действительно может обработать вызывающий код, а не быть произвольной строкой.

Когда операция может одновременно завершиться технической ошибкой и штатно не найти результат, используйте Result<Option<T>, E>. Семантика вариантов такова: Err(e) — сама операция не выполнена, Ok(None) — операция выполнена, но значения нет, Ok(Some(value)) — значение найдено.

fn find_even(values: &[u32]) -> Option<u32> { values.iter().copied().find(|value| value % 2 == 0) } fn parse_port(text: &str) -> Result<u16, std::num::ParseIntError> { text.parse::<u16>() } fn main() { assert_eq!(find_even(&[1, 3]), None); assert_eq!(parse_port("8080").unwrap(), 8080); }

Option не следует автоматически превращать в Result только ради использования оператора ?. Если отсутствие значения действительно означает ошибку, преобразование допустимо, но оно должно добавлять осмысленную причину. И наоборот, замена ошибки на None допустима только при намеренном отказе от информации о причине.

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

Ситуация из практики

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

Вариант Option<Product> прост, но скрывает ошибку кэша и делает её неотличимой от промаха кэша. Вариант Result<Product, CacheError> передаёт ошибки, но не может выразить штатный промах без специального варианта ошибки, что смешивает разные понятия.

Выбранное решение — Result<Option<Product>, CacheError>. При Ok(None) выполняется обращение к базе, при Ok(Some(product)) возвращается кэшированное значение, а при Err(error) применяется политика отказоустойчивости: резервный источник, повтор или сообщение об ошибке.

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

Что кандидаты часто упускают

1. Чем отличается Result<Option<T>, E> от Option<Result<T, E>>?

Result<Option<T>, E> обычно означает: сначала выясняется, удалось ли выполнить операцию, а затем — найдено ли значение. Это естественная модель запроса с возможным отсутствием результата и возможной технической ошибкой.

Option<Result<T, E>> означает другую последовательность: результата операции может вообще не быть, а если он есть, то он сам может быть успешным или ошибочным. Такая форма нужна реже, например при ленивом или условном выполнении операции, когда отсутствие самой попытки принципиально отличается от ошибки выполненной попытки.

2. Следует ли превращать любой None в ошибку на границе публичного API?

Нет. Нужно смотреть на контракт операции. Если отсутствие значения ожидаемо для потребителя API, Option сохраняет правильную семантику и не заставляет обрабатывать штатный сценарий как исключительный.

Преобразование в Result оправдано, когда на конкретном уровне приложения отсутствие нарушает обязательное условие. Например, репозиторий может вернуть Option<User>, а слой авторизации преобразовать None в доменную ошибку UserNotFound, если для текущей операции пользователь обязателен.

3. Почему тип ошибки не стоит заменять строкой, даже если операция возвращает Result?

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

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