Nano Memories
19 июл 2025

Минимальные Ограничения Трейтов: Зачем и Как?

Золотое правило любых интерфейсов в широком смысле и, в частности, трейтов в Rust - не требовать больше, чем необходимо

В этой статье мы разберём:

Пример: Итератор-принтер

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

Итак, тип оборачивает какой-то существующий итератор, поэтому нам понадобится обобщенный параметр для внутреннего типа итератора, пусть это будет что-то вроде I: Iterator. Также мы хотим печатать его элементы, поэтому нам будет нужна реализация трейта Display для элементов итератора. В итоге тип можно объявить как-то так

use std::fmt::Display;

pub struct Printer<I>
where
    I: Iterator<Item: Display>,
{
    iter: I,
}

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

impl<I> Iterator for Printer<I>
where
    I: Iterator<Item: Display>,
{
    type Item = I::Item;

    fn next(&mut self) -> Option<Self::Item> {
        let item = self.iter.next()?;
        println!("{item}");
        Some(item)
    }
}

Для создания нового экземпляра нашего типа добавим конструктор. Также добавим метод into_inner если пользователю понадобится вернуть свой итератор обратно

impl<I> Printer<I>
where
    I: Iterator<Item: Display>,
{
    pub fn new(iter: I) -> Self {
        Self { iter }
    }

    pub fn into_inner(self) -> I {
        self.iter
    }
}

Давайте также реализуем парочку полезных трейтов раз уж мы пишем что-то вроде библиотеки. Например, вдруг пользователю понадобится склонировать значение нашего типа? Реализуем Clone, только я сделаю это явно, а не через derive, исключительно в иллюстративных целях. Чтобы склонировать содержимое потребуем от него реализацию трейта Clone

impl<I> Clone for Printer<I>
where
    I: Iterator<Item: Display> + Clone,
{
    fn clone(&self) -> Self {
        Self {
            iter: self.iter.clone(),
        }
    }
}

В чём проблема?

Что же, на первый взгляд всё кажется логичным, но уже на этом этапе можно увидеть проблемы такого подхода. Любой impl который касается нашего типа Printer<I> неизбежно требует I: Iterator<Item: Display>. Если убрать это ограничение любой код перестанет компилироваться, даже тот, где никак не используется Iterator:

impl<I> Printer<I> {
    //  ^^^^^^^^^^ `I` is not an iterator
    pub fn new(iter: I) -> Self {
        Self { iter }
    }

    pub fn into_inner(self) -> I {
        self.iter
    }
}

Как минимум это загрязнет код ненужными подробностями. Зачем нам требовать I: Iterator<Item: Display> просто чтобы сконструировать, разобрать или склонировать значение типа? Технически, ничего не мешает сконструировать Printer<I> с любым типом. Тем более в реализации метода into_inner нам нужно просто вытащить значение, с ним не нужно больше ничего делать, не нужно итерироваться и тем более не нужно печатать отдельные элементы.

Код нашей библиотеки становится сложнее на пустом месте. Более того, все эти сигнатуры будут видны в документации крейта, даже если пользователь не будет читать исходный код, он всё равно будет думать про ограничения там, где они не нужны. Уже только это стоит того, чтобы убрать ограничения у всех impl-ов, кроме тех где эти ограничения действительно необходимы: в частности impl<I> Iterator for Printer<I> иначе мы просто не сможем реализовать трейт

Нужно ли ограничивать параметры типа?

Тем более никакие ограничения не нужны у объявления структуры. При создании struct или enum мы описываем исключительно данные, в отличии от функций/методов в структурах вообще нет никакого кода. Мы практически никак не сможем использовать факт того, что обобщенный параметр это итератор или какой-то ещё трейт. Разве что в довольно исключительных случаях: хороший пример тип Peekable из std. Обратите внимание на то, что он требует I: Iterator у самого типа и как следствие у всех методов и реализаций трейтов

Зачем это нужно? Откроем исходный код и посмотрим как именно объявлен тип Peekable

pub struct Peekable<I>
where
    I: Iterator,
{
    iter: I,
    /// Remember a peeked value, even if it was None.
    peeked: Option<Option<I::Item>>,
}

Тут раскрывается предназначение типа Peekable - это итератор, который может заглядывать на один элемент вперёд. Чтобы это работало нужно где-то сохранять элемент. Какой будет тип у этого элемента? Разумеется I::Item из трейта итератора, но тогда нам придётся потребовать чтобы I реализовывал трейт Iterator, иначе откуда взять Item? В данном случае ограничение трейта полностью оправданно

Но что касается нашего гипотетического типа Printer? Как мы выяснили, навешивать ограничение на обобщенный параметр вовсе необязательно, давайте упростим определение типа

pub struct Printer<I> {
    iter: I,
}

Нужно ли ограничивать impl типа?

Вся суть нашего типа Printer в том, чтобы быть итератором. Без этого ограничения тип не имеет смысла, так может стоит указать требование I: Iterator<Item: Display> хотя бы для наглядности? Пусть это загрязнит код библиотеки, но зато сразу будет видно намерение для чего этот тип используется? В конце концов, пусть я как автор библиотеки напишу больше кода, зато пользователю будет проще работать с моими типами и трейтами. Например, что если пользователь создаст значение Printer-а внутри которого будет не итератор, ведь будет лучше если он получит ошибку заранее, чем только тогда, когда попытается непосредственно воспользоваться итератором?

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

let n = (0..6).map("?").count();

Он не компилируется, так как чтобы вызвать метод count у итогового типа, нужно чтобы сам self был итератором

Но что тут не так? Если бы метод map никак не проверял нужные ограничения, а просто конструировал свой адаптер, то при вызове count я получил бы такое сообщение:

error[E0599]: the method `count` exists for struct `Map<Range<{integer}>, &str>`, but its trait bounds were not satisfied
  --> src/main.rs:2:29
   |
2  |     let n = (0..6).map("?").count();
   |                             ^^^^^ method cannot be called on `Map<Range<{integer}>, &str>` due to unsatisfied trait bounds
   |
  ::: /home/nano/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/iter/adapters/map.rs:61:1
   |
61 | pub struct Map<I, F> {
   | -------------------- doesn't satisfy `Map<std::ops::Range<{integer}>, &str>: Iterator`
   |
   = note: the following trait bounds were not satisfied:
           `<&str as FnOnce<({integer},)>>::Output = _`
           which is required by `Map<std::ops::Range<{integer}>, &str>: Iterator`
           `&str: FnMut<({integer},)>`
           which is required by `Map<std::ops::Range<{integer}>, &str>: Iterator`
           `Map<std::ops::Range<{integer}>, &str>: Iterator`
           which is required by `&mut Map<std::ops::Range<{integer}>, &str>: Iterator`

Благо данный пример довольно простой я всё же могу вычленить изначальную причину проблемы, а именно строку which is required by 'Map<std::ops::Range<{integer}>, &str>: Iterator' '&str: FnMut<({integer},)>' где я увижу что &str не реализует трейт FnMut

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

К счастью метод map у итератора накладывает необходимые ограничения заранее и прежде чем получить ошибку выше, я увижу это:

error[E0277]: expected a `FnMut({integer})` closure, found `&str`
   --> src/main.rs:2:24
    |
2   |     let n = (0..6).map("?").count();
    |                    --- ^^^ expected an `FnMut({integer})` closure, found `&str`
    |                    |
    |                    required by a bound introduced by this call
    |
    = help: the trait `Fn({integer})` is not implemented for `str`
    = note: required for `&str` to implement `FnMut({integer})`
note: required by a bound in `map`
   --> /home/nano/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/iter/traits/iterator.rs:748:12
    |
745 |     fn map<B, F>(self, f: F) -> Map<Self, F>
    |        --- required by a bound in this associated function
...
748 |         F: FnMut(Self::Item) -> B,
    |            ^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `Iterator::map`

Уже намного понятнее: &str не реализует FnMut о чём написано в самом начале лога ошибки

Сам map реализован так:

pub trait Iterator {
    /**/

    fn map<B, F>(self, f: F) -> Map<Self, F>
    where
        Self: Sized,
        F: FnMut(Self::Item) -> B,
    {
        Map::new(self, f)
    }

    /**/
}

Тут видно что в самой сигнатуре метода требуется F: FnMut(Self::Item) -> B для параметра передаваемой функции. Хотя, фактически, сконструировать значение Map можно и без этого ограничения. Этот пример отлично показывает в каких случаях полезно отойти от правила минимального итерфейса и потребовать большего там, где технически это не является необходимым

Вернёмся к нашему примеру. Для более понятных сообщений об ошибках, было бы разумно оставить ограничение I: Iterator<Item: Display>, но только для конструктора

Самое оптимальное решение для нашего публичного API это такой impl:

impl<I> Printer<I> {
    pub fn new(iter: I) -> Self
    where
        // Ограничение только тут
        I: Iterator<Item: Display>,
    {
        Self { iter }
    }

    pub fn into_inner(self) -> I {
        self.iter
    }
}

Упрощаем сигнатуры методов

Это хороший компромис, теперь мы не требуем сильно много там где это не нужно, но и заранее предупреждаем пользователя, что создавать значение типа имеет смысл только если I это итератор, причём не просто какой-то, а такой что Item: Display. При этом, если значение уже создано, ничего не мешает вызвать у него into_inner. Это сделает пользовательский код проще в некоторых местах

Например, что если пользователь напишет такую функцию?

fn debug_inner<I>(printer: Printer<I>)
where
    I: Debug,
{
    println!("{:?}", printer.into_inner());
}

Тут пользовательский код требует ровно то что необходимо, а именно Debug для inner значения и всё. В ранней версии библиотеки с ненужными ограничениями пришлось бы писать так:

fn debug_inner<I>(printer: Printer<I>)
where
    I: Iterator<Item: Display> + Debug,
    // ^^^^^^^^^^^^^^^^^^^^^^^ это ограничение не необходимо для реализации
{
    println!("{:?}", printer.into_inner());
}

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

Ещё один вариант использования: что если у типа вообще не вызываются никакие методы? Что если пользователь просто хочет содержать значения нашего типа в какой-то другой структуре?

use std::collections::HashMap;

fn check_key_hello<I>(m: &HashMap<&str, Printer<I>>) -> bool {
    m.contains_key("hello")
}

Не будь ограничения минимальны, а вместо этого были бы добавлены на всю структуру, тут потребовалось бы также уточнять I: Iterator<Item: Display>, хотя в этом нет никакого смысла. Опять же HashMap тут скорее для примера, вместо него может быть, например, какой-то пользовательский тип который содержит Printer<I> как поле или что угодно. Так или иначе, потенциально существует много кода, который не использует типы по их прямому назначению, а вместо этого как-то работает с ними опосредованно

Теперь рассмотрим такой пользовательский код:

fn make_boxed_printer<I>(iter: I) -> Box<Printer<I>>
where
    I: Iterator<Item: Display>,
{
    Box::new(Printer::new(iter))
}

Нужен ли тут I: Iterator<Item: Display>? Технически, чтобы код скомпилировался - нет. Но ранее мы решили что требовать реализацию итератора на конструкторе new может быть полезно

Упрощаем сигнатуры типов и трейтов

Надеюсь я смог показать сомнительность подобного кода:

struct Foo<T>(Vec<T>)
where
    T: Display;

Ну а тем более, если вы видите что-то такое:

struct Foo<T>(Vec<T>)
where
    T: Clone + Display + Debug + PartialEq + Eq + Send + Sync;

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

Аналогично, если вы видите что-то такое:

trait Foo: Clone + Display + Debug + PartialEq + Eq + Send + Sync {/**/}

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

trait Foo {/**/}

fn use_comparable_foo<F>(f: F)
where
    F: Foo + Eq,
{/**/}

Также стоит обратить внимание на то, что уже существующие трейты имеют ограничения. Например трейт Ord из стандартной библиотеки уже требует для своей реализации также Eq и PartialOrd. При этом сам Eq в свою очередь требует PartialEq

pub trait Ord: Eq + PartialOrd {/**/}
pub trait Eq: PartialEq {}

Поэтому нет практического смысла создавать сигнатуру вроде такой:

fn use_t<T>(t: T)
where
    T: PartialEq + Eq + PartialOrd + Ord,
{/**/}

В то время как действительно необходим только Ord

Также, имеет смысл гранулировать ограничения:

impl<T> Foo<T>
where
    T: Clone + Display + Debug + PartialEq + Eq + Send + Sync,
{
    fn foo(&self) {/**/}
    fn bar(&self) {/**/}
}

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

impl<T> Foo<T> {
    fn foo(&self)
    where
        T: Clone + Display,
    {/**/}

    fn bar(&self)
    where
        T: Eq,
    {/**/}
}

Заключение

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

Я уверен, что это в целом хорошая практика задумываться о трейтовых ограничениях. Я также надеюсь подробнее раскрыть эту тему на куда более насущном примере, всеми обожаемом Send + Sync + 'static, но об этом я напишу уже в следующих статьях

Завершая данную статью, зачем же это всё? Вот что мы получаем в итоге:

Бонус: Трейт алиасы

Но что если нам всё же нужно указать несколько ограничений, причём во многих местах? Для примера, мы работаем с трейтами Read и Write - и нам нужно везде указывать ограничения I: Read + Write. Ну или вовсе трейтов не два, а куда больше?

На этот случай можно сделать то что неофициально называется "трейт алиас". На данный момент в Rust нет встроенной поддержки настоящих трейт алиасов, поэтому приходится обходиться немного более многословным паттерном, который реализуется при помощи идиомы называемой "blanket implementation":

pub trait Io: Read + Write {}
impl<I> Io for I where I: Read + Write {}

Отлично, теперь все ограничения I: Io полностью взаимозаменяемы с I: Read + Write - это работает и в одну, и в другую сторону. Можно подставить любой Read + Write тип в Io и наоборот

Иногда, это действительно может упростить код, однако, следует использовать этот прием осторожнее. Очень уж легко им злоупотребить так, что в итоге пользователь с трудом будет понимать какие ограничения требуются от его типов. Да и всё же, это добавляет дополнительную ментальную абстракцию без какого-то функционального применения, фактически создаёт новый трейт который нужно описать, задокументировать и органично вплести в существующий API

Потому, постарайтесь применять этот паттерн только при действительно оправданной необходимости