Skip to content

Типы, трейты, обобщения

Чем структура отличается от enum

Ответ

Структура это произведение типов. Все поля присутствуют одновременно. Enum это сумма типов. В любой момент активен ровно один вариант. Эту разницу важно понимать, потому что в Rust enum полноценный алгебраический тип, не как в C. Каждый вариант может нести произвольные данные. Память enum занимает по размеру самого большого варианта плюс дискриминант. Компилятор умеет ужимать представление, если есть niche, например None в Option<&T> кодируется нулевым указателем.

rust
enum Shape {
    Circle(f64),
    Rect { w: f64, h: f64 },
}

fn area(s: &Shape) -> f64 {
    match s {
        Shape::Circle(r) => std::f64::consts::PI * r * r,
        Shape::Rect { w, h } => w * h,
    }
}

fn main() { println!("{}", area(&Shape::Rect { w: 2.0, h: 3.0 })); }

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

Что такое трейт и чем он отличается от интерфейса в Java

Ответ

Трейт это набор методов и ассоциированных элементов, который тип может реализовать. От интерфейсов в Java отличается тем, что реализацию можно писать в другом модуле, чем сам тип, при условии соблюдения orphan rule. Трейты поддерживают дефолтные методы, ассоциированные типы и константы, могут быть обобщенными. Диспетчеризация по умолчанию статическая, через мономорфизацию, но можно сделать динамическую через dyn Trait.

rust
trait Greet {
    fn name(&self) -> &str;
    fn hello(&self) { println!("hi {}", self.name()); }
}

struct Cat;
impl Greet for Cat { fn name(&self) -> &str { "cat" } }

fn main() { Cat.hello(); }

Дефолтный hello переиспользуется любым типом, который дал свою name.

Статическая и динамическая диспетчеризация, плюсы и минусы

Ответ

Статическая диспетчеризация это generics с мономорфизацией. Компилятор генерирует свою копию функции под каждый конкретный тип. Быстрее в рантайме, заинлайнено, но раздувает бинарник и увеличивает время компиляции. Динамическая диспетчеризация это dyn Trait. Используется vtable, вызов через указатель. Бинарник меньше, типы можно смешивать в одном Vec<Box<dyn Trait>>, но есть стоимость косвенного вызова и невозможность инлайна.

rust
trait Op { fn run(&self) -> i32; }
struct A;
impl Op for A { fn run(&self) -> i32 { 1 } }
struct B;
impl Op for B { fn run(&self) -> i32 { 2 } }

fn sum_static<T: Op>(x: &T, y: &T) -> i32 { x.run() + y.run() }
fn sum_dyn(ops: &[Box<dyn Op>]) -> i32 { ops.iter().map(|o| o.run()).sum() }

Если типы известны и набор маленький, generics. Если плагины, гетерогенные коллекции, конфигурация в рантайме, dyn.

Что такое object safety и почему не любой трейт может быть dyn

Ответ

Чтобы трейт можно было использовать как dyn Trait, он должен быть object safe. Главные ограничения такие. Методы не принимают и не возвращают Self по значению, не используют дженерики, не имеют where Self: Sized без отдельного маркера. Self не появляется в ассоциированных константах. Причина в том, что для vtable нужен фиксированный набор слотов с известными сигнатурами. Если бы метод принимал Self, размер аргумента зависел бы от конкретного типа, и универсальный vtable стал бы невозможен.

rust
trait Bad { fn make() -> Self; } // не object safe
trait Good { fn name(&self) -> &str; } // object safe

fn use_good(x: &dyn Good) { println!("{}", x.name()); }

Если очень хочется иметь и dyn, и generic метод, обычно разделяют трейт на два или используют where Self: Sized для отдельных методов.

Ассоциированные типы и дженерик параметры трейта, в чем разница

Ответ

Генерик параметр трейта позволяет реализовать трейт несколько раз для одного типа с разными параметрами. Ассоциированный тип фиксируется в реализации, один тип это одна Item. Iterator имеет ассоциированный Item, потому что для одного типа естественно одно Item. From наоборот имеет generic параметр, потому что один тип может конвертироваться из многих других.

rust
trait Counter {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
}

struct UpTo {
    i: u32,
    end: u32
}

impl Counter for UpTo {
    type Item = u32;
    fn next(&mut self) -> Option<u32> {
        if self.i < self.end {
            self.i += 1;
            Some(self.i - 1)
        } else { None }
    }
}

Если бы Item был обычным параметром, в коде пришлось бы постоянно его уточнять, и эргономика провисла.

Что такое orphan rule и зачем он нужен

Ответ

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

rust
use std::fmt::Display;

struct Wrap(Vec<i32>);

impl Display for Wrap {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{:?}", self.0)
    }
}

Wrap локальный, поэтому реализация Display для него разрешена.

Что такое newtype и зачем он применяется

Ответ

Newtype это структура с одним полем, оборачивающая другой тип. Применений много. Соблюдение orphan rule, типобезопасность доменных значений, скрытие представления, разные реализации трейтов для одного базового типа. В рантайме newtype бесплатен, потому что layout совпадает с внутренним типом, и часто компилятор инлайнит обертку полностью.

rust
struct Meters(f64);
struct Seconds(f64);

fn speed(d: Meters, t: Seconds) -> f64 { d.0 / t.0 }

fn main() { println!("{}", speed(Meters(100.0), Seconds(9.58))); }

Перепутать метры и секунды компилятор не даст. Это дешевая статическая защита от глупых багов.

Что такое derive и какие трейты обычно автоматически выводятся

Ответ

#[derive(...)] это атрибут, который запускает соответствующий процедурный макрос и генерирует реализацию трейта. Стандартные derivable трейты это Debug, Clone, Copy, PartialEq, Eq, Hash, Default, PartialOrd, Ord. Они работают, если у всех полей структуры или вариантов enum уже есть нужная реализация. Помимо стандартных, derive часто используют для Serialize, Deserialize, thiserror::Error, strum.

rust
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
struct Point {
    x: i32,
    y: i32
}

fn main() {
    let p = Point::default();
    println!("{:?}", p.clone());
}

Derive это про эргономику и про защиту от ошибок при рефакторинге. Добавили поле, и все реализации обновились автоматически.

Что такое impl Trait в позиции возврата и в позиции аргумента

Ответ

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

rust
fn iter_evens(v: &[i32]) -> impl Iterator<Item=&i32> {
    v.iter().filter(|x| *x % 2 == 0)
}

fn log(item: impl std::fmt::Display) { println!("{}", item); }

С возвращаемым impl Trait нужно помнить, что время жизни захваченных аргументов попадают в скрытый тип. С 2024 edition правила захвата стали более явными.

dyn Trait в полях структуры, что нужно знать

Ответ

dyn Trait это unsized тип. Положить его в поле структуры напрямую нельзя, нужен указатель. Чаще всего это Box<dyn Trait>, реже &dyn Trait, Rc или Arc. При выборе важно решить, кто владеет объектом. Если структура живет долго и должна владеть, обычно Box или Arc. Если структура только использует чужой объект, &dyn с временем жизни.

rust
trait Logger { fn log(&self, msg: &str); }

struct App {
    logger: Box<dyn Logger + Send + Sync>
}

struct Console;
impl Logger for Console { fn log(&self, m: &str) { println!("{}", m); } }

fn main() {
    let app = App { logger: Box::new(Console) };
    app.logger.log("ok");
}

Типичный паттерн внедрения зависимостей. Send и Sync добавлены, чтобы App можно было передавать между потоками.

Что такое Sized, ?Sized и зачем это

Ответ

Sized это трейт, который реализуют все типы с известным на этапе компиляции размером. Это i32, String, &T, Box<T>. Не Sized это str, [T], dyn Trait. С такими типами нельзя работать по значению, только через указатель. По умолчанию у любого дженерика стоит неявное T: Sized. Если нужно принимать DST(Dynamicly Sized types), явно пишут T: ?Sized.

rust
fn print_ref<T: ?Sized + std::fmt::Debug>(x: &T) {
    println!("{:?}", x);
}

fn main() {
    print_ref::<str>("hello");
    print_ref::<[i32]>(&[1, 2, 3]);
}

Без ?Sized такой код не собрался бы для str и среза.

Что такое where clause и когда она нужнее, чем inline ограничения

Ответ

Where позволяет описывать ограничения отдельно от списка параметров. Это удобно, когда ограничений много или они сложные. Например, ограничение на ассоциированный тип. T: Iterator, T::Item: Display. Записать это в угловых скобках громоздко. where в большом коде еще полезен тем, что выравнивает ограничения вертикально и упрощает диффы.

rust
use std::fmt::Display;

fn print_all<I>(iter: I) where
    I: IntoIterator,
    I::Item: Display
{
    for x in iter { println!("{}", x); }
}

fn main() { print_all(vec!["a", "b"]); }

При усложнении сигнатур where почти всегда читается лучше, чем длинная строка в угловых скобках.

Что такое coherence и блансировка реализаций

Ответ

Когерентность это свойство системы трейтов, при котором для каждой пары тип трейт существует не более одной реализации, видимой во всей программе. Без когерентности один и тот же вызов в разных местах мог бы попадать в разные реализации, и поведение программы стало бы непредсказуемым. На уровне правил это поддерживается orphan rule и запретом перекрывающихся реализаций. Specialization, который частично позволяет иметь более специфичные реализации, до сих пор в стабильном виде не доехал именно по этой причине.

Компилятор отказывается принимать любую пару реализаций, которые потенциально могут пересекаться.

rust
// Не соберется, если раскомментировать обе реализации.
// trait Foo {}
// impl<T> Foo for T {}
// impl Foo for i32 {}

Комментарий простой. Если хочется специализировать, сейчас приходится обходить это вручную через mini specialization паттерны.

Что такое supertrait и зачем он бывает нужен

Ответ

Supertrait - это требование, что любой тип, реализующий данный трейт, должен также реализовывать другой. Записывается как trait Child: Parent. Это позволяет вызывать методы родителя на &dyn Child и сужает множество реализаций. Полезно, когда родительский трейт дает базовые методы, а дочерний расширяет логику.

rust
use std::fmt::Display;

trait Pretty: Display {
    fn pretty(&self) -> String { format!("=> {}", self) }
}

impl Pretty for i32 {}

fn main() { println!("{}", 7i32.pretty()); }

В методе pretty можно использовать любой метод Display прямо через self.

Что такое blanket implementation и где она применяется в стандартной библиотеке

Ответ

Blanket impl - это реализация трейта для всех типов, удовлетворяющих какому-то ограничению.

Самые известные примеры из стандартной библиотеки это impl<T: Display> ToString for T и impl<T, U> Into<U> for T where U: From<T>. Первый автоматически дает метод to_string для всего, что умеет печататься. Второй автоматически дает Into, как только написана From.

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

rust
struct Celsius(f64);
struct Fahrenheit(f64);

impl From<Celsius> for Fahrenheit {
    fn from(c: Celsius) -> Self { Fahrenheit(c.0 * 1.8 + 32.0) }
}

fn main() {
    let f: Fahrenheit = Celsius(100.0).into(); // into получено через blanket
    println!("{}", f.0);
}

Здесь руками написана только From, Into автоматически реализовано стандартный blanket.