Привет, Хабр!

Воодушевившись статьёй std::expected в C++23: гайд по миграции с исключений на функциональный error handling, представляю собственную с переписанными примерами на Rust. Код в статье итеративно переписывается и улучшается.


О себе: код на С++ вижу только в работах первокурсников, которым дальше С++11 пока не рассказывают. В качестве хобби иногда пишу на Rust.

Первый пример из статьи демонстрирует замену try/catch на std::expected, процитирую:

#include <expected>
#include <string>
#include <iostream>

enum class ParseError {
    InvalidFormat,
    OutOfRange,
    EmptyInput
};

std::expected<int, ParseError> parse_positive_int(const std::string& input) {
    if (input.empty()) {
        return std::unexpected(ParseError::EmptyInput);
    }
    try {
        int value = std::stoi(input);
        if (value < 0) {
            return std::unexpected(ParseError::OutOfRange);
        }
        return value;
    } catch (...) {
        return std::unexpected(ParseError::InvalidFormat);
    }
}

int main() {
    auto result = parse_positive_int("42");
    if (result) {
        std::cout << "Parsed: " << *result << '\n';
    } else {
        std::cout << "Error code: " << static_cast<int>(result.error()) << '\n';
    }
}

А так как на Rust новый код не пишут, а только переписывают (шутка), то повторим реализацию, стараясь придерживаться идеи точь-в-точь:

#[derive(Debug)]
enum ParseError {
    InvalidFormat,
    OutOfRange,
    EmptyInput,
}

fn parse_positive_int(input: &String) -> Result<i32, ParseError> {
    if input.is_empty() {
        return Err(ParseError::EmptyInput);
    }
    match input.parse() {
        Ok(value) if value < 0 => Err(ParseError::OutOfRange),
        Ok(value) => Ok(value),
        Err(_) => Err(ParseError::InvalidFormat),
    }
}

fn main() {
    let result = parse_positive_int(&"42".to_string());
    if result.is_ok() {
        println!("Parsed: {}", result.unwrap());
    } else {
        println!("Error code: {}", result.unwrap_err() as u32);
    }
}

В чём нечестность: в Rust нет исключений, и по моему мнению он этим больше похож на Си. Так что функция parse_positive_int просто использует готовую реализацию и обрабатывает ошибки. Функция main тоже занимается обработкой ошибок, но более неаккуратно, вызовы методов .unwrap() и .unwrap_err() будут паниковать, если в Result окажется ошибка вместо значения и наоборот.

Ещё небольшое отличие от С++ кода: пришлось реализовать характеристику Debug у собственного типа, так как сигнатура метода .unwrap() требовала её, предположительно, чтобы иметь возможность распечатать ошибку при панике.

Немного внесём улучшения, и двинемся дальше:

fn parse_positive_int(input: &str) -> Result<i32, ParseError> {
    if input.is_empty() {
        return Err(ParseError::EmptyInput);
    }
    match input.parse() {
        Ok(value) if value < 0 => Err(ParseError::OutOfRange),
        Ok(value) => Ok(value),
        Err(_) => Err(ParseError::InvalidFormat),
    }
}

fn main() {
    let result = parse_positive_int("42");
    match result {
        Ok(value) => println!("Parsed: {}", value),
        Err(err) => println!("Error code: {}", err as u32),
    }
}

Изменён тип аргумента на строковый срез, в нашем примере создавался временный объект строки, что не есть хорошо. И более безопасная обработка ошибок без паник. Ещё применение as не совсем надёжно, одним из более правильных вариантов было бы написать метод, возвращающий числовой код ошибки.

auto result = parse_positive_int("abc");
int value = result.value_or(0);

Аналогично на Rust:

let result = parse_positive_int("42");
let value = result.unwrap_or(0);

Разве что можно сказать, что обе переменные типа "auto".

Пример: запрос пользователя из базы, проверка прав доступа, формирование DTO.

struct User {
    int id;
    std::string name;
    int role_id;
};

enum class AppError {
    UserNotFound,
    AccessDenied,
    DatabaseError
};

std::expected<User, AppError> load_user(int id);
std::expected<bool, AppError> check_admin(const User& user);
std::expected<UserDto, AppError> to_dto(const User& user);

// Цепочка через and_then
std::expected<UserDto, AppError> get_admin_dto(int id) {
    return load_user(id)
        .and_then([](const User& user) -> std::expected<User, AppError> {
            return check_admin(user)
                .and_then([&user](bool is_admin) -> std::expected<User, AppError> {
                    if (!is_admin) return std::unexpected(AppError::AccessDenied);
                    return user;
                });
        })
        .and_then(to_dto);
}

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

struct User {
    id: i32,
    name: String,
    role_id: i32,
}

enum AppError {
    UserNotFound,
    AccessDenied,
    DatabaseError,
}

// TODO
struct UserDto;

fn load_user(id: i32) -> Result<User, AppError> {
    todo!()
}

fn check_admin(user: &User) -> Result<bool, AppError> {
    todo!()
}

fn to_dto(user: &User) -> Result<UserDto, AppError> {
    todo!()
}

fn get_admin_dto(id: i32) -> Result<UserDto, AppError> {
    load_user(id)
        .and_then(|user| {
            check_admin(&user).and_then(|is_admin| {
                if !is_admin {
                    Err(AppError::AccessDenied)
                } else {
                    Ok(user)
                }
            })
        })
        .and_then(|user| to_dto(&user))
}

Реализация незначительно отличается в анонимной функции, которая обрабатывает результат, вернувшийся из load_user(). В С++ объект неявно преобразуется в константную ссылку, в Rust всё честно и приходится руками брать ссылку на объект в нескольких местах. Ах, да, нигде не указаны типа данных - компилятор выводит их сам из сигнатур функций.

Убираем функциональное программирование, оставляем железобетонное процедурное:

fn get_admin_dto(id: i32) -> Result<UserDto, AppError> {
    let user = match load_user(id) {
        Ok(user) => user,
        Err(err) => return Err(err),
    };
    let is_admin = match check_admin(&user) {
        Ok(is_admin) => is_admin,
        Err(err) => return Err(err),
    };
    if !is_admin {
        return Err(AppError::AccessDenied);
    }
    to_dto(&user)
}

Минус одно присваивание (из Ok(user))! Но обрабатывать ошибки утомительно, особенно, если надо лишь перекинуть ошибку выше.

И...

fn get_admin_dto(id: i32) -> Result<UserDto, AppError> {
    let user = load_user(id)?;
    let is_admin = check_admin(&user)?;
    if !is_admin {
        return Err(AppError::AccessDenied);
    }
    to_dto(&user)
}

Уже чище и на этом можно было бы остановиться, но давайте сначала заменим to_dto():

// TODO
struct UserDto;

impl TryFrom<&User> for UserDto {
    type Error = AppError;
    fn try_from(user: &User) -> Result<Self, Self::Error> {
        todo!()
    }
}

fn get_admin_dto(id: i32) -> Result<UserDto, AppError> {
    let user = load_user(id)?;
    let is_admin = check_admin(&user)?;
    if !is_admin {
        return Err(AppError::AccessDenied);
    }
    UserDto::try_from(&user)
}

Вместо того, чтобы помнить, какую функцию нужно вызвать для мапинга из User в UserDto, просто пишем собственную реализацию TryFrom.

И напоследок изменим check_admin, хотя это обсуждаемо, но просто в качестве демонстрации, что возвращаемое значение может отсутствовать:

const ROLE_ID_ADMIN: i32 = 1;

fn check_admin(user: &User) -> Result<(), AppError> {
    let is_admin = user.role_id == ROLE_ID_ADMIN;
    if !is_admin {
        return Err(AppError::AccessDenied);
    }
    Ok(())
}

fn get_admin_dto(id: i32) -> Result<UserDto, AppError> {
    let user = load_user(id)?;
    check_admin(&user)?;
    UserDto::try_from(&user)
}

Три строчки кода в функции get_admin_dto(). Nice!

Возврат разных типов ошибок

Yay, первый заголовок в статье!

Опять процитируем пример из статьи:

enum class DbError { ConnectionFailed, QueryFailed, RecordNotFound };
enum class ServiceError { UserNotFound, InvalidState, DependencyFailed };
enum class ApiError { NotFound, BadRequest, InternalError };

std::expected<User, DbError> repo_find_user(int id);

std::expected<User, ServiceError> service_get_user(int id) {
    return repo_find_user(id)
        .transform_error([](DbError db_err) -> ServiceError {
            switch (db_err) {
                case DbError::RecordNotFound: return ServiceError::UserNotFound;
                case DbError::ConnectionFailed: return ServiceError::DependencyFailed;
                case DbError::QueryFailed: return ServiceError::DependencyFailed;
            }
            return ServiceError::DependencyFailed;
        });
}

std::expected<UserResponse, ApiError> handler_get_user(int id) {
    return service_get_user(id)
        .transform([](const User& user) { return UserResponse{user}; })
        .transform_error([](ServiceError svc_err) -> ApiError {
            switch (svc_err) {
                case ServiceError::UserNotFound: return ApiError::NotFound;
                case ServiceError::InvalidState: return ApiError::BadRequest;
                case ServiceError::DependencyFailed: return ApiError::InternalError;
            }
            return ApiError::InternalError;
        });
}

"Дубовая" реализация, стараясь придерживаться идеи точь-в-точь:

struct User {
    id: i32,
    name: String,
    role_id: i32,
}

struct UserResponse {
    id: i32,
    name: String,
}

impl UserResponse {
    fn new(user: &User) -> Self {
        Self {
            id: user.id,
            name: user.name.clone(),
        }
    }
}

enum DbError {
    ConnectionFailed,
    QueryFailed,
    RecordNotFound,
}
enum ServiceError {
    UserNotFound,
    InvalidState,
    DependencyFailed,
}
enum ApiError {
    NotFound,
    BadRequest,
    InternalError,
}

fn repo_find_user(id: i32) -> Result<User, DbError> {
    todo!()
}

fn service_get_user(id: i32) -> Result<User, ServiceError> {
    repo_find_user(id).map_err(|db_err| match db_err {
        DbError::ConnectionFailed => ServiceError::DependencyFailed,
        DbError::QueryFailed => ServiceError::DependencyFailed,
        DbError::RecordNotFound => ServiceError::UserNotFound,
    })
}

fn handler_get_user(id: i32) -> Result<UserResponse, ApiError> {
    service_get_user(id)
        .map(|user: User| UserResponse::new(&user))
        .map_err(|svc_err| match svc_err {
            ServiceError::UserNotFound => ApiError::NotFound,
            ServiceError::InvalidState => ApiError::BadRequest,
            ServiceError::DependencyFailed => ApiError::InternalError,
        })
}

Многобукв. Вынесем реализацию мапинга ошибок во From:

impl From<DbError> for ServiceError {
    fn from(value: DbError) -> Self {
        match value {
            DbError::ConnectionFailed => ServiceError::DependencyFailed,
            DbError::QueryFailed => ServiceError::DependencyFailed,
            DbError::RecordNotFound => ServiceError::UserNotFound,
        }
    }
}

impl From<ServiceError> for ApiError {
    fn from(value: ServiceError) -> Self {
        match value {
            ServiceError::UserNotFound => ApiError::NotFound,
            ServiceError::InvalidState => ApiError::BadRequest,
            ServiceError::DependencyFailed => ApiError::InternalError,
        }
    }
}

fn service_get_user(id: i32) -> Result<User, ServiceError> {
    repo_find_user(id).map_err(ServiceError::from)
}

fn handler_get_user(id: i32) -> Result<UserResponse, ApiError> {
    service_get_user(id)
        .map(|user: User| UserResponse::new(&user))
        .map_err(ApiError::from)
}

Такая реализация уже выглядит проще, если не заглядывать внутрь From. Это всё ещё может быть утомительным, поэтому есть готовые библиотеки, которые берут часть работы по генерации кода через процедурные макросы (ПРЕДУПРЕЖДЕНИЕ: логика мапинга ошибок другая!).

use thiserror::Error;

#[derive(Error, Debug)]
enum DbError {
    #[error("Connection failed")]
    ConnectionFailed,
    #[error("Query failed")]
    QueryFailed,
}

#[derive(Error, Debug)]
enum ServiceError {
    #[error("User not found")]
    UserNotFound,
    #[error("Invalid state")]
    InvalidState,
    #[error("Dependency failed: {0}")]
    DependencyFailed(#[from] DbError),
}

#[derive(Error, Debug)]
enum ApiError {
    #[error("Not found")]
    NotFound,
    #[error("Bad request")]
    BadRequest,
    #[error("Internal error: {0}")]
    InternalError(#[from] ServiceError),
}

fn service_get_user(id: i32) -> Result<User, ServiceError> {
    Ok(repo_find_user(id)?)
}

fn handler_get_user(id: i32) -> Result<UserResponse, ApiError> {
    Ok(UserResponse::new(&service_get_user(id)?))
}

Небольшие минусы: пришлось реализовать Display для каждого варианта ошибки.

Если уж совсем лень обрабатывать ошибки, то есть библиотека anyhow, но там внутри Box<dyn Error>...

use anyhow::{Result, anyhow};

#[derive(Debug)]
enum DbError {
    ConnectionFailed,
    QueryFailed,
}

impl std::fmt::Display for DbError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            DbError::ConnectionFailed => write!(f, "Connection failed"),
            DbError::QueryFailed => write!(f, "Query failed"),
        }
    }
}

impl std::error::Error for DbError {}

fn repo_find_user(id: i32) -> Result<User> {
    Err(anyhow!(DbError::ConnectionFailed))
}

fn service_get_user(id: i32) -> Result<User> {
    Ok(repo_find_user(id)?)
}

fn handler_get_user(id: i32) -> Result<UserResponse> {
    Ok(UserResponse::new(&service_get_user(id)?))
}

Богатые типы ошибок с деталями

Цитирование примера на С++:

struct ValidationError {
    std::string field;
    std::string message;
    std::optional<std::string> received_value;
};

struct NotFoundError {
    std::string entity_type;
    std::string identifier;
};

struct ApiError {
    int http_status;
    std::string code;
    std::string message;
    std::vector<ValidationError> validation_errors;
};

std::expected<int, ApiError> parse_age(const std::string& input) {
    if (input.empty()) {
        return std::unexpected(ApiError{
            .http_status = 400,
            .code = "EMPTY_FIELD",
            .message = "Age cannot be empty",
            .validation_errors = {
                {"age", "Required field is missing", std::nullopt}
            }
        });
    }
    
    try {
        int value = std::stoi(input);
        if (value < 0 || value > 150) {
            return std::unexpected(ApiError{
                .http_status = 400,
                .code = "OUT_OF_RANGE",
                .message = "Age must be between 0 and 150",
                .validation_errors = {
                    {"age", "Value out of range", input}
                }
            });
        }
        return value;
    } catch (...) {
        return std::unexpected(ApiError{
            .http_status = 400,
            .code = "INVALID_FORMAT",
            .message = "Age must be a valid integer",
            .validation_errors = {
                {"age", "Could not parse as integer", input}
            }
        });
    }
}

Повторим реализацию на Rust, больше не стараясь придерживаться идеи точь-в-точь:

struct ValidationError {
    field: String,
    message: String,
    received_value: Option<String>,
}

struct ApiError {
    http_status: u16,
    code: String,
    message: String,
    validation_errors: Vec<ValidationError>,
}

fn parse_age(input: &str) -> Result<i32, ApiError> {
    if input.is_empty() {
        return Err(ApiError {
            http_status: 400,
            code: "EMPTY_FIELD".to_string(),
            message: "Age cannot be empty".to_string(),
            validation_errors: vec![ValidationError {
                field: "age".to_string(),
                message: "Required field is missing".to_string(),
                received_value: None,
            }],
        });
    }

    match input.parse() {
        Ok(value) if value < 0 || value > 150 => Err(ApiError {
            http_status: 400,
            code: "OUT_OF_RANGE".to_string(),
            message: "Age must be between 0 and 150".to_string(),
            validation_errors: vec![ValidationError {
                field: "age".to_string(),
                message: "Value out of range".to_string(),
                received_value: Some(input.to_string()),
            }],
        }),
        Ok(value) => Ok(value),
        Err(_) => Err(ApiError {
            http_status: 400,
            code: "INVALID_FORMAT".to_string(),
            message: "Age must be a valid integer".to_string(),
            validation_errors: vec![ValidationError {
                field: "age".to_string(),
                message: "Could not parse as integer".to_string(),
                received_value: None,
            }],
        }),
    }
}

Зато можно буквально одной строкой (и библиотекой serde) сериализовать объекты в json или любой мыслимый формат данных:

use serde::Serialize;

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ValidationError {
    field: String,
    message: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    received_value: Option<String>,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ApiError {
    http_status: u16,
    code: String,
    message: String,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    validation_errors: Vec<ValidationError>,
}

fn main() {
    let age = parse_age("152");
    if let Err(err) = age {
        let json = serde_json::to_string_pretty(&err).unwrap();
        println!("{}", json);
    }
}
{
  "httpStatus": 400,
  "code": "OUT_OF_RANGE",
  "message": "Age must be between 0 and 150",
  "validationErrors": [
    {
      "field": "age",
      "message": "Value out of range",
      "receivedValue": "152"
    }
  ]
}