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

TLDR: Что за утка-дублер?

«Утка‑дублер» это тип, который реализует часть трейтов популярного типа с одинаковым результатом. В моем случае это был тип MultiError, который, как позже оказалось, был аналогичен syn::Error и не добавлял ничего нового. Я попросту удалил свой тип, не теряя никакого функционала и мир стал только лучше.

Я сохранил код, прежде чем удалить его. Ниже — история процесса его создания и последующего откровения.

Полная версия - контекст

В последнее время я погружался в процедурные макросы, вы можете почитать про последнее расследование в статье «A Daft proc‑macro trick: How to Emit Partial‑Code + Errors». Мне бы хотелось, чтобы авторы процедурных макросов возвращали как можно больше ошибок (вместо лишь последней), а также я большой фанат юнит‑тестов. Мы хотелось добавить возвращаемый тип из своих функций, который говорил бы «я возвращаю много ошибок» и мне хотелось, чтобы этот тип был юнит‑тестируем.

В коде я собирал ошибки с помощью VecDeque. Благодаря этому их легко собирать в одну syn::Error:

if let Some(mut error) = errors.pop_front() {
    for e in errors {
        error.combine(e);
    }
    Some(error)
} else {
    None
}

Но мне не хотелось возвращать Result<T, VecDeque<syn::Error>>из своих функций, поскольку это не гарантирует, что ошибка не окажется пустой. Хороший тип должен делать невозможное состояние неотображаемым.

Начнем с данных

Чтобы гарантировать, что в моем типе есть хотя бы одна ошибка, я отделил первую ошибку от остальных в коллекции. Даже если контейнер пуст, определение типа гарантирует, что мы сможем преобразовать его в syn::Error.

/// Guaranteed to hold at least one [`syn::Error`]
///
/// The [`syn::Error`] can hold multiple errors
/// through [`syn::Error::combine()`], however it
/// does not allow the receiver to distinguish
/// between the two cases, which makes testing
/// less precise. Using this type is a stronger
/// hint that the function accumulates errors.
///
#[derive(Debug, Clone)]
pub(crate) struct MultiError {
    first: syn::Error,
    rest: VecDeque<syn::Error>,
}

impl MultiError {
    pub(crate) fn from(mut errors: VecDeque<syn::Error>) -> Option<Self> {
        if let Some(first) = errors.pop_front() {
            let rest = errors;
            Some(Self { first, rest })
        } else {
            None
        }
    }
}

Осторожно: То, что написано в документации не всегда правда

Обратите внимание на видимость, по умолчанию я использую pub(crate) для структуры и относящихся к ней функций, но не для полей (first и rest). Когда у меня нет уверенности в дизайне, обеспечение доступа через функции делает дальнейшее изменение проще.

Этот тип позволил добавить следующие функции:

pub(crate) fn parse_attrs<T>(
        attrs: &[syn::Attribute]
    ) -> Result<Vec<T>, MultiError>
where
    T: syn::parse::Parse,
{
    let mut errors = VecDeque::new();
    // ...
    if let Some(error) = MultiError::from(errors) { // <== HERE
        Err(error)
    } else {
        Ok(
        // ...
        )
    }
}

Этот код по сути означает «я принимаю любой слайс syn::Attribute и преобразовываю этот атрибут в вектор T или возвращаю одну или более ошибок syn». Пока все идет неплохо.

Но моему макросу нужен syn::Error для генерации токенов ошибок, а функция возвращает MultiError. Так что мне нужен способ преобразовать мой тип в syn::Error.

Добавляю into

Основываясь на характеристиках типа, мы знаем что всегда можем преобразовать его в syn::Error, так что можно добавить это поведение путем реализации Into<syn::Error>:

impl From<MultiError> for syn::Error {
    fn from(value: MultiError) -> Self {
        let MultiError { mut first, rest } = value;
        for e in rest {
            first.combine(e);
        }
        first
    }
}

В качестве приятного бонуса оператор ? будет неявно вызывать into(), что позволяет использовать следующие конструкции:

fn check_logic(...) -> Result<(), syn::Error> {
  // ...
  let result: Result<(), MultiError> = logic();
  let _ = result?; // <=== Convert MultiError to syn::Error implicitly
  // ...
}

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

Добавляю Display

Для вывода ошибки типу потребуется реализация std::fmt::Display:

impl std::fmt::Display for MultiError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        Into::<syn::Error>::into(self.clone()).fmt(f)
    }
}

Не особо элегантно, но просто, а главное работает. Этот код вызывается только в тестах.

Добавляю итерацию

Чтобы выводить несколько ошибок в тестах, я решил реализовать трейт IntoIterator:

impl IntoIterator for MultiError {
    type Item = syn::Error;
    type IntoIter = <VecDeque<syn::Error> as IntoIterator>::IntoIter;

    fn into_iter(self) -> Self::IntoIter {
        let MultiError { first, mut rest } = self;
        rest.push_front(first);
        rest.into_iter()
    }
}

Данный код говорит нам, что теперь мы можем преобразовать нашу структуру в серию syn::Error. Поскольку у нас уже есть VecDeque и я знал, что он реализует тот же трейт, я просто добавил свою логику поверх него. Это позволило создавать такие тесты:

    #[test]
    fn test_captures_many_field_errors() {
        let field: syn::Field = syn::parse_quote! {
            #[cache_diff(unknown)]
            #[cache_diff(unknown)]
            version: String,
        };
        let result: Result<Vec<ParseAttribute>, MultiError> =
            crate::shared::parse_attrs::<ParseAttribute>(&field.attrs);

        assert!(
            result.is_err(),
            "Expected {result:?} to be err but it is not"
        );
        let error = result.err().unwrap();
        assert_eq!(2, error.into_iter().count()); // <== into_iter() HERE
    }

    enum ParseAttribute {
        //...
    }
    impl syn::parse::Parse for ParseAttribute {
        // ...
    }

Он парсит одно поле с несколькими syn::Attribute в нем. В данном случае cache_diff(unknown) — невалидный атрибут и я хочу убедиться, что выполнение не происходит после первого вхождения. Код преобразует результат в итератор и проверяет, что в нем два элемента. Отлично!

Итераторный упс

Хотя пример кода выше работал нормально, применяя данный паттерн дальше я наткнулся на некорректное поведение:

    #[test]
    fn test_captures_many_field_errors() {
        let result = ParseContainer::from_derive_input(&syn::parse_quote! {
            struct Metadata {
                #[cache_diff(unknown)]
                #[cache_diff(unknown)]
                version: String,

                #[cache_diff(unknown)]
                #[cache_diff(unknown)]
                name: String
            }
        });

        assert!(
            result.is_err(),
            "Expected {result:?} to be err but it is not"
        );
        let error = result.err().unwrap();
        assert_eq!(4, error.into_iter().count()); // <== FAILED here
    }

Текст ошибки говорил, что я возвращал лишь 2 ошибки вместо 4. Я перенес код в интеграционный тест trybuild и увидел 4. И тут меня осенило, что в какой‑то момент я помещал несколько ошибок в один syn::Error, а его — в MultiError. По сути у меня вышел вложенный MultiError.

Если звучит сложно, вот псевдокод:

let mut errors: VecDeque<syn::Error> = VecDeque::new();

match call_fun() { // Returns a MultiError
    Ok(_) => todo!(),
    // Combines it into a single `syn::Error`
    Error(error) => errors.push_back(error.into())
}
// ...

if let Some(error) = MultiError::from(errors) {
    Err(error)
} else {
    Ok(
    // ...
    )
}

По сути, мой MultiError позволял то, что, я думал, было невозможно проверить. Каждый экземпляр syn::Error мог содержать в себе N ошибок.

Озакряние

Проходя через все стадии принятия горя на тему фундаментального недостатка моего прелестного типа, мне пришла идея — может быть, стоит заапстримить изменение для вывода нескольких объединенных ошибок из syn::Error. Интерфейс IntoIterator казался мне хорошим кандидатом. К моему удивлению, когда я открыл документацию, impl IntoIterator для syn::Error уже существовал все это время. Я просто пропустил его.

Осознав, что для syn::Error уже реализованы все нужные мне трейты, я смог просто заменить все MultiError на syn::Error, а MultiError::from_error на функцию, возвращающую Option. И, не меняя ничего другого в логике, код успешно скомпилировался. Это подтвердило мои подозрения, что я написал утиный дубль уже существующей структуры.

Единственное, что привносил мой MultiError — намек на то, что функция написана с расчетом на сбор ошибок, но не гарантировал, что логика сборки корректна. Этот намек был недостаточен, чтобы оправдать весь лишний код. Для этой цели достаточно алиаса типа.

Плохая утка

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

Хорошая утка

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

Третья причина оставить утку — стабильность интерфейса. Если вы беспокоитесь, что после добавления типа в библиотеку его придется изменить, может быть полезно обернуть его, чтобы пользователю не пришлось менять свой код, если его реализация изменится.

Документация

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

В моем случае я явно применял syn::Error и даже реализовал Into<syn::Error>. Это два явных признака, что мне стоило перепроверить свои заявления и поискать нужные фичи в трейтах.

Практикуйте ваш утиный

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

Помимо «установки быть лучше» и «написания статьи для рефлексии» я подумал, что было бы неплохо, если бы это поведение было показано на примере, так что создал PR в репозитории syn с примерами для syn::Error::combine. Я не считаю, что нам нужно документировать каждый пример использования, но этот функционал итерирования неплохо показывает, как работает объединение. Надеюсь, документацию примут положительно, а не как дурное предзнаменование.

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

Комментарии (1)


  1. Krypt
    30.05.2025 18:07

    pub(crate) struct MultiError {
        first: syn::Error,
        rest: VecDeque<syn::Error>,
    }
    

    Я не rust разработчик, но это тоже выглядит как плохой подход: он создаёт дополнительный edge case который требует отдельного кода для обработки