Мастерство обработки ошибок в Rust: Option и Result

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

Философия отсутствия: введение в типы Option и Result и отказ от Null

Философия отсутствия: введение в типы Option и Result и отказ от Null

Добро пожаловать в курс «Мастерство обработки ошибок в Rust». Мы начинаем наше путешествие с фундаментальной концепции, которая отличает Rust от многих других популярных языков программирования — с того, как язык работает с отсутствием данных и ошибками.

Если вы пришли из мира C++, Java, Python или JavaScript, вы наверняка знакомы с концепцией null (или None, undefined, nil). В Rust этого нет. И это не упущение разработчиков, а осознанное архитектурное решение, которое делает ваши программы надежнее.

Проблема на миллиард долларов

В 1965 году Тони Хоар изобрел «нулевую ссылку» (null reference). Спустя много лет он назвал это своей «ошибкой на миллиард долларов». Почему? Потому что попытка использовать значение, которое оказывается null, приводит к падению программы. В Java это NullPointerException, в C++ — неопределенное поведение или segfault, в Python — AttributeError: 'NoneType' object has no attribute....

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

Rust решает эту проблему радикально: значение либо есть, либо его нет, и это зафиксировано в системе типов.

Option: Быть или не быть

Вместо null в Rust используется перечисление (enum) под названием Option. Оно определено в стандартной библиотеке следующим образом:

enum Option<T> {
    Some(T),
    None,
}

Это обобщенный (generic) тип. T может быть чем угодно: числом, строкой, структурой или даже другим Option.

  • Some(T): Обертка, содержащая значение типа T.
  • None: Маркер отсутствия значения.

Главное преимущество: вы не можете использовать Option<T> так, как будто это T. Вы не можете сложить Option<i32> и i32. Компилятор ударит вас по рукам и скажет: «Сначала проверь, есть ли внутри значение!».

Пример использования Option

Представьте, что мы ищем элемент в списке. Он может там быть, а может и не быть.

fn find_item(items: &[i32], target: i32) -> Option<usize> {
    for (index, &item) in items.iter().enumerate() {
        if item == target {
            return Some(index); // Возвращаем индекс, обернутый в Some
        }
    }
    None // Ничего не нашли
}

fn main() {
    let numbers = vec![10, 20, 30];
    let result = find_item(&numbers, 20);

    // Мы не можем просто использовать result как число.
    // Нам нужно обработать оба варианта.
}

Result: Успех или Провал

Если Option отвечает на вопрос «есть ли данные?», то Result отвечает на вопрос «прошла ли операция успешно?».

Определение Result выглядит так:

enum Result<T, E> {
    Ok(T),
    Err(E),
}

Здесь два обобщенных типа:

  • T (Type): Тип возвращаемого значения в случае успеха.
  • E (Error): Тип ошибки в случае неудачи.

В отличие от None в Option, вариант Err в Result несет в себе информацию о том, почему произошла ошибка. Это критически важно для ввода-вывода, парсинга данных и сетевых запросов.

Пример использования Result

Попробуем открыть файл:

use std::fs::File;

fn main() {
    let f = File::open("hello.txt");
    // f имеет тип Result<File, std::io::Error>
}

Если файл существует, f будет Ok(File). Если файла нет или нет прав доступа, f будет Err(Error).

Извлечение данных: Pattern Matching

Самый надежный и идиоматичный способ работы с Option и Result — это сопоставление с образцом (pattern matching) через оператор match.

let result = find_item(&vec![1, 2, 3], 5);

match result {
    Some(index) => println!("Найдено по индексу: {}", index),
    None => println!("Элемент не найден"),
}

То же самое для Result:

let f = File::open("hello.txt");

match f {
    Ok(file) => println!("Файл открыт успешно: {:?}", file),
    Err(error) => println!("Ошибка открытия файла: {:?}", error),
}

match гарантирует, что вы обработали все возможные варианты. Вы не сможете забыть обработать ошибку — код просто не скомпилируется.

Опасные методы: unwrap и expect

Иногда вы увидите код, где используются методы .unwrap() или .expect("msg").

  • unwrap(): Если внутри Some или Ok, возвращает значение. Если None или Err, программа аварийно завершается (panic).
  • expect("сообщение"): То же самое, что unwrap, но при панике выводит ваше сообщение.

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

Конвертация: Мост между мирами

Часто бывает так, что у вас есть Option, а функции нужен Result, или наоборот. Rust предоставляет удобные методы для конвертации.

Из Option в Result

Метод ok_or превращает Option<T> в Result<T, E>. Поскольку Option не содержит информации об ошибке (только None), вы должны предоставить ошибку сами.

let my_option: Option<i32> = None;
// Превращаем отсутствие значения в конкретную ошибку
let my_result: Result<i32, &str> = my_option.ok_or("Значение отсутствует");

Из Result в Option

Метод ok превращает Result<T, E> в Option<T>. При этом информация об ошибке отбрасывается (превращается в None).

let my_result: Result<i32, &str> = Err("Что-то пошло не так");
// Нам не важна причина ошибки, важно лишь наличие значения
let my_option: Option<i32> = my_result.ok(); // Будет None

Оператор «?» — синтаксический сахар

В Rust есть оператор ?, который позволяет писать очень лаконичный код обработки ошибок. Он работает так:

  1. Если значение Ok (или Some), он распаковывает его и возвращает результат.
  2. Если значение Err (или None), он немедленно возвращает ошибку из текущей функции.
use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let mut s = String::new();
    // Если open вернет Err, функция завершится и вернет этот Err.
    // Если Ok, мы получим файл и продолжим.
    File::open("hello.txt")?.read_to_string(&mut s)?;
    Ok(s)
}

Это делает код линейным и читаемым, избавляя от вложенных match.

Лучшие практики и подводные камни

  1. Не бойтесь возвращать типы-обертки. Если функция может не вернуть значение, Option — ваш лучший друг. Не используйте «магические числа» (например, -1 для ошибки), используйте систему типов.
  2. Избегайте unwrap в библиотеках. Если вы пишете библиотеку, никогда не вызывайте панику, если только состояние не является критически невосстановимым. Позвольте пользователю вашей библиотеки решать, как обрабатывать ошибку.
  3. Используйте комбинаторы. Методы map, and_then, unwrap_or позволяют работать с данными внутри Option и Result, не извлекая их явно через match. Мы рассмотрим их подробнее в следующих статьях курса.

Заключение

Типы Option и Result — это не просто замена исключениям или null. Это философия, которая заставляет вас думать о краевых случаях на этапе написания кода, а не отладки. Приняв эту философию, вы обнаружите, что ваши программы на Rust становятся удивительно стабильными.

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

Механизмы извлечения: Pattern Matching, unwrap и безопасная обработка значений

Механизмы извлечения: Pattern Matching, unwrap и безопасная обработка значений

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

Но как только данные упакованы, возникает закономерный вопрос: как их оттуда достать? Как безопасно открыть коробку и использовать содержимое, не сломав при этом программу?

В этой статье мы разберем все способы извлечения данных: от фундаментального match до синтаксического сахара if let, от опасного unwrap до элегантного оператора ?.

Pattern Matching: Золотой стандарт безопасности

Самый мощный и идиоматичный инструмент в Rust для работы с перечислениями (которыми и являются Option и Result) — это выражение match. Оно позволяет сопоставить значение с набором шаблонов и выполнить код для совпавшего варианта.

Представьте match как сортировочную станцию. Данные поступают на вход, и в зависимости от того, что это — Some или None, Ok или Err — они направляются по разным путям.

Исчерпывающая проверка

Главная особенность match в Rust — это исчерпывающая проверка (exhaustiveness checking). Компилятор не позволит вам забыть ни один из вариантов. Если вы обрабатываете Option, вы обязаны написать ветку для None.

fn get_discount(user_id: Option<i32>) {
    match user_id {
        Some(id) => println!("Скидка для пользователя {}", id),
        None => println!("Скидка для гостя"),
    }
}

Здесь id внутри ветки Some(id) — это уже распакованное значение типа i32. Вы можете использовать его как обычное число.

Для Result логика идентична:

let file_result = std::fs::File::open("config.toml");

match file_result {
    Ok(file) => println!("Файл открыт, дескриптор: {:?}", file),
    Err(e) => eprintln!("Не удалось открыть файл: {:?}", e),
}

if let: Лаконичность для одного случая

Иногда match кажется избыточным. Представьте, что вам нужно выполнить действие только если значение существует, а в случае None не делать ничего. Использование match заставит вас писать пустую ветку:

let config = Some("dark_mode");

match config {
    Some(theme) => println!("Установлена тема: {}", theme),
    None => (), // Приходится писать это, чтобы удовлетворить компилятор
}

Для таких ситуаций в Rust есть конструкция if let. Это синтаксический сахар, который читается как: «Если config соответствует шаблону Some(theme), то выполни блок кода».

if let Some(theme) = config {
    println!("Установлена тема: {}", theme);
}

Этот код делает то же самое, что и match выше, но выглядит чище. Вы также можете добавить блок else:

if let Ok(data) = fetch_data() {
    process(data);
} else {
    println!("Ошибка загрузки, используем кэш...");
}

Используйте if let, когда вас интересует только один вариант развития событий, и match, когда логика сложная и требует обработки всех исходов.

Опасная зона: unwrap и expect

Новички в Rust часто злоупотребляют методами .unwrap() и .expect(). Эти методы пытаются достать значение «силой». Если внутри Some или Ok, они возвращают содержимое. Но если там None или Err, программа паникует (аварийно завершается).

unwrap

let x = Some(10);
let val = x.unwrap(); // Возвращает 10

let y: Option<i32> = None;
let val = y.unwrap(); // ПАНИКА! Программа падает.

expect

expect работает так же, как unwrap, но позволяет указать сообщение, которое будет выведено при панике. Это значительно упрощает отладку.

let f = std::fs::File::open("missing.txt")
    .expect("Файл должен существовать, проверьте права доступа");

Когда их можно использовать?

  1. Прототипирование: Когда вы быстро набрасываете структуру программы и обработка ошибок пока не важна.
  2. Тесты: В тестах паника — это нормальный способ сообщить о провале теста.
  3. Математическая уверенность: Когда вы точно знаете, что ошибки быть не может, но компилятор этого не видит.

Пример уверенности:

let ip: std::net::IpAddr = "127.0.0.1".parse().unwrap();

Мы захардкодили строку, мы знаем, что она валидна. Здесь unwrap допустим.

Безопасные запасные варианты (Fallbacks)

Вместо того чтобы паниковать при отсутствии значения, часто лучше использовать значение по умолчанию. Rust предоставляет для этого семейство методов unwrap_or.

unwrap_or

Возвращает значение из коробки или предоставленное значение по умолчанию.

let user_input: Option<&str> = None;
let name = user_input.unwrap_or("Аноним"); // Вернет "Аноним"

unwrap_or_else

Этот метод принимает не значение, а замыкание (функцию), которая вычисляет значение по умолчанию. Это критически важно, если вычисление дефолтного значения ресурсоемкое.

fn heavy_calculation() -> i32 {
    // Представьте здесь сложную математику
    println!("Вычисляем...");
    42
}

let x = Some(10);
// heavy_calculation НЕ будет вызвана, так как x содержит значение
let val = x.unwrap_or_else(heavy_calculation);

Если бы мы использовали unwrap_or(heavy_calculation()), функция вызвалась бы до передачи в метод, даже если она не нужна.

unwrap_or_default

Работает для типов, реализующих трейт Default (например, 0 для чисел, пустая строка для String, пустой вектор для Vec).

let x: Option<String> = None;
let s = x.unwrap_or_default(); // Вернет "" (пустую строку)

Распространение ошибок: Оператор «?»

Часто функция не знает, как обработать ошибку, и хочет просто передать её «наверх» — вызывающему коду. В языках вроде Java это происходит автоматически через исключения. В Rust для этого есть оператор ?.

Рассмотрим пример без ?:

fn read_username() -> Result<String, std::io::Error> {
    let f = std::fs::File::open("hello.txt");

    let mut f = match f {
        Ok(file) => file,
        Err(e) => return Err(e), // Явный возврат ошибки
    };

    let mut s = String::new();
    match f.read_to_string(&mut s) {
        Ok(_) => Ok(s),
        Err(e) => Err(e), // Явный возврат ошибки
    }
}

А теперь с оператором ?:

use std::io::Read;

fn read_username() -> Result<String, std::io::Error> {
    let mut f = std::fs::File::open("hello.txt")?; // Если ошибка, вернет Err сразу
    let mut s = String::new();
    f.read_to_string(&mut s)?; // Если ошибка, вернет Err сразу
    Ok(s)
}

Оператор ? делает три вещи:

  1. Проверяет Result.
  2. Если Ok, распаковывает значение и продолжает выполнение.
  3. Если Err, немедленно возвращает ошибку из текущей функции.

Важно: Оператор ? можно использовать только в функциях, которые возвращают Result (или Option), совместимый с типом ошибки.

Конвертация между Option и Result

Иногда у вас есть Option, а API требует Result, или наоборот. Rust позволяет легко переключаться между этими типами.

Option -> Result: ok_or

Превращая Option в Result, вы должны ответить на вопрос: «Если значения нет (None), то какая это ошибка (Err)?».

let val: Option<i32> = None;
// Превращаем None в Err("Нет данных")
let res: Result<i32, &str> = val.ok_or("Нет данных");

Также существует ok_or_else для ленивого создания ошибки (полезно, если создание объекта ошибки стоит дорого).

Result -> Option: ok

Превращая Result в Option, вы теряете информацию об ошибке. Все варианты Err превращаются в None.

let res: Result<i32, &str> = Err("Что-то сломалось");
let val: Option<i32> = res.ok(); // Будет None

Это полезно, когда вам не важна причина ошибки, а важно лишь наличие результата.

Лучшие практики и подводные камни

Подводя итог, сформулируем правила безопасной работы:

  1. Предпочитайте match и if let для явной обработки логики.
  2. Используйте unwrap_or и unwrap_or_else для предоставления значений по умолчанию. Это делает программу устойчивой.
  3. Избегайте unwrap в продакшене. Если вы пишете библиотеку, unwrap — это табу. Ваша библиотека не должна ронять приложение пользователя.
  4. Используйте expect вместо unwrap, если паника неизбежна. Сообщение в expect сэкономит вам часы отладки.
  5. Используйте ? для чистого кода, когда ошибку нужно просто передать дальше.

Заключение

Мы научились безопасно извлекать данные из Option и Result. Теперь вы знаете, что unwrap — это не единственный способ открыть коробку, и у вас есть целый арсенал инструментов для элегантной обработки отсутствия данных.

Но что, если нам нужно изменить данные, не доставая их из коробки? Что если мы хотим применить функцию к значению внутри Option, только если оно там есть? В следующей статье мы погрузимся в мир функциональных комбинаторов: map, and_then и других методов, которые позволят писать код в стиле конвейера.

Магия комбинаторов: map, and_then и конвертация между Option и Result

Магия комбинаторов: map, and_then и конвертация между Option и Result

В предыдущих статьях мы научились создавать «коробки» Option и Result и безопасно извлекать из них содержимое с помощью match, if let и оператора ?. Эти инструменты прекрасно справляются с задачей, когда вам нужно получить значение «здесь и сейчас».

Однако в реальном программировании часто возникает другая задача: нам нужно не просто достать значение, а изменить его, проверить или передать в другую функцию, но только при условии, что значение существует (или операция прошла успешно). Если мы будем использовать match на каждом шаге, наш код превратится в «лестницу» из вложенных проверок, которую трудно читать и поддерживать.

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

Проблема вложенности

Представьте, что у нас есть Option<i32>, и мы хотим умножить число внутри на 2, а затем превратить его в строку. Используя только match, мы бы написали:

let maybe_number = Some(5);

let result = match maybe_number {
    Some(n) => {
        let doubled = n * 2;
        Some(doubled.to_string())
    },
    None => None,
};

Это работает, но выглядит громоздко. Мы пишем много шаблонного кода (None => None), который просто перекладывает пустоту из одной переменной в другую. Rust предлагает лучшее решение.

Метод map: Трансформация внутри коробки

Метод map — это один из самых часто используемых комбинаторов. Он принимает функцию (обычно замыкание), применяет её к содержимому Option или Result (если оно есть) и упаковывает результат обратно.

Как это работает с Option

Сигнатура метода упрощенно выглядит так:

fn map<U, F>(self, f: F) -> Option<U>
where F: FnOnce(T) -> U

Перепишем наш пример с использованием map:

let maybe_number = Some(5);

// Мы преобразуем i32 в String внутри Option
let result: Option<String> = maybe_number
    .map(|n| n * 2)
    .map(|n| n.to_string());

println!("{:?}", result); // Вывод: Some("10")

Если maybe_number будет None, ни одно из замыканий не выполнится, и результатом всей цепочки будет None.

Как это работает с Result

Для Result метод map работает аналогично, но затрагивает только вариант Ok. Вариант Err остается без изменений.

let result: Result<i32, &str> = Ok(10);
let new_result = result.map(|i| i + 1); // Ok(11)

let error: Result<i32, &str> = Err("Ошибка");
let new_error = error.map(|i| i + 1); // Err("Ошибка")

Метод map_err: Работа с ошибками

Иногда нам нужно трансформировать не успешное значение, а ошибку. Например, мы получили ошибку ввода-вывода, но хотим вернуть ошибку нашего приложения. Для этого существует map_err.

use std::fs::File;

fn open_file() -> Result<File, String> {
    File::open("missing.txt")
        // Преобразуем std::io::Error в String
        .map_err(|e| format!("Не удалось открыть файл: {}", e))
}

Этот метод игнорирует Ok и применяет функцию только к содержимому Err.

Метод and_then: Цепочки вычислений

Метод map прекрасен, когда ваша функция возвращает обычное значение (например, число или строку). Но что, если функция сама возвращает Option или Result?

Рассмотрим пример. У нас есть ID пользователя, мы хотим найти пользователя в базе, а затем найти его адрес.

fn find_user(id: i32) -> Option<User> { ... }
fn get_address(user: User) -> Option<Address> { ... }

Если мы используем map, мы получим «матрешку»:

let id = Some(1);
let user_option = find_user(1);

// Тип результата: Option<Option<Address>>
let address = user_option.map(|u| get_address(u));

Option<Option<Address>> — это неудобно. Нам бы хотелось, чтобы структура оставалась плоской. Здесь на сцену выходит and_then (в других языках часто называется flatMap).

and_then работает как map, но ожидает, что замыкание само вернет Option (или Result). Затем он «сплющивает» результат, убирая лишнюю обертку.

let address: Option<Address> = find_user(1)
    .and_then(|u| get_address(u));

Логика and_then:

  1. Если исходное значение None -> вернуть None.
  2. Если Some(x) -> вызвать функцию f(x).
  3. Вернуть результат функции f(x) (который уже является Option).

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

Конвертация и адаптация типов

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

Option в Result: ok_or и ok_or_else

Самая частая операция — превращение отсутствия значения (None) в ошибку (Err).

  • ok_or(err): Принимает ошибку жадно (значение создается всегда).
  • ok_or_else(closure): Принимает замыкание, которое создает ошибку только при необходимости (лениво).
let config_option: Option<String> = None;

// Превращаем Option в Result
let config_result: Result<String, &str> = config_option
    .ok_or("Конфигурация не найдена");

Result в Option: ok

Иногда нам не важна причина ошибки, мы просто хотим знать, есть значение или нет. Метод ok() превращает Result<T, E> в Option<T>, отбрасывая ошибку.

let res: Result<i32, &str> = Err("Взрыв!");
let opt: Option<i32> = res.ok(); // None

Transpose: Выворачивание наизнанку

Иногда вы сталкиваетесь с типом Option<Result<T, E>>, но вам удобнее работать с Result<Option<T>, E>. Это часто случается при итерации.

Метод transpose меняет порядок вложенности:

  • None -> Ok(None)
  • Some(Ok(x)) -> Ok(Some(x))
  • Some(Err(e)) -> Err(e)

Это позволяет использовать оператор ? на Option<Result<...>>.

let x: Option<Result<i32, &str>> = Some(Ok(5));
let y: Result<Option<i32>, &str> = x.transpose();

Практический пример: Парсинг конфигурации

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

  1. Получаем переменную (возвращает Option<String>).
  2. Если её нет, это ошибка.
  3. Парсим строку в число (возвращает Result<i32, ParseIntError>).
  4. Если число отрицательное, это тоже ошибка.
use std::env;

fn get_port() -> Result<u16, String> {
    env::var("PORT")
        .ok() // Result -> Option (игнорируем ошибку чтения переменной)
        .ok_or("Переменная PORT не задана".to_string()) // Option -> Result (своя ошибка)
        .and_then(|s| {
            s.parse::<u16>()
             .map_err(|_| "PORT должен быть числом".to_string()) // Result -> Result
        })
        .and_then(|p| {
            if p > 1024 {
                Ok(p)
            } else {
                Err("Порт должен быть > 1024".to_string())
            }
        })
}

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

Лучшие практики и подводные камни

  1. Читаемость превыше всего. Цепочки комбинаторов могут быть очень элегантными, но если ваша цепочка занимает 10 строк и содержит сложную логику внутри замыканий, возможно, лучше разбить её на несколько переменных или использовать match.
  2. Ленивые вычисления. Используйте ok_or_else, unwrap_or_else и map_or_else, если вычисление значения по умолчанию или ошибки требует ресурсов (например, аллокации памяти или форматирования строк).
  3. Не бойтесь смешивать стили. Вполне нормально использовать комбинаторы для простых трансформаций, а затем ? для выхода из функции. Rust не заставляет вас выбирать только один подход.

Заключение

Комбинаторы map, and_then и методы конвертации — это то, что делает обработку ошибок в Rust не рутиной, а искусством. Они позволяют вам сосредоточиться на «счастливом пути» (happy path) выполнения программы, декларативно описывая, как обрабатывать отклонения.

Теперь, когда вы владеете и императивными (match, if let), и функциональными инструментами, вы готовы к решению любых задач. В следующей части курса мы рассмотрим создание собственных типов ошибок и работу с крейтом thiserror, чтобы сделать ваши API профессиональными.

Эргономика ошибок: оператор вопросительного знака и проброс исключений

Эргономика ошибок: оператор вопросительного знака и проброс исключений

В предыдущих статьях мы исследовали фундамент обработки ошибок в Rust. Мы научились упаковывать значения в Option и Result, извлекать их с помощью match и трансформировать через функциональные комбинаторы вроде map и and_then.

Однако, если вы попытаетесь написать реальное приложение, используя только эти инструменты, вы можете столкнуться с проблемой, известной как «ад вложенности» или «пирамида смерти». Когда одна операция зависит от успеха предыдущей, код начинает смещаться вправо, становясь трудночитаемым.

В этой статье мы познакомимся с инструментом, который делает обработку ошибок в Rust не только безопасной, но и удивительно лаконичной — оператором вопросительного знака (?). Мы разберем, как он работает «под капотом», как он автоматически конвертирует ошибки и почему это считается «золотым стандартом» эргономики в Rust.

Проблема многословности

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

  1. Открытие файла.
  2. Чтение содержимого в строку.
  3. Парсинг строки в число.

Используя классический match, код выглядел бы так:

use std::fs::File;
use std::io::{self, Read};

fn read_number_from_file() -> Result<i32, io::Error> {
    let f = File::open("number.txt");

    let mut f = match f {
        Ok(file) => file,
        Err(e) => return Err(e),
    };

    let mut s = String::new();
    match f.read_to_string(&mut s) {
        Ok(_) => (),
        Err(e) => return Err(e),
    };

    match s.trim().parse::<i32>() {
        Ok(n) => Ok(n),
        Err(e) => {
            // Нам нужно превратить ошибку парсинга в ошибку ввода-вывода,
            // чтобы типы совпадали (об этом поговорим позже)
            Err(io::Error::new(io::ErrorKind::InvalidData, e))
        }
    }
}

Этот код надежен, но ужасен для чтения. Логика «счастливого пути» (happy path) теряется за шумом обработки ошибок. Мы постоянно пишем одно и то же: «Если ошибка, верни её, иначе дай мне значение».

Оператор ?: Элегантное решение

Rust решает эту проблему с помощью оператора ?. Он ставится в конце выражения, возвращающего Result (или Option).

Вот тот же самый код, переписанный с использованием ?:

use std::fs::File;
use std::io::{self, Read};

fn read_number_from_file() -> Result<i32, io::Error> {
    let mut s = String::new();
    File::open("number.txt")?.read_to_string(&mut s)?;
    let n: i32 = s.trim().parse().map_err(|e|
        io::Error::new(io::ErrorKind::InvalidData, e)
    )?;
    Ok(n)
}

Код стал линейным. Он читается почти как обычный императивный код на Python или Java, который выбрасывает исключения, но при этом сохраняет строгую типобезопасность Rust.

Как это работает?

Оператор ? — это синтаксический сахар. Когда компилятор видит expression?, он заменяет его на конструкцию, логически эквивалентную следующему match:

match expression {
    Ok(value) => value,
    Err(e) => return Err(e.into()), // Обратите внимание на .into()
}

Суть проста:

  1. Успех: Если результат Ok, оператор распаковывает значение, и программа продолжает выполняться дальше.
  2. Провал: Если результат Err, оператор немедленно возвращает ошибку из текущей функции, прерывая её выполнение. Это называется «проброс ошибки» (error propagation).

Магия конвертации ошибок: Трейт From

Вы могли заметить вызов .into() в объяснении выше. Это, пожалуй, самая мощная особенность оператора ?.

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

Например, вы пишете функцию, которая работает с базой данных и с файловой системой. Она может столкнуться с io::Error (ошибка файла) и db::Error (ошибка БД). Но сигнатура функции позволяет вернуть только один тип Result<T, MyAppError>.

Оператор ? автоматически вызывает функцию From::from, чтобы преобразовать ошибку, которую он получил, в ошибку, которую ожидает возвращаемый тип функции.

// Представим, что у нас есть свой тип ошибки
enum MyError {
    Io(std::io::Error),
    Parse(std::num::ParseIntError),
}

// Мы учим Rust превращать io::Error в MyError
impl From<std::io::Error> for MyError {
    fn from(err: std::io::Error) -> MyError {
        MyError::Io(err)
    }
}

// Мы учим Rust превращать ParseIntError в MyError
impl From<std::num::ParseIntError> for MyError {
    fn from(err: std::num::ParseIntError) -> MyError {
        MyError::Parse(err)
    }
}

fn work() -> Result<i32, MyError> {
    let f = File::open("data.txt")?; // io::Error автоматически превратится в MyError::Io
    let n: i32 = "123".parse()?;     // ParseIntError автоматически превратится в MyError::Parse
    Ok(n)
}

Без этой особенности нам пришлось бы писать .map_err(MyError::Io) после каждого вызова. Оператор ? берет эту рутину на себя.

Использование с Option

Хотя чаще всего ? используется с Result, он также прекрасно работает с Option. Логика идентична:

  • Если Some(v), возвращает v.
  • Если None, функция немедленно завершается и возвращает None.

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

fn find_first_positive(nums: Vec<i32>) -> Option<i32> {
    // Если first() вернет None, функция вернет None.
    // Если Some, мы получим число и продолжим.
    let first = nums.first()?;
    if *first > 0 {
        Some(*first)
    } else {
        None
    }
}

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

Result в функции main

Долгое время в Rust функция main могла возвращать только (). Это означало, что для использования ? в main приходилось писать обертки или использовать unwrap.

В современном Rust main может возвращать Result:

use std::fs::File;

fn main() -> Result<(), std::io::Error> {
    let f = File::open("config.txt")?;
    Ok(())
}

Если программа завершится с ошибкой, Rust автоматически распечатает отладочное сообщение об ошибке (Debug представление) и завершит процесс с ненулевым кодом возврата.

Лучшие практики и подводные камни

1. Не злоупотребляйте ? при потере контекста

Оператор ? отлично подходит для библиотек, где вы просто пробрасываете ошибку наверх. Но в приложениях иногда важно добавить контекст.

Вместо:

File::open(path)?

Иногда лучше написать:

File::open(path).map_err(|e| format!("Не удалось открыть конфиг по пути {}: {}", path, e))?

Это поможет понять, какой именно файл не открылся, а не просто получить сухое «No such file or directory».

2. Совместимость типов

Помните, что ? работает только тогда, когда тип ошибки совместим с возвращаемым типом функции (реализован трейт From). Если компилятор жалуется на несовпадение типов, вам придется использовать map_err для ручной конвертации.

3. Отличие от unwrap

Никогда не путайте ? и unwrap.

  • unwrap говорит: «Я уверен, что здесь успех, а если нет — убей программу».
  • ? говорит: «Если здесь ошибка, верни её вызывающему коду, пусть он разбирается».

Заключение

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

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

Идиоматичный Rust: лучшие практики, распространенные антипаттерны и подводные камни

Идиоматичный Rust: лучшие практики, распространенные антипаттерны и подводные камни

Мы прошли долгий путь. Мы изучили философию отсутствия null, освоили типы Option и Result, научились извлекать данные через match и строить элегантные цепочки с помощью комбинаторов. Теперь, когда у вас есть все инструменты, пришло время поговорить о стиле.

В сообществе Rust есть термин «идиоматичный код». Это код, который не просто работает, но и использует возможности языка так, как это задумывали его создатели. Такой код легче читать, поддерживать, и он, как правило, работает быстрее и безопаснее.

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

Антипаттерн №1: Злоупотребление unwrap

Самый распространенный грех новичка — повсеместное использование .unwrap(). Когда вы только учитесь, соблазн велик: компилятор требует обработать Option или Result, а вам просто нужно значение. Вы пишете unwrap(), и код компилируется. Но это мина замедленного действия.

Почему это плохо?

Метод unwrap вызывает панику (аварийное завершение программы), если встречает None или Err. В продакшене это недопустимо. Сервер не должен падать из-за того, что пользователь прислал некорректный JSON.

// Плохо: Если переменной окружения нет, программа упадет без объяснений
let port = std::env::var("PORT").unwrap();

Как сделать идиоматично?

  1. Используйте expect: Если вы считаете, что паника допустима (например, при старте приложения без критической конфигурации), используйте expect. Он делает то же самое, что unwrap, но позволяет написать сообщение, которое поможет вам при отладке.

    // Лучше: Мы поймем, почему программа упала
    let port = std::env::var("PORT").expect("Не задана переменная окружения PORT");
    
  2. Используйте значения по умолчанию: Методы unwrap_or и unwrap_or_else позволяют продолжить работу, даже если данных нет.

  3. Пробрасывайте ошибки: Используйте оператор ?, чтобы передать ответственность за обработку ошибки вызывающему коду.

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

Антипаттерн №2: «Лестница» из match

Вторая распространенная проблема — вложенные конструкции match. Это часто происходит, когда разработчик игнорирует комбинаторы map и and_then.

// Неидиоматично: Трудно читать, логика смещается вправо
fn get_server_status() -> Option<String> {
    match config::load() {
        Some(cfg) => match cfg.server {
            Some(server) => Some(server.status),
            None => None,
        },
        None => None,
    }
}

Как сделать идиоматично?

Используйте цепочки методов. Это делает код плоским и декларативным.

// Идиоматично: Читается как предложение
fn get_server_status() -> Option<String> {
    config::load()
        .and_then(|cfg| cfg.server)
        .map(|server| server.status)
}

Подводный камень: map против and_then

Частая ошибка — путаница между map и and_then.

  • Используйте map, когда ваша функция возвращает обычное значение (не обернутое).
  • Используйте and_then, когда ваша функция сама возвращает Option или Result.

Если вы используете map там, где нужен and_then, вы получите «матрешку» типа Option<Option<T>>. Rust не будет автоматически сплющивать типы за вас.

Лучшая практика: Работа с итераторами

Rust сияет, когда дело доходит до обработки коллекций Option и Result. Представьте, у вас есть вектор строк, и вы хотите распарсить их в числа.

let strings = vec!["42", "tofu", "99", "burger"];

filter_map: Оставить только успехи

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

let numbers: Vec<i32> = strings
    .iter()
    .filter_map(|s| s.parse().ok()) // .ok() превращает Result в Option
    .collect();

// Результат: [42, 99]

Это очень мощный паттерн: мы конвертируем Result в Option (ошибка становится None), а filter_map автоматически выбрасывает все None и распаковывает Some.

collect: Все или ничего

А что, если ошибка для нас критична? Мы хотим получить вектор чисел, но если хотя бы одна строка некорректна, мы хотим получить ошибку целиком.

У метода collect есть магическая способность: он умеет выворачивать контейнеры наизнанку. Он может превратить Vec<Result<T, E>> в Result<Vec<T>, E>.

let strings = vec!["42", "99"];
let numbers: Result<Vec<i32>, _> = strings
    .iter()
    .map(|s| s.parse())
    .collect();

// Если все хорошо: Ok([42, 99])
// Если есть ошибка: Err(ParseIntError)

Это избавляет от необходимости писать сложные циклы с ранним возвратом.

Антипаттерн №3: «Слепота» логических типов (Boolean Blindness)

Приходя из C или Java, программисты часто пишут функции, возвращающие пару (bool, T) или просто проверяющие наличие чего-то перед получением.

// Неидиоматично
if map.contains_key("key") {
    let val = map.get("key").unwrap();
    process(val);
}

Это неэффективно (два поиска по хеш-карте вместо одного) и небезопасно (между проверкой и получением состояние могло измениться в многопоточной среде, хотя Rust от этого защищает, логика остается хрупкой).

Как сделать идиоматично?

Доверяйте Option. Метод get уже возвращает Option. Используйте if let.

// Идиоматично
if let Some(val) = map.get("key") {
    process(val);
}

Никогда не возвращайте bool, чтобы обозначить успех операции, если вы можете вернуть Option или Result. Типы несут больше информации.

Подводный камень: Игнорирование Result

В Rust предупреждения компилятора — это не просто шум. Если функция возвращает Result, она помечена атрибутом #[must_use]. Это значит, что игнорирование возвращаемого значения вызовет предупреждение.

use std::fs::File;
use std::io::Write;

fn main() {
    let mut file = File::create("log.txt").unwrap();
    // Предупреждение: unused `Result` that must be used
    file.write_all(b"Important data");
}

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

Как исправить?

Всегда обрабатывайте Result. Если вы абсолютно уверены, что ошибку можно игнорировать (подумайте дважды!), сделайте это явно:

// Явное игнорирование через паттерн _
let _ = file.write_all(b"Data");

Но лучше обработать ошибку или использовать expect.

Лучшая практика: Newtype Pattern для ошибок

В библиотечном коде избегайте возврата Result<T, String> или Result<T, Box<dyn Error>>. Это называется «stringly typed errors». Вызывающий код не сможет программно отреагировать на разные типы ошибок, ему придется парсить строку.

Создавайте свои перечисления (enum) для ошибок. Это делает API понятным и предсказуемым.

enum ConfigError {
    Io(std::io::Error),
    Parse(std::num::ParseIntError),
    MissingField(String),
}

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

Заключение

Идиоматичный Rust — это баланс между безопасностью и читаемостью.

  1. Избегайте unwrap, предпочитайте expect или ?.
  2. Заменяйте вложенные match на плоские цепочки комбинаторов.
  3. Используйте мощь итераторов (filter_map, collect) для работы с коллекциями результатов.
  4. Не проверяйте наличие данных перед их получением — пытайтесь получить и обрабатывайте Option.

Следование этим правилам сделает ваш код профессиональным и надежным. Вы перестанете бороться с языком и начнете использовать его сильные стороны.