🦀 Rust

OOP: Vererbung in Rust – eine ausführliche Anleitung

Что это такое и зачем? (аналогия из реальной жизни)

Представь, что ты работаешь в конструкторском бюро игрушек. Твоя задача — создать каталог всех игрушек, которые выпускает фабрика. У тебя есть:

  • Машинки — у них есть колёса, цвет, скорость.
  • Куклы — у них есть имя, наряд, причёска.
  • Роботы — у них есть батарейки, оружие, режим ходьбы.

Но если ты начнёшь описывать каждую игрушку с нуля, ты быстро заметишь, что у всех игрушек есть общие черты: название, цена, материал, вес. Каждый раз писать одно и то же — скучно и глупо. Ты можешь сделать так:

  1. Создать общий чертёж «Игрушка», где указаны название, цена, материал.
  2. Для каждого типа игрушек сделать свой чертёж, который наследует общий, но добавляет свои детали.

В программировании это называется наследование (inheritance). Это механизм, который позволяет создать один класс (или структуру) на основе другого, забрав себе все его свойства и методы, а потом добавить что-то своё.

Важно: В Rust нет классического наследования классов, как в Python или Java. Но у Rust есть свои мощные инструменты, которые делают то же самое, только безопаснее и быстрее. Мы будем говорить о наследовании поведения через трейты (traits) и о композиции структур.

Зачем это нужно?

  • Не повторяй себя (DRY) — общий код пишется один раз.
  • Иерархия — легко показать, что «Кошка — это Животное», а «Студент — это Человек».
  • Расширяемость — добавил новый тип, и он автоматически получает всё базовое.
  • Полиморфизм — можно работать с разными типами через единый интерфейс.

Теория: как это работает изнутри

Давай заглянем «под капот» и разберёмся, что происходит в памяти, когда мы используем наследование (в Rust — трейты и композицию).

Композиция вместо наследования

В Rust нет ключевого слова extends. Вместо этого мы используем композицию: одна структура содержит другую как поле. Например:

struct Животное {
    имя: String,
    возраст: u8,
}

struct Собака {
    // Композиция: Собака содержит Животное
    животное: Животное,
    порода: String,
}

В памяти это выглядит так: у структуры Собака есть поле животное, которое само является структурой Животное. Все поля Животное (имя, возраст) доступны через точку: собака.животное.имя.

Трейты — наследование поведения

Если мы хотим, чтобы разные типы умели делать одно и то же (например, издавать звук), мы используем трейты (traits). Трейт — это контракт: «Если ты реализуешь этот трейт, ты обещаешь, что у тебя будет такой-то метод».

trait Голос {
    fn издать_звук(&self);
}

struct Кошка;
struct Собака;

impl Голос for Кошка {
    fn издать_звук(&self) {
        println!("Мяу!");
    }
}

impl Голос for Собака {
    fn издать_звук(&self) {
        println!("Гав!");
    }
}

Теперь и Кошка, и Собака «наследуют» поведение «уметь издавать звук». Но в Rust это называется не наследованием, а реализацией трейта.

Как это работает в памяти?

Когда ты вызываешь метод трейта, компилятор смотрит: «Для какого типа вызван метод?» — и подставляет нужную реализацию. Это называется статическая диспетчеризация (monomorphization). Компилятор создаёт отдельную копию функции для каждого типа, который реализует трейт. Это быстро, но может увеличить размер бинарника.

Если тебе нужна динамическая диспетчеризация (когда тип известен только во время выполнения), используй Box<dyn Трейт> или &dyn Трейт. Тогда в памяти будет храниться указатель на таблицу виртуальных методов (vtable).

Запомни: Rust выбирает безопасность и производительность. Вместо «классического» наследования с риском «ромбовидной смерти» (когда два родителя имеют одинаковый метод), Rust предлагает композицию и трейты. Это сложнее на старте, но спасает от многих багов.

Синтаксис в Rust: все формы и варианты

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

1. Композиция структур (вложение)

Самый простой способ: одна структура содержит другую.

struct Человек {
    имя: String,
    возраст: u8,
}

struct Студент {
    человек: Человек, // Композиция
    группа: String,
    средний_балл: f32,
}

fn main() {
    let студент = Студент {
        человек: Человек {
            имя: String::from("Анна"),
            возраст: 20,
        },
        группа: String::from("БВТ-101"),
        средний_балл: 4.5,
    };
    // Доступ к полям через вложенность
    println!("Имя: {}", студент.человек.имя);
    println!("Группа: {}", студент.группа);
}

2. Реализация методов для вложенной структуры

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

impl Студент {
    fn имя(&self) -> &str {
        &self.человек.имя
    }
    fn возраст(&self) -> u8 {
        self.человек.возраст
    }
}

fn main() {
    let студент = Студент { /* ... */ };
    println!("Имя: {}", студент.имя());
}

3. Трейты как интерфейсы

Трейты — это главный инструмент для наследования поведения.

// Определяем трейт
trait Фигура {
    fn площадь(&self) -> f64;
    fn периметр(&self) -> f64;
}

// Реализуем для разных типов
struct Круг {
    радиус: f64,
}

impl Фигура for Круг {
    fn площадь(&self) -> f64 {
        std::f64::consts::PI * self.радиус * self.радиус
    }
    fn периметр(&self) -> f64 {
        2.0 * std::f64::consts::PI * self.радиус
    }
}

struct Квадрат {
    сторона: f64,
}

impl Фигура for Квадрат {
    fn площадь(&self) -> f64 {
        self.сторона * self.сторона
    }
    fn периметр(&self) -> f64 {
        4.0 * self.сторона
    }
}

4. Трейты с реализацией по умолчанию

Можно задать метод, который будет работать «из коробки», но его можно переопределить.

trait Животное {
    fn издать_звук(&self) {
        println!("Издаёт непонятный звук");
    }
    fn спать(&self) {
        println!("Спит...");
    }
}

struct Кошка;
struct Собака;

impl Животное for Кошка {
    fn издать_звук(&self) {
        println!("Мяу!");
    }
    // спать() остаётся по умолчанию
}

impl Животное for Собака {
    fn издать_звук(&self) {
        println!("Гав!");
    }
    // спать() остаётся по умолчанию
}

5. Наследование трейтов (супертрейты)

Один трейт может требовать, чтобы тип реализовал другой трейт.

trait Именованный {
    fn имя(&self) -> &str;
}

// Трейт Студент требует, чтобы тип был Именованным
trait Студент: Именованный {
    fn группа(&self) -> &str;
    fn учиться(&self) {
        println!("{} учится в группе {}", self.имя(), self.группа());
    }
}

struct Человек {
    имя: String,
    группа: String,
}

impl Именованный for Человек {
    fn имя(&self) -> &str {
        &self.имя
    }
}

impl Студент for Человек {
    fn группа(&self) -> &str {
        &self.группа
    }
}

6. Обобщённые функции с ограничениями по трейтам

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

fn вывести_площадь<T: Фигура>(фигура: &T) {
    println!("Площадь: {}", фигура.площадь());
}

// Или с синтаксисом where
fn вывести_периметр<T>(фигура: &T)
where
    T: Фигура,
{
    println!("Периметр: {}", фигура.периметр());
}

7. Динамическая диспетчеризация (Box<dyn Трейт>)

Когда нужно хранить разные типы в одном векторе.

fn main() {
    let фигуры: Vec<Box<dyn Фигура>> = vec![
        Box::new(Круг { радиус: 5.0 }),
        Box::new(Квадрат { сторона: 4.0 }),
    ];
    for фигура in &фигуры {
        println!("Площадь: {}", фигура.площадь());
    }
}

Примеры кода — от простого к сложному

Пример 1: Базовое наследование через композицию

// Базовая структура "Транспорт"
struct Транспорт {
    скорость: u32,
    цвет: String,
}

// Производная структура "Автомобиль" содержит "Транспорт"
struct Автомобиль {
    транспорт: Транспорт,
    колёса: u8,
    марка: String,
}

impl Автомобиль {
    // Метод-прокси для доступа к скорости
    fn скорость(&self) -> u32 {
        self.транспорт.скорость
    }
    fn информация(&self) {
        println!(
            "Автомобиль {} {} цвета, скорость {} км/ч, колёс: {}",
            self.марка, self.транспорт.цвет, self.транспорт.скорость, self.колёса
        );
    }
}

fn main() {
    let авто = Автомобиль {
        транспорт: Транспорт {
            скорость: 180,
            цвет: String::from("красный"),
        },
        колёса: 4,
        марка: String::from("Toyota"),
    };
    авто.информация();
    // Вывод: Автомобиль Toyota красный цвета, скорость 180 км/ч, колёс: 4
}

Пример 2: Трейт с методом по умолчанию

trait Животное {
    fn имя(&self) -> &str;
    fn звук(&self) -> &str;
    // Метод по умолчанию
    fn представить(&self) {
        println!("Я — {}, я говорю: {}", self.имя(), self.звук());
    }
}

struct Кошка {
    имя: String,
}

impl Животное for Кошка {
    fn имя(&self) -> &str {
        &self.имя
    }
    fn звук(&self) -> &str {
        "Мяу"
    }
    // представить() используем по умолчанию
}

struct Собака {
    имя: String,
}

impl Животное for Собака {
    fn имя(&self) -> &str {
        &self.имя
    }
    fn звук(&self) -> &str {
        "Гав"
    }
    // Переопределяем метод по умолчанию
    fn представить(&self) {
        println!("{} говорит: {}! Я друг человека!", self.имя(), self.звук());
    }
}

fn main() {
    let кошка = Кошка { имя: String::from("Мурка") };
    let собака = Собака { имя: String::from("Шарик") };
    кошка.представить(); // Я — Мурка, я говорю: Мяу
    собака.представить(); // Шарик говорит: Гав! Я друг человека!
}

Пример 3: Наследование трейтов (супертрейты)

// Базовый трейт
trait Именованный {
    fn имя(&self) -> &str;
    fn возраст(&self) -> u8;
}

// Производный трейт, который требует Именованный
trait Работник: Именованный {
    fn должность(&self) -> &str;
    fn зарплата(&self) -> f64;
    fn работать(&self) {
        println!(
            "{} ({}) работает как {}",
            self.имя(),
            self.возраст(),
            self.должность()
        );
    }
}

struct Программист {
    имя: String,
    возраст: u8,
    язык: String,
}

impl Именованный for Программист {
    fn имя(&self) -> &str {
        &self.имя
    }
    fn возраст(&self) -> u8 {
        self.возраст
    }
}

impl Работник for Программист {
    fn должность(&self) -> &str {
        "Программист"
    }
    fn зарплата(&self) -> f64 {
        150000.0
    }
}

fn main() {
    let прог = Программист {
        имя: String::from("Иван"),
        возраст: 25,
        язык: String::from("Rust"),
    };
    прог.работать(); // Иван (25) работает как Программист
    println!("Зарплата: {}", прог.зарплата());
}

Пример 4: Полиморфизм через обобщения

trait Фигура {
    fn площадь(&self) -> f64;
}

struct Круг { радиус: f64 }
struct Прямоугольник { ширина: f64, высота: f64 }

impl Фигура for Круг {
    fn площадь(&self) -> f64 {
        3.14159 * self.радиус * self.радиус
    }
}

impl Фигура for Прямоугольник {
    fn площадь(&self) -> f64 {
        self.ширина * self.высота
    }
}

// Обобщённая функция, работает с любым типом, реализующим Фигура
fn удвоить_площадь<T: Фигура>(фигура: &T) -> f64 {
    фигура.площадь() * 2.0
}

fn main() {
    let круг = Круг { радиус: 3.0 };
    let прямоугольник = Прямоугольник { ширина: 4.0, высота: 5.0 };
    println!("Удвоенная площадь круга: {}", удвоить_площадь(&круг));
    println!("Удвоенная площадь прямоугольника: {}", удвоить_площадь(&прямоугольник));
}

Пример 5: Динамическая диспетчеризация (вектор разных типов)

trait Животное {
    fn издать_звук(&self);
}

struct Кошка;
struct Собака;
struct Корова;

impl Животное for Кошка {
    fn издать_звук(&self) { println!("Мяу!"); }
}
impl Животное for Собака {
    fn издать_звук(&self) { println!("Гав!"); }
}
impl Животное for Корова {
    fn издать_звук(&self) { println!("Муу!"); }
}

fn main() {
    // Вектор с разными типами, упакованными в Box
    let животные: Vec<Box<dyn Животное>> = vec![
        Box::new(Кошка),
        Box::new(Собака),
        Box::new(Корова),
    ];
    for животное in &животные {
        животное.издать_звук();
    }
    // Вывод: Мяу! Гав! Муу!
}

Пример 6: Композиция с несколькими уровнями

struct Двигатель {
    мощность: u32,
    тип_топлива: String,
}

struct Кузов {
    цвет: String,
    материал: String,
}

struct Машина {
    двигатель: Двигатель,
    кузов: Кузов,
    марка: String,
    модель: String,
}

impl Машина {
    fn новая(марка: &str, модель: &str) -> Машина {
        Машина {
            двигатель: Двигатель {
                мощность: 150,
                тип_топлива: String::from("бензин"),
            },
            кузов: Кузов {
                цвет: String::from("белый"),
                материал: String::from("сталь"),
            },
            марка: String::from(марка),
            модель: String::from(модель),
        }
    }
    fn описание(&self) {
        println!(
            "{} {}: двигатель {} л.с. ({}), кузов {} ({})",
            self.марка,
            self.модель,
            self.двигатель.мощность,
            self.двигатель.тип_топлива,
            self.кузов.цвет,
            self.кузов.материал
        );
    }
}

fn main() {
    let машина = Машина::новая("Toyota", "Camry");
    машина.описание();
}

Пример 7: Реализация нескольких трейтов для одного типа

trait Летать {
    fn лететь(&self);
}

trait Плавать {
    fn плыть(&self);
}

struct Утка {
    имя: String,
}

impl Летать for Утка {
    fn лететь(&self) {
        println!("{} летит!", self.имя);
    }
}

impl Плавать for Утка {
    fn плыть(&self) {
        println!("{} плывёт!", self.имя);
    }
}

// Функция, требующая оба трейта
fn тест_утки<T: Летать + Плавать>(существо: &T) {
    существо.лететь();
    существо.плыть();
}

fn main() {
    let утка = Утка { имя: String::from("Дональд") };
    тест_утки(&утка);
}

Сравнение с Python: чем похоже и чем отличается

Характеристика Python Rust
Ключевое слово для наследования class Child(Parent): Нет ключевого слова. Используется композиция или трейты
Множественное наследование Поддерживается (но может вызвать «проблему ромба») Не поддерживается в классическом виде. Вместо этого — множественные трейты
Переопределение методов Просто объявить метод с тем же именем Нужно реализовать трейт заново или переопределить метод по умолчанию
Доступ к родительскому классу super() Нет прямого доступа. Нужно вызывать методы через поле композиции
Полиморфизм Утиная типизация (duck typing) Статический полиморфизм через обобщения или динамический через dyn
Производительность Динамическая диспетчеризация (медленнее) Статическая диспетчеризация (быстрее) или динамическая по выбору
Безопасность Ошибки во время выполнения Ошибки на этапе компиляции
Гибкость Очень гибкий, можно менять классы на лету Жёсткая система типов, всё должно быть определено на этапе компиляции
Главное отличие: Python говорит: «Ты можешь наследовать что угодно от чего угодно, а если что-то пойдёт не так — узнаешь во время выполнения». Rust говорит: «Я проверю всё на этапе компиляции, и если ты ошибёшься — программа не соберётся». Это делает Rust безопаснее, но требует больше размышлений на старте.

Типичные ошибки и подводные камни

1. Попытка использовать наследование классов как в Python

Ошибка: Новички ищут ключевое слово extends или inherits.

Решение: Забудьте про классическое наследование. Думайте в терминах «имеет» (композиция) и «умеет» (трейты).

2. Забыть реализовать все методы трейта

Ошибка: Компилятор выдаст ошибку: not all trait items implemented.

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

3. Путаница между статической и динамической диспетчеризацией

Ошибка: Использование Box<dyn Трейт> там, где можно обойтись обобщениями, или наоборот.

Решение:

  • Если тип известен на этапе компиляции — используй обобщения <T: Трейт>.
  • Если нужно хранить разные типы в одной коллекции — используй Box<dyn Трейт>.

4. Проблемы с временем жизни при использовании трейтов

Ошибка: dyn Trait может потребовать указания времени жизни.

Решение: Добавь + 'static или укажи явное время жизни: Box<dyn Трейт + 'a>.

5. Чрезмерное использование композиции

Ошибка: Создание слишком глубокой вложенности структур (структура в структуре в структуре).

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

6. Игнорирование супертрейтов

Ошибка: Попытка реализовать трейт, который требует другой трейт, но не реализовав его.

Решение: Всегда реализуй все требуемые трейты. Компилятор подскажет, если что-то забыл.

7. Неправильное использование self и Self

Ошибка: Путаница между self (экземпляр) и Self (тип).

Решение: self — это сам объект, Self — это тип объекта. В трейтах используй Self для возврата типа.

Лучшие практики и советы профессионалов

  1. Предпочитайте композицию наследованию — это принцип из «Банды четырёх» (GoF). В Rust это естественный подход.
  2. Используйте трейты для интерфейсов — определяйте поведение, а не данные.
  3. Делайте трейты маленькими — один трейт = одна ответственность (принцип разделения интерфейсов).
  4. Используйте методы по умолчанию — они уменьшают дублирование кода.
  5. Для сложной иерархии используйте вложенные трейты — это безопаснее, чем глубокое вложение структур.
  6. Не бойтесь Box<dyn Trait> — это нормально для динамических коллекций, но старайтесь минимизировать их использование в критическом по производительности коде.
  7. Тестируйте каждый трейт отдельно — напишите тесты для каждой реализации.
  8. Документируйте трейты — пишите, что должен делать каждый метод, особенно если есть реализация по умолчанию.
  9. Используйте #[must_use] для методов, которые возвращают результат — это предотвратит случайное игнорирование ошибок.
  10. Изучите паттерн «Новотип» (newtype) — обёртка вокруг существующего типа для реализации чужих трейтов.
Совет от профи: В Rust нет «серебряной пули». Композиция и трейты — это два разных инструмента. Композиция для данных, трейты для поведения. Используйте их вместе, и вы получите чистый, безопасный и быстрый код.

Вопросы для самопроверки

  1. Вопрос: Чем композиция в Rust отличается от классического наследования в Python?

    Ответ: В Python дочерний класс автоматически получает все методы родителя. В Rust нужно явно обращаться к полю композиции или писать методы-прокси. Композиция даёт больше контроля, но требует больше кода.

  2. Вопрос: Что такое трейт в Rust?

    Ответ: Трейт — это набор методов, которые тип обязуется реализовать. Это аналог интерфейса в Java или абстрактного класса в Python.

  3. Вопрос: Можно ли в Rust сделать так, чтобы один трейт наследовал другой?

    Ответ: Да, через супертрейты. Например: trait Child: Parent означает, что любой тип, реализующий Child, должен также реализовать Parent.

  4. Вопрос: В чём разница между Box<dyn Trait> и &dyn Trait?

    Ответ: Box<dyn Trait> — владеющий указатель на кучу (данные будут удалены при выходе из области видимости). &dyn Trait — заимствованная ссылка (не владеет данными).

  5. Вопрос: Что произойдёт, если не реализовать все методы трейта?

    Ответ: Компилятор выдаст ошибку: not all trait items implemented. Нужно реализовать все обязательные методы.

  6. Вопрос: Как сделать метод трейта необязательным для реализации?

    Ответ: Предоставить реализацию по умолчанию прямо в определении трейта. Тогда реализующий тип может её переопределить, а может и нет.

  7. Вопрос: Можно ли хранить в одном векторе объекты разных типов, реализ