Newbie question: what's the idiomatic way to do Python-style inheritance in Rust?

I have an application in Python that has two editions, pro and standard. The entire application is structured in this way, where there is a pro class and a standard class for various aspects of the system. Depending on whether user is pro or standard, a different class is used. This design leans heavily on OO. If a method is not available in the pro class then the standard method is automatically used, and the pro class often calls down into the standard method after doing some sort of "pro" oriented thing.

What is the idiomatic way to do the same thing in Rust? I'm not looking for someone to write the code for me, but even just words to point in the right direction.

Is there a "right" way to do this in Rust?

class Pro(Standard):

    def __init__(self):
        super().__init__()

    def do_something(self):
        super().do_something() # call down into the standard method first
        print('did it professionally')

class Standard():

    def do_something(self):
        print('did it in a standard way')

user_level = 'pro'

if user_level == 'pro':
    foo = Pro()
else:
    foo = Standard()

foo.do_something()

I would make the Pro struct simply encapsulate a Standard struct and have them both implement a common trait.

Sample Implementation: click to exapnd
trait Edition {
    fn do_something(&self);
}
struct Standard;
struct Pro {
    std: Standard,
}

impl Edition for Standard {
    fn do_something(&self) {
        println!("did it in a standard way");
    }
}

impl Edition for Pro {
    fn do_something(&self) {
        self.std.do_something();
        println!("did it professionally");
    }
}
fn main() {
    
    let user_level = "pro"; // get this from somewhere...
    
    let foo: Box<dyn Edition> = match user_level {
        "pro" => Box::new(Pro{std: Standard}),
        _ => Box::new(Standard),
    };
    
    foo.do_something();
}

Edit: Another approach would be to create an enum with Standard & Pro as variants instead of using Box/dyn in the sample provided.

4 Likes

I like that idea. Something like

struct Standard { ... }
enum Either {
  S(Standard),
  Pro {
      s: Standard,
      fancy: String,
  },
}

This way you can share structural information from the Standard struct, and a few wrappers (or a trait) can let you treat either.

3 Likes

Your example code is a blessing thanks.

Regarding your edit/second approach ... would you mind showing where in your example this would be implemented?

thanks!

No prob. :slight_smile:

Well, I think it would be convenient to have the enum implement the common trait so something like this could be done:

enum Editions {
    Pro(Pro),
    Standard(Standard),
}

impl Edition for Editions {
    fn do_something(&self) {
        /* saves the previous Box<> allocation */
        let s: &dyn Edition = match self {
            Editions::Pro(v) => v,
            Editions::Standard(v) => v,
            /* more editions? */
        };
        s.do_something();
    }
}

fn main() {
    let user_level = "pro"; // get this from somewhere...
    
    let foo = match user_level {
        "pro" => Editions::Pro(Pro{std: Standard}),
        _ => Editions::Standard(Standard),
    };
    
    foo.do_something();
}
1 Like

Is there really a hierarchical relationship between Pro and Standard? Even your own example casts doubt on this assumption: calling Pro::do_something prints did it in a standard way followed by did it professionally, which seems like a contradiction. If you wanted to only print did it professionally, you'd have to remove the super().do_something(), which works in the trivial example but starts to break things in more complicated scenarios. Furthermore, what if you wanted to add a Student edition with some of the features of Pro but not all?

This inheritance hierarchy doesn't seem like a hierarchy to me. Let's turn it sideways:

Standard               Edition
   ↑         ⇒       ┌────┴────┐
  Pro            Standard     Pro

Edition will be a trait implemented for all editions. But that's just the API that defines what an edition is: you still need a place to put shared data and behavior common to all editions, so let's put that in a generic struct:

struct Application<E> {
    edition: E,
    whatever: String,
}

impl<E: Edition> Application<E> {
    fn do_something(&self) {
        println!("I'm about to do something!");
        self.edition.do_something(&self.whatever);
    }
}

trait Edition {
    fn do_something(&self, whatever: &str);
}

struct Standard {}

impl Edition for Standard {
    fn do_something(&self, whatever: &str) {
        println!("did {} in the standard way", whatever);
    }
}

struct Pro {}

impl Edition for Pro {
    fn do_something(&self, whatever: &str) {
        println!("did {} professionally", whatever);
    }
}

This is similar to naim's suggestion, but the common stuff ("I'm about to do something!") is in the wrapper struct, rather than folded in with the Standard edition-specific stuff.

You may also be able to use provided methods to avoid cluttering the impls with empty functions if there is a lot of behavior that only needs to be defined in one or the other but not both. With only two impls it doesn't really reduce the amount of code to write, but it might if you add a third Edition.

And yeah, this isn't flawless. For example, you will encounter problems if you have methods that mutate the shared data in Application as well as the edition-specific data in Standard and/or Pro. But it's a toy solution for a toy problem. If you have additional requirements and constraints, you'll have to adjust it. But that's just what software architecture is.

7 Likes

Is there really a hierarchical relationship between Pro and Standard ? Even your own example casts doubt on this assumption: calling Pro::do_something prints did it in a standard way followed by did it professionally , which seems like a contradiction.

True my example probably isn't good from that angle.

But yes, that happens alot in this application.

For example, a professional method "store" may be called. The professional "store" method has the same functionality as the standard "store" method, BUT it always encrypts one specific field on the data that comes in via argument. So to avoid code duplication, the professional method does nothing at all except to encrypt one field of the arguments, then it calls the standard "store" method with the now modified arguments to do the rest of the work of storing the data. Thus the professional method has not much in it except "encrypt one field in the arguments, then pass it on to standard to do the rest of the work", whilst the standard method might have many lines of work to validate process and store the entire set of arguments.

I guess that raises an important point about this question .... the professional method needs to be able to validate/transform the inbound arguments/data prior to sending it on to the standard method. Both professional and standard methods always have the exact same signature.

You're right my example is not very clear when it says it is doing something both ways. I was trying to cut it down to a minimal example.

So in that case I might write something like this:

impl<E: Edition> Application<E> {
    fn do_something(&self, arg: String) {
        let arg = self.edition.encrypt(arg);
        println!("I'm doing {}!", arg);
    }
}

trait Edition {
    fn encrypt(&self, arg: String) -> String {
        arg // default implementation
    }
}

impl Edition for Standard {}

impl Edition for Pro {
    fn encrypt(&self, arg: String) -> String {
        arg.chars().rev().collect()
    }
}

If there really is no behavior that is in Standard but not in Pro, then maybe this is unnecessary flexibility. But if you have any functions in Pro that do not just call the parent function in Standard, or if the Standard function has a flag that you pass it just to tell it whether it's being called via Pro or not, that would be a good sign that the problem is not actually well modeled by inheritance.

3 Likes

Taking a similar approach to what @trentj proposed, you can go a long way in doing pseudo-OO in Rust:

Please appreciate how neat the field accesses are working out...

use std::ops::{Deref, DerefMut};
use derive_more::{Deref, DerefMut};
use enum_dispatch::enum_dispatch;

#[derive(Clone, Debug)]
struct StandardFields {
    field1: i32,
    field2: i32,
}
#[derive (Deref, DerefMut, Debug, Clone)]
struct Standard(StandardFields);

// implementations for standard in here
#[enum_dispatch]
trait ApplicationTrait: DerefMut<Target = StandardFields> {
    fn do_something(&self) {
        println!("doing standard");
    }
    fn modify_some_fields_to(&mut self, x: i32) {
        self.field1 = x;
    }
    fn some_other_method_without_overrides(&self) {
        println!("hello from other method");
    }
}
impl ApplicationTrait for Standard {}


#[derive(Deref, DerefMut, Debug)]
struct Pro {
    #[deref(forward)]
    #[deref_mut(forward)]
    standard: Standard,
    extra_pro_field1: i32,
    pro_field2: i32,
}

// override whatever you like for pro
impl ApplicationTrait for Pro {
    fn do_something(&self) {
        println!("doing pro");
        self.standard.do_something();
    }
    fn modify_some_fields_to(&mut self, x: i32) {
        self.field2 = x;
        self.standard.modify_some_fields_to(x);
        self.pro_field2 = x;
    }
}

fn main() {
    let app1 = Standard(StandardFields{
        field1: 42,
        field2: 100,
    });
    let app2 = Pro {
        standard: app1.clone(),
        extra_pro_field1: 1337,
        pro_field2: 0,
    };
    let mut apps = [Application::from(app1), Application::from(app2)];
    for app in &mut apps {
        app.do_something();
        app.modify_some_fields_to(999);
        app.some_other_method_without_overrides();
        print!("{:?}\n\n", app);
    }
}

#[enum_dispatch(ApplicationTrait)]
#[derive(Debug)]
enum Application {
    Standard(Standard),
    Pro(Pro),
}

// couldn’t find any crate to derive this kind of impl
impl Deref for Application {
    type Target = StandardFields;
    fn deref(&self) -> &StandardFields {
        use Application::*;
        match self {
            Standard(s) => s.deref(),
            Pro(p) => p.deref(),
        }
    }
}
impl DerefMut for Application {
    fn deref_mut(&mut self) -> &mut StandardFields {
        use Application::*;
        match self {
            Standard(s) => s.deref_mut(),
            Pro(p) => p.deref_mut(),
        }
    }
}

Output:

doing standard
hello from other method
Standard(Standard(StandardFields { field1: 999, field2: 100 }))

doing pro
doing standard
hello from other method
Pro(Pro { standard: Standard(StandardFields { field1: 999, field2: 999 }), extra_pro_field1: 1337, pro_field2: 999 })

One detail to note here (click me to expand/collapse):

When you call down to the same (or a different) standard method via .standard.method(), that method cannot "call up" to other pro methods anymore. If you, however, call "horizontally" to a different method via self.method() from pro, that method can in turn call other pro methods, even if Pro doesn’t implement the method itself but its implementation comes from the standard/default implementation. The default implementation gets duplicated in this case into two versions: the standard version, accessing other standard methode, and the pro version, accessing other pro version methods. If e.g. you wanted do_something to call another method that needs to behave like the pro version on pro, you can split it up like e.g.

// in trait ApplicationTrait:
    fn __do_something(&self) {
        println!("doing standard");
        self.modify_some_field_to(42); // supposed to have different behavior on pro
    }
    fn do_something(&self) { self.__do_something() }

// in impl ApplicationTrait for Pro:
   // don’t implement __do_something
   fn do_something(&self) {
        println!("doing pro");
        self.__do_something(); // calls standard version with access to pro
   }

If you always want this behavior, just don’t use the .standard field explicitly at all and always introduce a duplicate method. (I suppose, someone could write a macro for this kind of stuff, too....)


Edit: Since this thread is about "idiomatic" ways, I have to mention that my code above might not be the most idiomatic Rust code.

2 Likes

I just thought to mention that if you don't need different data for the two versions, you could avoid using methods at all for the Standard/Pro distinction. e.g. something like

enum Edition { Standard, Pro }

fn do_something(data: ..., more_data: ..., edition: Edition) {
  Do stuff...
  if let Edition::Pro = edition {
     Do something extra special...
  }
  Do rest of stuff...
}

By separating the edition entirely from the data, you would gain the ability to make the distinction in behavior within the functions, which could still be methods on your other data types.

But this will likely only work if the same data is stored in the two versions (or almost the same, so some fields could just be unused in one case), which is likely not the case.

1 Like

@droundy I need to separate the Pro and Standard code entirely - into different files - because the Standard is a different license to the Pro. The standard version needs to work with 0% knowledge of anything about the pro version.

FYI, this smells like an XY problem. Try giving as concrete an example as you can. :wink:

I think inheritance is usually taught wrongly. Here's why.

Inheritance is focused on the variants. You tend to put many different, but similar things into one class. A symptom of this is combining classes: If you have classes Animal with Cat and Dog, and Colored with Red and Blue, then how do you make colored animals? Wouldn't you need to create classes for each combination, RedDog, BlueDog, RedCat, BlueCat?

Instead, I find it much healthier to think about what I need at the place of use. At some point in your code you have something that says, "I don't care what animal you give me, but I need to know how to feed it," and another part that says, "I need something with a color so that I can sort it correctly." You can express these two requirements with traits (or interfaces in other languages).

With traits, it's much easier to make a blue dog. You have a struct and implement both traits for it.

So in your case, I wouldn't try to emulate inheritance. Identify the parts in your code that don't care which version they use. For example, if you have a license page, then define a VersionName trait with a method name(). The license page then simply asks for something that can give its name, i.e. a VersionName.

I hope that you find this relevant. I am really frustrated that I took so long to understand this difference, so I was trying to explain it in a way that makes sense. Maybe you'll find the interface/trait way of thinking a bit easier.

5 Likes

The second Rust koan provides insight into the limitations of inheritance vs. Rust's traits and methods approach.

2 Likes

And here's the XY problem.

The actual question to differentiate the right answer is whether the standard version is open source. If it is, you'll indeed probably want some way to separate the files; there are other ways but it's generally the same idea. But if it's not, you can just use features. With that, none of the pro code will even be built in the standard version, and will certainly not make it into the standard distribution, guaranteed.

Then the particular structure of the Rust code is irrelevant and can be tailored to other problems like your actual functionality.

2 Likes

@passcod Good thinking thankyou yes the standard is open source so indeed it is a requirement to separate the files.

Even then you'll probably want to use a feature, and then include the files in place e.g. with #[cfg(feature = pro)] include!(pro/file.rs); rather than necessarily using the type system for it. includede

1 Like

To not create a shadow of the closed-code in the open source version, there is two ways:

  1. Make your "standard version" a crate(library) or a hybrid crate-runnable, so it can be compile to run but also as a crate with the possiblity of use its functions, enums, structs, traits, etc. Then you only have to use it from your pro version :wink:.
  2. The patch way: when you are developing the pro version you need to replace at least the main.rs with something more that invokes the code used in the pro version and is only available in that version, of this way you can continue using the standard methods because you are in the same project but the open source version not have clue of this.

In any other way you will need to put some special code in the open-source version like that described by @passcod.

1 Like