Мертвая фича
Мертвая фича

В моей первой статье на хабре речь пойдет о комбинации примитивных конструкций, позволяющих организовать наследование реализаций и композицию состояний. Поочередно разберу, от простых в использовании конструкций, до комплексных prod-ready решений, которые могут найти повсеместного применения в разработке и публичных контейнерах. Здесь не будет зависимостей, макросов, Rc, Box и тд. - исключительноno_std.

Историческая справка

Мнение относительно ООП разнится, кто-то против него, кто-то (как в UI) без него ставят крест на ржавом. В Rust Book, в 17 главе приводят: "Если язык должен иметь наследование, чтобы быть объектно-ориентированным, то Rust таким не является. Здесь нет способа определить структуру, наследующую поля и реализации методов родительской структуры, без использования макроса". Сегодня я покажу, что такое возможно и считаю, что такой подход можно использовать разумно и безопасно, что не помешало бы большому количеству контейнеров, где для собственных реализаций Middleware и Response приходится писать либо тонну однообразного кода, что совсем не DRY; либо разбивать логику на кучу компонентов.

Никакой магии

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

trait Animal {
    type Parent: Animal;

    fn say(text: &'static str) {
        Self::Parent::say(text);
    }
}
  1. Объявление трейта с зависимостью от собственного типа

  2. Сигнатура функции, избитой во всех примерах ООП (для простоты понимания)

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

И уже более замысловатый пример с объявлением наследственной иерархии животного:

trait Animal {
    type Parent: Animal;

    fn say(text: &'static str) {
        Self::Parent::say(text);
    }
}


struct Dog;

impl Animal for Dog {
    type Parent = Self;

    fn say(text: &'static str) {
        println!("Woof: {text}");
    }
}


impl Animal for Cat {
    type Parent = Self;

    fn say(text: &'static str) {
        println!("Meow: {text}");
    }
}


struct Komaru;

impl Animal for Komaru {
    type Parent = Cat;
}


fn soft_sayer<A>(text: &'static str)
    where A: Animal {
        A::say(text)
}

fn strong_sayer<A>(text: &'static str)
    where A: Animal<Parent = Cat> {
        A::say(text)
}


fn main() {
    soft_sayer::<Komaru>("hello");
    strong_sayer::<Komaru>("bonjur");

    Komaru::say("Меня едят с солью!")
}
  1. Создаем статическую структуру Dog и Cat, без заранее известного размера присвоить тип невозможно. В обоих случаях используется type Parent = Self, чтобы явно указать, что родителей не имеется и вся логика при распутывании иерархии закончится на них. Важно при этом покрыть все методы, чтобы не создать бесконечный цикл из-за стандартной реализации.

  2. Для разнообразия возможностей полиморфизма с такой конструкцией, добавим две функции с почти идентичной сигнатурой: A: Animal - принимает все вариации с имплементированным базовым трейтом, A: Animal<Parent = Cat> - принимает реализации с конкретным родителем.

При использовании этой конструкции (да и во всех последующих) появляется пару неудобных моментов, держите это в уме, если решите использовать этот подход:

  1. Нам приходится дублировать сигнатуру вызова функции, добавляя Self::Parent к каждому вызову в стандартной реализации, с другой стороны появляется больше контроля за промежуточными вызовами, которые можно обвесить логами/трасерами/таймером или передаче параметров не включенных в аргументы, например, из глобального мьютекса.

  2. При добавлении нового метода, появляется опасность вызвать непокрытую зацикленную функцию и получить панику с Stack overflow. Как вариант исправления, использовать базовую структуру с имплементированными паниками или стандартным поведением на вершине иерархии, так сказать замыкаясь, что и будет использоваться дальше.

Развиваем до композиции состояния

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

trait BaseAnimal {
    type Parent: BaseAnimal;

    fn parent(&self) -> &Self::Parent;
    
    fn say(&self, text: &'static str) {
        self.parent().say(text)
    }
}


struct Animal {
    prefix: &'static str,
}

impl Animal {
    fn new(prefix: &'static str) -> Self {
        Self { prefix }
    }
}

impl BaseAnimal for Animal {
    type Parent = Self;

    fn parent(&self) -> &Self::Parent {
        self
    }

    fn say(&self, text: &'static str) {
        println!("{}: {text}", self.prefix);
    }
}


struct Cat(Animal);

impl Cat {
    fn new() -> Self {
        Self(Animal::new("Meow"))
    }
}

impl BaseAnimal for Cat {
    type Parent = Animal;

    fn parent(&self) -> &Self::Parent {
        &self.0
    }
}


struct Winky(Cat);

impl Default for Winky {
    fn default() -> Self {
        Self(Cat::new())
    }
}

impl BaseAnimal for Winky {
    type Parent = Cat;

    fn parent(&self) -> &Self::Parent {
        &self.0
    }
}


struct Komaru(Cat);

impl Default for Komaru {
    fn default() -> Self {
        Self(Cat::new())
    }
}

impl BaseAnimal for Komaru {
    type Parent = Cat;

    fn parent(&self) -> &Self::Parent {
        &self.0
    }

    fn say(&self, _: &'static str) {
        println!("Поддержите автора кваззом!");
    }
}


fn unsized_sayer<A>()
    where A: Default + BaseAnimal {
        A::default().say("hello");
}

fn sized_sayer(animal: impl BaseAnimal<Parent = Cat>) {
    animal.say("hello");
}


fn main() {
    unsized_sayer::<Winky>();

    sized_sayer(Komaru::default());
}
  1. Теперь для обращения к родителю используется функция parent и рекурсия в методах использует ее для вызовов.

  2. По рекомендации из прошлого примера, добавляем стандартную реализацию в виде Animal, хранящего префикс и метод для озвучивания. parent ссылает на себя, потому что иерархия вызовов должна замкнуться на нем.

  3. Объявляем Cat c компонентом Animal и билдером префикса для всех наследников (для лаконичности так будут объявляться компоненты и дальше). При имплементации Animal необходимо только указать на состояние компонента, метод озвучивания подтянется автоматически.

  4. Объявляем Winky с компонентом Cat. Как и в пункте выше, указываем только на состояние, причем из-за того что Cat уже наследует Animal, углубляться в состоянии не требуется.

  5. Но при необходимости можно и перегрузить метод, как в случае с Komaru, в этом случае можно даже использовать type Parent = Self, так как состояние не нужно.

  6. Как и в примере выше, возможно разграничить доступ к функциям на основе Parent, если бы мы убрали Cat у Komaru, то компилятор бы ее не пропустил!

Начинается черная магия

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

trait Animal<Parent: Animal<Parent>> {
    fn say(text: &'static str) {
        Parent::say(text);
    }
}


struct Cat;

impl Animal<Self> for Cat {
    fn say(text: &'static str) {
        println!("Meow: {text}");
    }
}


struct Dog;

impl Animal<Self> for Dog {
    fn say(text: &'static str) {
        println!("Woof: {text}");
    }
}


struct Kokoa;

impl Animal<Cat> for Kokoa {}
impl Animal<Dog> for Kokoa {}


fn cat_sayer<A>(text: &'static str) 
    where A: Animal<Cat> {
        A::say(text);
}

fn dog_sayer<A>(text: &'static str) 
    where A: Animal<Dog> {
        A::say(text);
}


fn main() {
    cat_sayer::<Kokoa>("hello");
    dog_sayer::<Kokoa>("bonjur");

    <Kokoa as Animal<Cat>>
        ::say("Такое возможно только с солью!");
}

Почти идентичный первому примеру вариант, с одним отличием, вместо type Parent используется баунд, что позволяет ему унаследовать несколько реализаций и быть в зависимости от ситуацииCat и Dog. Увы, глубина наследования при множественном наследовании одинаковых типов ограничена всего одним наследником. Рассмотрим использование баундов уже для наследования состояния:

trait BaseAnimal<Parent: BaseAnimal<Animal>> {
    fn parent(&self) -> &Parent;

    fn say(&self, text: &'static str) {
        self.parent().say(text);
    }
}


struct Animal {
    prefix: &'static str,
}

impl Animal {
    fn new(prefix: &'static str) -> Self {
        Self { prefix }
    }
}

impl BaseAnimal<Self> for Animal {
    fn parent(&self) -> &Self {
        &self
    }

    fn say(&self, text: &'static str) {
        println!("{}: {text}", self.prefix);
    }
}


struct Cat(Animal);

impl Default for Cat {
    fn default() -> Self {
        Self(Animal::new("Meow"))
    }
}

impl BaseAnimal<Animal> for Cat {
    fn parent(&self) -> &Animal {
        &self.0
    }
}


struct Dog(Animal);

impl Default for Dog {
    fn default() -> Self {
        Self(Animal::new("Woof"))
    }
}

impl BaseAnimal<Animal> for Dog {
    fn parent(&self) -> &Animal {
        &self.0
    }
}


struct Komaru(Cat, Dog);

impl Default for Komaru {
    fn default() -> Self {
        Self(Cat::default(), Dog::default())
    }
}

impl BaseAnimal<Cat> for Komaru {
    fn parent(&self) -> &Cat {
        &self.0
    }
}

impl BaseAnimal<Dog> for Komaru {
    fn parent(&self) -> &Dog {
        &self.1
    }
}


fn unsized_cat_sayer<A>()
    where A: Default + BaseAnimal<Cat> {
        A::default().say("hello");
}

fn sized_cat_sayer(animal: &impl BaseAnimal<Cat>) {
    animal.say("hello");
}


fn main() {
    let komaru = Komaru::default();

    unsized_cat_sayer::<Komaru>();
    sized_cat_sayer(&komaru);

    <dyn BaseAnimal<Cat>>
        ::say(&komaru, "Что я такое...");
}

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

В заключении

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

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


  1. blandger
    13.06.2023 14:54

    Неуже ли это Витя Дудочкин автор статьи?


    1. IkaR49
      13.06.2023 14:54

      Слог не похож. Но утверждать не берусь :)


  1. Siddthartha
    13.06.2023 14:54

    хм.. это надо обдумать) попробую как будет время!..


  1. NeoCode
    13.06.2023 14:54

    Да, определенно нужна хорошая статья (а лучше серия статей) по трейтам в Rust. Желательно с точки зрения программиста С++ :)


  1. Dooez
    13.06.2023 14:54

    Я не очень знаком с Rust, но насколько я понял вы используете композицию? Если это так, то мне кажется не совсем честным и правильным называть это наследованием. Если я не ошибаюсь, композиция вместо наследования довольно устоявшееся название паттерна.


    1. hexacosichoron Автор
      13.06.2023 14:54

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


  1. djmaxus
    13.06.2023 14:54

    Комментарий в поддержку статьи

    Кажется, в прямом эфире видел одно из первых обсуждений этого паттерна в русскоязычных телеграм-чатах по Rust. Автор, это вы и были? Если да, ваша разработка сделала круг и снова обсуждается, хорошая работа!