Brief
I'm seeking the community's opinion on implementing strictly single-inheritance classes in my personal project. It requires a strictly ordered memory model; but Rust doesn't even have a memory model.
In my use case, it is primarily for similar members and functionality across multiple data structures. Where I can simply rewrite each of these struct items for every struct and generalize the functionality in terms of interface traits which gets annoying to me very fast.
Context
I'm writing a program that, for the most part, requires similar members and functionality across multiple data structures.
Coming from a C/C++
background, I know that I can simplify this through the OOP paradigm. Creating the base class that implements all the required members and functionality and simply extend wherever needed.
However, Rust
doesn't have this simplistic (and sometimes problematic) paradigm directly. As we know, it's OOP paradigm is more of a Has-A
(and Implements-A
) structure as contrary to its cousin's Is-A
structure. That makes it less prone to the problematic pitfalls of inheritance as I personally see in C/C++
code.
I understand that I can simply rewrite the code that make up my needed members and functionality and go even beyond to writing procedural-derive macros to streamline my process. But rewriting repeatedly can be quite a nuisance, a boring endeavor that, at least for me, drowns the readability of functional code in verbosity. This got me thinking into writing a procedural macro that leverages strictly ordered memory that expands a struct to implement a single inheritance class.
Pseudo Code and Technical Design
I haven't fully committed to doing this approach, as I want to hear the community's thoughts. Particularly, if the approach is bad given the language's design and other things that I may have missed.
Disambiguating the class
concept
The first and foremost problem of trying to implement a class
in a language is, of course, the question what should be a class
?, its definition fundamentally drives the implementation. The most basic definition of a class
is that it is a data structure comprising of member data and functions.
In practice this is often:
class A {
private:
item1: type1;
/* other members */
public:
func foo();
/* other members */
}
In Rust
we already have somewhat of this through:
struct A {
item1: Type1,
}
impl A {
pub fn foo() {}
}
So let's start with this, a class
is a data structure; meaning it can have structure items, member variables wherein we can specify its visibility. A class
can also have functionality that can be publicly/privately/statically available.
It could look something like:
struct A {
item1: Type1, // private data member
pub item2: Type2, // public data member
}
impl A {
fn foo1() {} // static private function
pub fn foo2() {} // static public function
fn foo3(&self) {} // private const member function
fn foo4(&mut self) {} // private non-const member function
pub fn foo5(&self) {} // public const member function
pub fn foo6(&mut self) {} // public non-const member function
}
This covers half of my need in the system that I'm writing; a data structure that I can create and manipulate through its functionality. As is with the definition of a class
in OOP, this is only half; class
es can have functionality generalized somewhere else.
Thus comes in Rust
's trait
s, which suffice to say in my traditional OOP brain, function similar to Java
's interface
s. This meaning, Rust
in fact satisfy the basic definitions of OOP class
es.
In practice, we often do:
trait Trait1 {
fn get_item1(&self) -> &Type1;
fn get_item2(&self) -> &Type1;
fn sum(&self) -> Type1 {
self.get_item1 + self.get_item2
}
}
impl Trait1 for A {
fn get_item1(&self) -> &Type1 { item1 }
fn get_item2(&self) -> &Type1 { item2 }
// fn sum(&self) is *provided*
}
For the most part this solves the next quarter in my needs. And in reality, functionally, all that I want to have. This is, from my perspective at least, what inheritance in Rust
should do.
Unfortunately, this is where the problem also arises: when implementing the same interface across a bunch of classes, it gets boring real fast. Furthermore, the codebase for a project becomes repeated stuff, which is quite frankly too verbose.
My Idea
Given that Rust
simply just inherits the C11
memory model, and as often the book describes: memory is allocated sequentially to how it was declared.
In theory:
+---+---+---+
| a | b | c |
+---+---+---+
=
struct Foo {
a: u8,
b: u8,
c: u8,
}
Thus we can have:
struct Foo {
a: u8,
b: u8,
}
struct Bar {
foo: Foo,
c: u8,
}
Should be equivalent to:
+-------+---+
| foo | c |
+-------+---+
+---+---+
| a | b |
+---+---+
Which essentially covers how inheritance is handled in memory in C++
.
This is easy and all, but the real problem starts with functionalities and trait implementations.
Ideally it can be simple as:
impl Foo {
fn foo1();
fn foo2(&self);
}
impl Trait for Foo {
fn foo3(&mut self);
}
impl Bar {
fn foo1() {
Foo::foo1()
}
fn foo2(&self) {
self.foo.foo2()
}
}
impl Trait for Bar {
fn foo3(&mut self) {
self.foo.foo3()
}
}
impl Into<Foo> for Bar { // yes I know this is not recommended
fn into(self) -> Foo {
self.foo
}
}
impl From<Foo> for Bar {
fn from(val: Foo) -> Bar {
Bar {
foo: val,
c: u8::default(),
}
/* the implementation here should really be read from memory address */
}
}
It can take a lot of work, but it could be possible with procedural macros.
This idea only could work if there is only 1 base class and the memory model is strictly ordered (hence the title).
Furthermore, I must also find a way to enforce ctor for any class, to streamline the process of constructing the base class.
Particular Caveats that I'm not a Fan of
I fully admit this is without flaws and that is fully because this is just a plan for now and Rust
isn't particularly comfortable with this approach either (I know I'm breaking a lot of safety guardrails already). Don't get me wrong, I'm a fan of the safety guarantees in Rust
but I'm also lazy and love to code to do stuff (and not just copy-paste all day).
Here are the caveats that I've uncovered so far:
- Down and up casting can't be simple
Into
andFrom
implementations, and reading from memory address is unsafe and could lead to OOTA. I imagine I should implement some kind of runtime memory marking to guard rail if an instance can be down or upcasted outside the assistance of the borrow checker. - Collections (
Vec
,HashMap
,Set
etc.) can be a pain deal with on a base class; unless I can somehow enforce that base classes can only be in a collection as&'a mut T
. - Heap allocation containers (
Box
,Arc
, etc.) can be a headache. I'm honestly clueless on how to enforce heap allocations to allocate dynamically, i.e. grow in place the memory allocation if polymorphing into a child class. This is in part because I'm not that well-versed to the ongoing topic of theallocator-trait
(I tried, at the very least).
My end goals with this plan
- Make inheriting code highly readable — As I've noted before, I find the repeated same code implementation that the current
trait
system enforces as to drown the readability of the codebase. - Make inheriting code streamlined — Again, this brainfart essentially came out because I got bored of essentially copy pasting implementations.
- Make inheriting code safe — The main headache I see in
C++
code with polymorphism is bunch of OOTAs, leading to reading access violations and aim to prevent that when implementing this plan.
Posting this here to hear the ideas of the community on this matter.
Thank you for reading my long first post! Have a nice day!