
В моей первой статье на хабре речь пойдет о комбинации примитивных конструкций, позволяющих организовать наследование реализаций и композицию состояний. Поочередно разберу, от простых в использовании конструкций, до комплексных 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ссылает на себя, потому что иерархия вызовов должна замкнуться на нем.Объявляем
Catc компонентом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 } } #[derive(Default)] struct Komaru(Cat, Dog); 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); BaseAnimal::<Cat> ::say(&komaru, "Что я такое..."); }
Почти также минимум отличий, кроме использования баундов вместе type Parent. Важно то, что состояния не конфликтуют друг с другом и используются в зависимости от ситуации или явного каста. При должном использовании, четыре варианта наследования могут дополнять друг друга, конструкции открыты и не мешают развивать их в необходимом направлении.
В заключении
Всего две строчки могли бы изменить ход развития экосистемы, появившись оно немного раньше и, возможно, эта статья поможет начать внедрять недоступные раньше или изобретать новые паттерны проектирования. С другой стороны забавно осознавать, что всего две строчки оставались недоступны сообществу на протяжении всего развития ржавого и неизвестно еще что из этого получится в будущем, ведь перечисленные механизмы можно перенести на макросы, а описанные абстракции бесплатны в использовании. На написание статьи ушло полгода размышлений и один день жизни, и я надеюсь оно облегчит мне работу с публичными контейнерами в будущем, а код из статьи будет внесен в Rust Book с выдачей мне вечного членства в Rust Foundation.
