Give "extends" a chance?

I found writing Rust, using inheritance is rarely needed. However, in niche situations I think it would be a godsend. Here is an example:

struct Creature<E> {
    hit_points : isize,
    name : String,
    extra : E
}

struct Demon<E> {
    demon_domain : String,
    extra : E
}

struct Imp<E> {
    fireballs_left : usize,
    extra : E
}

trait CreatureTrait {
    fn announce(&self);
    fn attack<F>(&mut self, target : &mut Creature<F>);
}

impl<E> CreatureTrait for Creature<E> {

    default fn announce(&self) {
        println!("{} enters.", self.name);
    }

    default fn attack<F>(&mut self, target : &mut Creature<F>) {
        println!("{} makes a general attack on {}.", self.name, target.name);
    }
}

impl<E> CreatureTrait for Creature<Demon<E>> {
    default fn announce(&self) {
        println!("{} ascends from the demonic place '{}'", self.name, self.extra.demon_domain);
    }

    default fn attack<F>(&mut self, target : &mut Creature<F>) {
        println!("{} uses it's claws on {}", self.name, target.name);
    }
}

impl<E> CreatureTrait for Creature<Demon<Imp<E>>> {    
    fn announce(&self) {
        println!("A demonic imp ascends from the Abyss");
    }

    fn attack<F>(&mut self, target : &mut Creature<F>) {
        println!("The imp throws a fireball at {}", target.name);
        self.extra.extra.fireballs_left -= 1;
    }
}

Now the following code will work, whether or not the Rust compiler knows the type of the objects at compile time:

fn main() {
    let mut demon = Creature::<Demon::<()>> {
        hit_points : 100,
        name : "Asmodeus".to_string(),
        extra : Demon { 
            demon_domain: "Hell".to_string(),
            extra: () 
        }
    };

    let mut target = Creature::<()> {
        hit_points : 42,
        name : "General Fighter".to_string(),
        extra : ()
    };
    
    demon.announce();
    demon.attack(&mut target)
}

The result is:

Asmodeus ascends from the demonic place 'Hell'
Asmodeus uses it's claws on General Fighter

This seems basically how to do OO in Rust. In my opinion it would be cleaner and more explicit with an extends keyword. At the risk of sounding heretical, is there any chance of an extends keyword in the future? Then the code would look something like this:

struct Creature {
    hit_points : isize,
    name : String,
}

struct Demon extends Creature {
    demon_domain : String,
}

struct Imp extends Demon {
    fireballs_left : usize,
}

trait CreatureTrait {
    fn announce(&self);
    fn attack(&mut self, target : &mut Creature);
}

impl CreatureTrait for Creature {

    default fn announce(&self) {
        println!("{} enters.", self.name);
    }

    default fn attack(&mut self, target : &mut Creature) {
        println!("{} makes a general attack on {}.", self.name, target.name);
    }
}

impl CreatureTrait for Demon {
    default fn announce(&self) {
        println!("{} ascends from the demonic place '{}'", self.name, self.demon_domain);
    }

    default fn attack(&mut self, target : &mut Creature) {
        println!("{} uses it's claws on {}", self.name, target.name);
    }
}

impl CreatureTrait for Imp {    
    fn announce(&self) {
        println!("A demonic imp ascends from the Abyss");
    }

    fn attack<F>(&mut self, target : &mut Creature) {
        println!("The imp throws a fireball at {}", target.name);
        self.fireballs_left -= 1;
    }
}
3 Likes

It’s not completely out of the question, but it won’t be anytime soon. A change of this magnitude will take someone writing a formal RFC, advocating for that RFC so that it gets accepted, implementing the changes behind a feature flag, and finally advocating for the implementation to get stabilized.

That’s a lot of work, and I don’t think anyone has started the process. If you’d like to try it yourself, the place to start is asking on IRLO, where the development discussions happen.

(NB: I skipped over most of your proposal, so I don’t know whether or not it is technically sound as written. Often, this sort of proposal fails not on technical grounds, but instead based on the effects it might have on the wider the Rust ecosystem.)

3 Likes

I think your proposal overlaps with fields in traits.

From a practical point, this will never happen. The change is too radical and being proposed too many times.

btw, I would recommend look up entity-component-system, which seems fits your need.

2 Likes

It's not question of belief, it's question of practicality. OOP (and extends) only work if you obey LSP.

Which, basically means: everything you can do with ancestor you can do with any descendant. Everything. And in Rust… it just doesn't work.

    fn change_name(creature: Creature, name: String) -> Creature {
        Creature { name, ..creature)
    }

What happens if you pass Demon in change_name? What happens if you have Vec<Creature>s? What happens if someone else adds traits with such functions?

All the choices that you can invent are, basically, dead ends. You may say that this shouldn't be allowed. But what if you have Creature defined in one crate, change_name in the other crate and Demon in third crate? Which crate should fail to compile and why?

You may say that you can only use extends for types defined in one crate… but then, in most OOP program extends used across library boundaries! Why adding feature which would be incompatible with expectations of users?

Tragedy of OOP lies with the fact to be sound it needs thoroughly non-local property (if A extends B then A is always B, no matter what, when and how we are talking about), but to be actually useful you have to write code only with local knowledge (if you would demand that anyone who writes any code which accepts Creature would talk to you and get an approval from you… this just wouldn't work).

There are three well-known pillars to OOP: Inheritance, Encapsulation and Polymorphism.

The tragedy of OOP in Rust: Rust adds fourth property — Soundness. And makes it non-optional.

That immediately makes the whole thing pointless: if you want to ensure that the code which you write is sound you have to pick two of three.

Traits with default methods give you Inheritance and Polymorphism at the cost of Incapsulation, traits without default methods give you Encapsulation and Polymorphism by sacrificing Inheritance, nested data structures and newtypes give you Inheritance and Encapsulation without Polymorphism. That's where:

comes from.

We already have all useful combos — and in quarter-century of OOP development no one invented a practical way to bring all three together (from purely mathematical POV LSP works, but it's fig leaf: it just replaces one impossible problem with another, equally impossible, problem).

5 Likes

Let’s keep the order right: Of course we’ll need “Rust with Classes” first!

5 Likes

I just creeped myself out with the thought of a Rust ISO standard and accompanying design-by-committee :stuck_out_tongue:

4 Likes

When do we get Rustyclone (version without unsafe), Rust— (direct register access and in-line mnemonics), HolyRust (weird stuff for christians willing to write operating systems) and M*crosoft V#sual Rust (too perverted, I had to censor this)? :smiley:

1 Like

Just going to re-post what I said last time there was a discussion on the merits of classical object-oriented inheritance:

17 Likes

If we had support for arbitrary object-safe self types, you could write a pattern like this:

struct BaseClassData<SubClassData:?Sized=dyn BaseClass> {
    field_1: String,
    ...,
    subclass:SubClassData
}

trait BaseClass {
    fn ref_method(self: &BaseClassData<Self>) { ... }
    fn mut_method(self: &mut BaseClassData<Self>) { ... }
    fn owned_method(self: BaseClassData<Self>) where Self: Sized { ... }
}

impl BaseClass for () {}

impl<T:?Sized + BaseClass> BaseClassData<T> {
    // final methods
}

In fact, it would probably be possible to make something like this work today with a few clever dyn tricks that could be implemented via a crate/proc macro. Implementing that would be a good first step if someone wanted to push for an OOP-style inheritance system to be added to the language.

1 Like

Well you could make subclasses "slice" like C++ (I hear the screams of outrage).

A more interesting alternative is add a keyword to a struct (maybe "virtual" - more screams) so the struct has the same behaviour as a locally allocated struct at all times, but internally it is always dynamically allocated.

No. You are pretty much right. I will learn to live without "extends" and use my generic pattern like I planned when I really need it.

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.