В моей первой статье на хабре речь пойдет о комбинации примитивных конструкций, позволяющих организовать наследование реализаций и композицию состояний. Поочередно разберу, от простых в использовании конструкций, до комплексных 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);
}
}
Объявление трейта с зависимостью от собственного типа
Сигнатура функции, избитой во всех примерах ООП (для простоты понимания)
Благодаря возможности стандартной реализации, можно рекурсивно вызывать функцию из родительского типа
И уже более замысловатый пример с объявлением наследственной иерархии животного:
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("Меня едят с солью!")
}
Создаем статическую структуру
Dog
иCat
, без заранее известного размера присвоить тип невозможно. В обоих случаях используетсяtype Parent = Self
, чтобы явно указать, что родителей не имеется и вся логика при распутывании иерархии закончится на них. Важно при этом покрыть все методы, чтобы не создать бесконечный цикл из-за стандартной реализации.Для разнообразия возможностей полиморфизма с такой конструкцией, добавим две функции с почти идентичной сигнатурой:
A: Animal
- принимает все вариации с имплементированным базовым трейтом,A: Animal<Parent = Cat>
- принимает реализации с конкретным родителем.
При использовании этой конструкции (да и во всех последующих) появляется пару неудобных моментов, держите это в уме, если решите использовать этот подход:
Нам приходится дублировать сигнатуру вызова функции, добавляя
Self::Parent
к каждому вызову в стандартной реализации, с другой стороны появляется больше контроля за промежуточными вызовами, которые можно обвесить логами/трасерами/таймером или передаче параметров не включенных в аргументы, например, из глобального мьютекса.При добавлении нового метода, появляется опасность вызвать непокрытую зацикленную функцию и получить панику с
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());
}
Теперь для обращения к родителю используется функция
parent
и рекурсия в методах использует ее для вызовов.По рекомендации из прошлого примера, добавляем стандартную реализацию в виде
Animal
, хранящего префикс и метод для озвучивания.parent
ссылает на себя, потому что иерархия вызовов должна замкнуться на нем.Объявляем
Cat
c компонентомAnimal
и билдером префикса для всех наследников (для лаконичности так будут объявляться компоненты и дальше). При имплементацииAnimal
необходимо только указать на состояние компонента, метод озвучивания подтянется автоматически.Объявляем
Winky
с компонентомCat
. Как и в пункте выше, указываем только на состояние, причем из-за того чтоCat
уже наследуетAnimal
, углубляться в состоянии не требуется.Но при необходимости можно и перегрузить метод, как в случае с
Komaru
, в этом случае можно даже использоватьtype Parent = Self
, так как состояние не нужно.Как и в примере выше, возможно разграничить доступ к функциям на основе
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)
NeoCode
13.06.2023 14:54Да, определенно нужна хорошая статья (а лучше серия статей) по трейтам в Rust. Желательно с точки зрения программиста С++ :)
Dooez
13.06.2023 14:54Я не очень знаком с Rust, но насколько я понял вы используете композицию? Если это так, то мне кажется не совсем честным и правильным называть это наследованием. Если я не ошибаюсь, композиция вместо наследования довольно устоявшееся название паттерна.
hexacosichoron Автор
13.06.2023 14:54Наследуется реализация, состояние остается за компонентом, но разделение состояния полезно, как в случае наследования баундом двух (можно и больше) родителей в разных компонентах. К тому же можно добавить геттеров, тогда будет иметься доступ к состоянию без прямого обращения к компоненту.
djmaxus
13.06.2023 14:54Комментарий в поддержку статьи
Кажется, в прямом эфире видел одно из первых обсуждений этого паттерна в русскоязычных телеграм-чатах по Rust. Автор, это вы и были? Если да, ваша разработка сделала круг и снова обсуждается, хорошая работа!
blandger
Неуже ли это Витя Дудочкин автор статьи?
IkaR49
Слог не похож. Но утверждать не берусь :)