Single-Inheritance Classes requiring Strict Memory Model

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; classes can have functionality generalized somewhere else.
Thus comes in Rust's traits, which suffice to say in my traditional OOP brain, function similar to Java's interfaces. This meaning, Rust in fact satisfy the basic definitions of OOP classes.

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:

  1. Down and up casting can't be simple Into and From 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.
  2. 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.
  3. 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 the allocator-trait (I tried, at the very least).

My end goals with this plan

  1. 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.
  2. Make inheriting code streamlined — Again, this brainfart essentially came out because I got bored of essentially copy pasting implementations.
  3. 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!

1 Like

Note that this requires an explicit #[repr(C)] declaration on your type, as the default layout doesn't give any guarantees to field ordering.

2 Likes

Your understanding of what "memory model" means is wrong. From Wiki:

In computing, a memory model describes the interactions of threads through memory and their shared use of the data.

In other words, it does not cover memory layout of structs defined in the language.

You can easily cover conversions between Foo and Bar in a safe manner by implementing AsRef and Deref traits. In practice people sometimes abuse these traits to implement inheritance-like APIs, but usually it's frowned upon.

5 Likes

Personal opinion: don't. Figure out what your "class" needs to store - which will go inside the definition of your end struct or enum; the behaviour it is expected to have - defined either as an impl or an impl Trait, depending on what you're going after; then implement it, as needed.

Forget "single inheritance". If you intend to work with memory directl by getting down and dirty with the struct's or enum's layout and representation, declare your trait as unsafe - to mark your intent for yourself and your lib's users down the line. Provide sane defaults, and be done with it.

For bonus points: figure out the logic behind your instinct to go for a SI-ed class, to begin with. Work your way up, to understand all the possible solutions available to the problem at hand. Pick the one suitable for this language's design. Don't cram squares into circles: they're different shapes.

Rust's MM is not your only option: see #[repr(C)].

Sounds like a job for your standard trait. Not sure as to why you would want to get down to the layout and offsets of individual fields if all you're looking for is some shared interface, honestly.


Another piece of opinion: instead of trying to port an abstract solution (single inheritance) to an environment that doesn't share the same conceptual framework (class vs enum/struct + impl) to begin with, start with a concrete problem, and work your way up from there.

3 Likes

I encountered the same difficulties because of the lack of inheritance, so I understand where you're coming from. I also mostly agree with the pros and cons, and while there's a risk of abusing inheritance, the same could be said of most language features.

I don't think it should be too hard to do the part that avoids all the compositional repetitions with a procedural macro (likely the function flavour), although you'd have to put all the relevant code in the same macro, since there is no way to pass information from one macro to the next from the perspective of the procedural macro code. For example, something like this wouldn't work:

class!(Widget { 
    /* fields, methods */ 
})

// ...

class!(ScrollBar: Widget { 
    /* fields, methods */ 
})

This could be a serious practical limitation. I've seen a hack to pass information through temporary files, but I haven't looked much into that because it seemed fragile.

EDIT: Scratch that. It should be possible to store data as a constant or static when generating the code of the first class, then get that data back from the name of the "base class" in other macros. Of course, it needs to be a type of data that can be const or static, and it's not very macro-hygienic, but it's a way to work around the problem.

PS: I haven't read your example fully; when I saw a bunch of "foo" and "bar", my eyes started to squint over the meaningless names. :wink:

1 Like

Yeah, I think most people coming from an OO-heavy background have done this in one way or another when first using Rust. I did it too, came up with some “clever” (or so I thought) ways to bring OO patterns into Rust. But, as you might guess, I eventually realized it was a dead end.

But here’s the thing: By going through that process, I learned the Rust way and started to understand why things are done differently in Rust.

So my advice: Go ahead, build your OO-inspired designs in Rust, gain that hands-on experience, and eventually, you’ll find yourself thinking like a true Rustacean. :wink:

5 Likes

Long time ago, when I started using OO in C++, I had large class hierarchies with fields and methods scattered over many levels of hierarchy.

Usually, there were lots of classes. For example, Man and Woman would inherit from Person, and respectively from Male and Female. Soon, there would also be LivingBeing and NameOwner, and the temptation to also add FemaleLivingNameOwner and so on and it gets a bit out of hand.

After years of OO in C++, Java and Scala, I gradually stopped using inheritance to share fields and implementations, and instead shared implementations through composition and helper functions. So most of my classes became abstract base classes in C++, or interfaces in Java, or traits in Scala.

My earlier style would be difficult to translate to Rust, but that's fine, because I consider it obsolete. My later styles translates to Rust easily. Well, almost. Traits in Rust are not types and can not have fields.

Also, in OO-languages, if you tell it that she is a Woman, it will automatically assume that she is also a Person and a Female, and so on, while Rust will ask you to also implement the Person and Female trait bounds, etc.

But at least, there is no FemaleLivingNameOwner, it's just Female + Living + NameOwner, and I'm fine with that.

2 Likes

An alternative formulation, which maybe matches C++ inheritance patterns a bit better, is to put a generic extension field on Foo:

struct Foo<Ext> {
    a: u8,
    b: u8,
    ext: Ext
}

struct Bar {
    c: u8
}

With this structure, you can write inherent methods for both Foo<T> where T: SomeTrait and Foo<Bar> for methods that only apply to the specific derived type.

3 Likes

As others have pointed out, you're reasoning about layouts, not memory models. The default, Rust layout is unspecified and subject to change (even within a single compiler version, thanks to things like PGO and intentional randomization). So you'd be talking C repr or such.

But you said single-inheritance. So don't hack it yourself. Use composition and From, Deref or AsRef, etc. Those are existing, safe, and idiomatic traits which exist for such purposes.

And/or use dyn and supertrait upcasting.[1]

You've made some leap in the narrative here, and I don't fully follow. As best I can tell you want some sort of in-place polymorphism. But it's hard to give technical advice (or explain what parts are not possible) without a clearer picture.

You can't, but you don't want that anyway. Rust references are generally for temporary borrows, not your primary long-lived data structures.

Different layouts? You can't do it in place.

Maybe you want something more like a union.


Rust doesn't have inheritance, classes, or type-based subtyping. So do note that you'll be swimming against the waves the whole way.

Yes, Rust is very boilerplate heavy. But you'd be trading it for a lot of unsafe, dead-ends, unidiomatic code, and learning about how Rust defines UB the hard way.

An interesting learning project? Perhaps. An idea likely to result in something others would like to use or maintain? Unlikely.


  1. Note that despite the name, there is no subtype relationship between a dyn SubTrait and dyn SuperTrait. ↩︎

4 Likes

Thank you everyone for taking time to read and reply to my post!

Regarding my confusion between memory layouts and memory models, yes, I admit I often confuse the two.

I highly appreciate the community's advice and correction on the matter and will explore it further.
I am mystified by this supertrait upcasting, and I think it would work well with what I intend to do.

Again, thank you very much :smiley:

1 Like