Trait-объекты и полиморфизм в Rust

988cce8b7884e38ee8bcdda3d71d3dea.png

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

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

Один из способов, с помощью которого Rust достигает полиморфизма, — использование Trait-объектов.

Trait-объекты - это способ реализации абстрактных типов данных. Так мы можем группировать разные типы, объединенные общими свойствами или функциональностью. Допустим, у нас есть разные структуры, каждая из которых представляет собой какую-то геометрическую фигуру. Они разные, но у всех есть общий метод для вычисления площади. Мы определяем Trait Shape с методом area, а затем реализуем этот Trait для каждой из структур. Trait-объекты в Rust позволяют использовать полиморфизм прямо во время выполнения!

Инкапсуляция с помощью Trait-объектов позволяет скрыть детали реализации. Возвращаясь к нашему примеру с геометрическими фигурами, когда мы используем Trait-объекты, пользователь нашего кода видит только метод area, но не детали того, как каждая фигура вычисляет свою площадь. А полиморфизм тут выступает как способ использовать один и тот же интерфейс (в нашем случае, метод area) для различных типов данных.

Взаимозаменяемость Trait-объектов — это когда один и тот же код может работать с различными типами данных, реализующими определенный Trait. Мы можем легко расширять наш код, добавляя новые типы, которые реализуют нужный Trait, без необходимости изменения существующего кода.

Например, если мы захотим добавить ещё одну фигуру, скажем, трапецию, в нашу программу, нам просто нужно будет реализовать Trait Shape для этой новой структуры. И все функции, которые работали с Shape, автоматически смогут работать и с трапециями.

Статические и динамические диспетчеризации

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

Статический диспетчеризация через параметрический полиморфизм

В Rust параметрический полиморфизм достигается через использование дженериков. Таким образом можно написать функции или структуры данных, которые могут работать с любым типом данных. К примеру fn generic_function(arg: T) { ... }. T может быть любым типом, и функция будет работать с этим типом, не зная заранее, что это будет.

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

Если есть функция generic_function(arg: T) и она вызывается с i32 и f64, компилятор Rust создаст две версии этой функции — одну для i32 и одну для f64.

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

Пример реализации:

trait Walkable {
    fn walk(&self);
}

struct Cat;
struct Dog;

impl Walkable for Cat {
    fn walk(&self) {
        println!("Cat is walking");
    }
}

impl Walkable for Dog {
    fn walk(&self) {
        println!("Dog is walking");
    }
}

fn generic_walk(t: T) {
    t.walk();
}

generic_walk является обобщенной функцией, которая принимает аргумент T, реализующий Trait Walkable. Во время компиляции Rust создаст версии этой функции для каждого типа Cat и Dog, который используется для вызова generic_walk.

Динамическая диспетчеризация

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

Trait-объекты позволяют использовать указатели на данные, которые реализуют определенный Trait, без указания конкретного типа данных. Это мастхев, когда нужно работать с коллекцией объектов разных типов, но которые реализуют общий Trait.

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

Пример реализации:

trait Walkable {
    fn walk(&self);
}

struct Cat;
struct Dog;

impl Walkable for Cat {
    fn walk(&self) {
        println!("Cat is walking");
    }
}

impl Walkable for Dog {
    fn walk(&self) {
        println!("Dog is walking");
    }
}

fn dynamic_dispatch(w: &dyn Walkable) {
    w.walk();
}

fn main() {
    let cat = Cat{};
    let dog = Dog{};

    dynamic_dispatch(&cat);
    dynamic_dispatch(&dog);
}

dynamic_dispatch принимает ссылку на любой тип, реализующий Walkable. В отличие от статической диспетчеризации, конкретная реализация walk определяется во время выполнения, а не во время компиляции.

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

Trait Bounds

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

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

Trait Bounds могут применяться не только к функциям, но и к структурам и перечислениям. Примером может служить структура Printer, где T ограничен трейтом Display. Printer может быть создан только с типами, реализующими Display.

Слово where предоставляет альтернативный синтаксис для указания ограничений трейтов. Например, в функции, которая сравнивает два значения, fn compare(a: T, b: U) where T: PartialOrd + Display, U: PartialOrd + Display, where позволяет ясно разделить трейт-ограничения от параметров функции.

Полиморфизм с использованием Enums

Enums позволяют определить тип, который может принимать одно из нескольких различных форм. Каждая форма может включать в себя данные и даже иметь разные типы данных.

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

Message — это Enum, который представляет четыре различных типа сообщений. Каждый вариант может хранить различные данные.

fn process_message(msg: Message) {
    match msg {
        Message::Quit => println!("Quit"),
        Message::Move { x, y } => println!("Move to x: {}, y: {}", x, y),
        Message::Write(text) => println!("Text message: {}", text),
        Message::ChangeColor(r, g, b) => println!("Change color to RGB: {}, {}, {}", r, g, b),
    }
}

process_message(Message::Write(String::from("Hello, Rust!")));

process_message использует pattern matching для обработки разных вариантов Enum Message. Каждый вариант обрабатывается по-разному.

Enums могут быть в сочетании с trait objects для создания более сложных полиморфических структур:

trait Draw {
    fn draw(&self);
}

struct Circle {
    radius: f64,
}

impl Draw for Circle {
    fn draw(&self) {
        // рисование круга
    }
}

struct Square {
    side: f64,
}

impl Draw for Square {
    fn draw(&self) {
        // рисование квадрата
    }
}

enum Shape {
    Circle(Circle),
    Square(Square),
}

impl Draw for Shape {
    fn draw(&self) {
        match self {
            Shape::Circle(c) => c.draw(),
            Shape::Square(s) => s.draw(),
        }
    }
}

Shape — это Enum, который может быть либо Circle, либо Square. Каждый из этих типов реализует trait Draw. Реализуем Draw для самого Shape, что позволяет вызывать draw на экземплярах Shape, не зная, какой конкретно тип он содержит.

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

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

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

Подробнее про Rust и другие языки программирования, мои друзья из OTUS рассказывают в рамках практических онлайн-курсов. С полным списком курсов можно ознакомиться в каталоге.

© Habrahabr.ru