How to think without field inheritance?

I'm new to rust with experience with Java, C++, and Python. In order to get more familiar with Rust I need to learn how to think more like a crab (rustecean?). At uni, OOP was the major method of programming taught, Rust only has inheritance of traits, not structs.

The most useful thing in inheritance, I find is field inheritance, but that doesn't exist in Rust (at least not to my knowledge). Is there any good material on how to write good, clean code without that? Most of what I find is just proselytizing "composition over inheritance", but I don't care about dogmatic discussion in programming as everything has its place.

The obvious and (IMO) very ugly OOP workaround would be defining traits with methods that look like fields e.g

class Device:
    wattage: float
    name: str
    fs_path: str # /dev/...

class AudioDevice(Device):
    sample_rate: float
    volume: float

class Microphone(AudioDevice):
    directional: bool

class SoundSystem(AudioDevice):
    speaker_count: int
    bass_count: int
trait HasDeviceProperties {
  fn wattage(&self) -> f32;
  fn name(&self) -> String;
  fn fs_path(&self) -> String;
}

trait HasAudio: HasDeviceProperties {
  fn sample_rate(&self) -> f32;
  fn volume(&self) -> f32;
}

trait RecordsAudio: HasAudio {
  fn directional(&self) -> bool;
}

trait PlaysAudio: HasAudio {
  fn speaker_count(&self) -> u8;
  fn bass_count(&self) -> u8;
}

struct Microphone {
  _wattage: f32,
  _name: String,
  _fs_path: String,
  _sample_rate: f32,
  _volume: f32,
  _directional: bool,
}

impl RecordsAudio for Microphone {
  fn wattage(&self) -> f32 { self._wattage }
  fn name(&self) -> String { self.name }
  // ...
}
// Repeat for SoundSystem and replace directional with the other methods

I haven't run this through the compiler yet, but it should look similar to this. It is in the Rust by Example: Supertraits chapter, but this can't be the compositional or rust way of doing it (at least I hope it isn't). There'd be loads of copy-pasting and boilerplate code (maybe a macro could get rid of it, but ugh) and methods look like properties.

What's a conceptually better way of solving problems like this?

8 Likes

Well, arguably, the “problem” you’re defining here is “define something akin to an OOP inheritance hierarchy”, which is not a very natural problem, some people might argue. But nonetheless, such hierarchy-like patterns can be useful...

...as you identified,

composition is a way to achieve some of the (multiple) properties of inheritance. Then, e.g.

would be written differently, by including some device: Device field, or perhaps an audio_device: AudioDevice if you want this multi-level hierarchy. Also … by the way … why all the underscores in the field names?

Anyways, when you use composition, and also want to expose all those “inherited” fields in the form of an interface (via traits), then one alternative approach instead of accessor functions for all the fields, you could save some boilerplate and provide an accessor to the whole Device at once. Either using custom traits, or perhaps even using standard-library traits, you could just implement AsRef<Device> for all traits with device-functionality (and maybe also AsMut<…> if you want to allow mutable access). Of course, custom traits become more useful, as soon as the interface of a Device is supposed to be more than just access to the fields, i.e. as soon as you want to allow some methods, too. W.r.t. methods, there are significant differences to OOP, and what the “closest” equivalent approach would be probably depends heavily on the particular use-case.

Finally, there’s also the Deref trait which allows for conveniently working with concrete types in such a composed hierarchy, so you can syntactically directly access fields and methods of – say – the contained Device on a Microphone, or even do implicit coercions from &Microphone to &Device. The Deref trait is not a good way for defining an interface though; it really is mostly useful just for more conveniently working with concrete types, you probably won’t get too far if you tried writing generic function with T: Deref<Target = Device> functions.


Finally, let me remark that while I know the features of the language very well, I don’t do too much practical Rust programming, and in particularly I’ve never tried employing any of the patterns above in a larger project myself, so I can’t give recommendations of what might work better or worse with 100% certainty, nor do I know how advisable or inadvisable following the idea to try to, say, “model an OOP-inheriteace-like hierarchy in Rust” is in the first place.

8 Likes

That's the way to do it if you really need these traits. The way you can simplify it is by not trying to model all aspects of reality with the traits, but treat them as very pragmatic interfaces for specific usages.

Do you really need it to be generic? Are you going to work with pluggable and extensible audio systems? If you need a list of device types, it could be enum { Microphone(directional), Speaker(bass) } or something like that.

Try thinking of traits as glue code to make types work with functions, not abstract properties of the objects themselves. Forget about "is-a" relationship. Have flat, unrelated, non-abstract struct types for specific objects, and then write trait glue code to make them fit where they're needed.

16 Likes

My problem is that I would like to think in a rust manner, which means not thinking about inheritance. I'm trying to find out how objects that have similar fields can share those fields with composition.

Hmm.. I'll see if I understand this correctly:

struct Device {
  wattage: f32,
  name: String,
  fs_path: String,
}

struct AudioDevice {
  device: Device,
  sample_rate: f32,
  volume: f32,
}

struct Microphone {
  audio_device: AudioDevice,
  directional: bool,
}

struct SoundSystem {
  audio_device: AudioDevice,
  speaker_count: u8,
  bass_count: u8
}

fn main() {
  let sound_system = SoundSystem {
    audio_device: AudioDevice {
      device: Device {
        wattage: 5000.0,
        name: "5000W bass machine".to_string(),
        fs_path: "/dev/bass".to_string(),
      },
      sample_rate: 48000.0,
      volume: 0.0,
    },
    speaker_count: 5,
    bass_count: 2,
  };
  println!("The sound system is called: {}", sound_system.audio_device.device.name);
}

Is this what you're proposing? I assume then that the complexity of the instantiation could be reduced by introducing a constructor, that doesn't require intimate knowledge of the struct.
It would probably also require an accessor method to hide the internal fields. sound_system.audio_device.device.name is a very long way of getting to a property.

I assumed there would be a naming conflict: struct wattage and trait wattage(). After a test, it seems I'm wrong, which is confusing, but not important to this discussion. Name resolution will be the object of another focus another time.

This is just an example to illustrate what I mean with field inheritance. I need to learn how to think in Rust, which mean without inheritance. There will be problems that require generic solutions for models that are related and I'll have to be able to do that in Rust, just like I can in OOP languages.

For the sake of this example, say I were writing a device manager. How would you model it in Rust in a DRY manner? Many devices will have properties in common and are related (sound devices, display devices, input devices, etc.). How would you make those flat, unrelated, non-abstract struct types?

1 Like

You're still zoomed too far in. You're thinking about problems you've solved with inheritance, and thinking "okay, how do I do that, but without inheritance?" and drawing a blank. This is natural, because when you take a design based on inheritance and remove the inheritance from it, it will have holes.

There are ways to deal with "objects that have similar fields". [1] But that isn't the point: the point is that when you're designing things without inheritance, you (usually) won't create objects with similar fields, and you won't be solving this "problem" because it doesn't exist except in the world where everything is designed around inheritance.

The problem should be understood not as "how do I make structs with similar fields without inheritance?" but instead as "how do I make software without inheritance?" And the only possible answer to that question is: It depends on the software! You cannot design something entirely in the abstract. You need to go back to fundamentals: What does the code do? What are its inputs and outputs? What kind of computer does it run on? Does it have any performance constraints? Etc.

None of these questions has anything to do with objects or inheritance, but you must answer them to be able to design a thing. And that's why "How do I design things without inheritance?" is an impossible question. You design them the same way you would with inheritance - the design process isn't fundamentally about classes and objects and fields, it's about realizing a set of requirements. If you don't have requirements, any design is equally good. Only once you know what the software is supposed to do, in the real world, can you start to meaningfully discuss how one design could be better than another.


  1. Generic structs, for instance. ↩︎

22 Likes

Yeah. Let me illustrate the other kinds of things I was talking about then, too…

macro_rules! hierarchy {
    ($Type:ty$(: $Super:ty [$field_name:ident])?) => {
        impl std::convert::AsRef<$Type> for $Type {
            fn as_ref(&self) -> &$Type {
                self
            }
        }
        impl std::convert::AsMut<$Type> for $Type {
            fn as_mut(&mut self) -> &mut $Type {
                self
            }
        }
        $(
        impl std::ops::Deref for $Type {
            type Target = $Super;
            fn deref(&self) -> &$Super {
                &self.$field_name
            }
        }
        impl std::ops::DerefMut for $Type {
            fn deref_mut(&mut self) -> &mut $Super {
                &mut self.$field_name
            }
        }
        impl<T: ?Sized> std::convert::AsRef<T> for $Type
        where
            $Super: std::convert::AsRef<T>
        {
            fn as_ref(&self) -> &T {
                (**self).as_ref()
            }
        }
        impl<T: ?Sized> std::convert::AsMut<T> for $Type
        where
            $Super: std::convert::AsMut<T>
        {
            fn as_mut(&mut self) -> &mut T {
                (**self).as_mut()
            }
        }
        )?
    }
}

struct Device {
    wattage: f32,
    name: String,
    fs_path: String,
}
hierarchy!(Device);

struct AudioDevice {
    device: Device,
    sample_rate: f32,
    volume: f32,
}
hierarchy!(AudioDevice: Device [device]);

struct Microphone {
    audio_device: AudioDevice,
    directional: bool,
}
hierarchy!(Microphone: AudioDevice [audio_device]);

struct SoundSystem {
    audio_device: AudioDevice,
    speaker_count: u8,
    bass_count: u8,
}
hierarchy!(SoundSystem: AudioDevice [audio_device]);

fn main() {
    let mut sound_system = SoundSystem {
        audio_device: AudioDevice {
            device: Device {
                wattage: 5000.0,
                name: "5000W bass machine".to_string(),
                fs_path: "/dev/bass".to_string(),
            },
            sample_rate: 48000.0,
            volume: 0.0,
        },
        speaker_count: 5,
        bass_count: 2,
    };
    println!(
        "The sound system is called: {}",
        sound_system.name // short syntax made possible by `Deref` implementations
    );
    
    println!("old wattage: {}", sound_system.wattage);
    increase_wattage(&mut sound_system);
    println!("new wattage: {}", sound_system.wattage);
    increase_wattage2(&mut sound_system);
    println!("new wattage: {}", sound_system.wattage);
    increase_wattage_and_print(Box::new(sound_system));
}

// demo: generic function
fn increase_wattage(device: &mut impl AsMut<Device>) {
    device.as_mut().wattage += 100.0;
}

// demo: Deref coercion (look at how this is called above)
fn increase_wattage2(device: &mut Device) {
    device.as_mut().wattage += 100.0;
}

// demo: trait objects
fn increase_wattage_and_print(x: Box<dyn AsRefOrMut<Device>>) {
    let mut d = DataBase::default();
    d.0.push(x);
    d.print_all_wattages();
    d.increase_all_wattages();
    d.print_all_wattages();
}

// a "trait synonym" for "AsRef<T> + AsMut<T>""
trait AsRefOrMut<T: ?Sized>: AsRef<T> + AsMut<T> {}
impl<S: ?Sized, T: ?Sized> AsRefOrMut<T> for S where S: AsRef<T> + AsMut<T> {}

#[derive(Default)]
struct DataBase(Vec<Box<dyn AsRefOrMut<Device>>>);

impl DataBase {
    fn increase_all_wattages(&mut self) {
        for device in &mut self.0 {
            (**device).as_mut().wattage += 100.0;
        }
    }
    fn print_all_wattages(&self) {
        for device in &self.0 {
            println!("wattage in db: {}", (**device).as_ref().wattage);
        }
    }
}

(try out this code in the playground)

As mentioned, I don’t know how well such simulation of OOP-hierarchies work in Rust in practice, but at least it showcases some capabilities of the language; so even given that this is not the most idiomatic use of Rust [1], perhaps it can make you curious to learn more about the language features I’ve used above, in order to understand in detail what’s going on.

Of course, to learn some new ways of structuring code, you should probably focus more on making some use of the kinds of features that Rust has that typical OOP languages don’t: for example algebraic data types, i.e. the enum types that @kornel mentioned above.


  1. the only place so far where I've come across an extensive trait-based OOP-style type hierarchy in practice was in a wrapper of an existing OOP-style C++ library ↩︎

3 Likes

For the sake of argument, consider:

enum Property {
    Float(f32),
    String(String),
    Boolean(bool),
    Byte(u8),
}

struct Device {
    properties: std::collections::BTreeMap<String, Property>,
}

This may seem kind of facetious but it's not really meant that way. I just want to suggest a simple, flat, orthogonal, extremely versatile way of modeling things. It wouldn't work for everything, but nothing will. The point is whether it works for what you actually want to do.

2 Likes

This is well-put. I've programmed off-and-on in OCaml (much like Rust, only + GC - traits) for 30+ years, sometimes very heavily, as well as heavily in Java, Python, and OO Perl. Also C++. By the way, the use of subclassing in C++ code drops way, way off, once you start using STL and other libraries: you start using templates and collection classes much more often, instead. And when using languages that don't have O-O at their center, I don't reach for objects much at all.

But even still ..... there's another language that doesn't enjoy actual "inheritance", and yet you can get most of the value of "field inheritance": Golang (as much as I fricken' hate, hate, hate that language). Perhaps it might be worth looking at that language for inspiration, if one must find a way to model things in a way that uses field inheritance.

"fields in traits" comes up occasionally, but it's not a universally loved concept [1], and independently of that I'm unaware of anyone championing the cause now or recently. So it's a "far future" kind of possibility.


  1. IIRC, don't have time to reread everything right now ↩︎

1 Like

Even in OOP languages heavy use of inheritance is typically a mistake. I've found (and this is backed up by much mainstream thought on OOP these days), that interface inheritance is much, much better. That problem is easily solved with Rust's traits.

Structural inheritance often becomes a straight-jacket and the only thing you likely gain with it is saving some typing on boilerplate code. The classic 'square is a rectangle' anti-pattern of inheritance is a great example of how it can paint you into corners.

Instead of thinking of how A is a kind of B and designing a hierarchy, think about the things A and B have in common and abstract them out either into structs to be composed, or traits to be implemented. More concretely, instead of having Bird derived from FlyingAnimal which is derived from Animal (the later two being abstract classes), have Bird implement a Flying trait, and an Animal trait if really needed.

10 Likes

Recently i've met this crate: Introduction - SuperStruct Guide that seems can generation all the boilerplate code OP-mentioned. Not to say it's a good design pattern, but maybe worth a read.

Traditionally, OOP tries to model "is-a" relationships, while in my opinion, a trait implementation in Rust is more of a "plays the role of' relationship.

The main difference is that a "is-a" is a universal commitment. If A is a B, then it is always a B, regardless of context. In Rust, a type can implement a trait in some context but not in another.

6 Likes

Go actually have field inheritance and implements what topicstarter wants automatically:

type Device struct {
	wattage float32
	name    string
	fs_path string
}

type AudioDevice struct {
	Device
	sample_rate float32
	volume      float32
}

type Microphone struct {
	AudioDevice
	directional bool
}

type SoundSystem struct {
	AudioDevice
	speaker_count uint8
	bass_count    uint8
}

Thus I wouldn't say it's good language to look into. Better would be too look on Haskell, OCaml or, heck, maybe even Linux kernel (what these guys are doing is, surprisingly enough, very close to traits, just they implement them with designated initializers in C).

Yes, I would say that's the key. Instead of trying to share some fields you just don't do that. You just create the trait which you actually want in a particular place and make sure your Microphone and your SoundSystem implement it.

If there are code duplication — you think about how to remove that particular code duplication.

This may sound like it would lead to much more code than OOP, but in practice OOP produces much uglier code. Precisely because of the requirement that if A is a B, then it is always a B, regardless of context. In practice I have seen very few class higherarchies where that was true. Number of bugs produced as a result is insane and workaround can be crazy ugly.

Even in your example: you AudioDevice must have a wattage apparently. Always. Why? What should you put there if that's a virtual device (e.g. device which stores file on disk)?

9 Likes

A way to change thinking could be to consider how would you solve this problem in C (or any non-OOP language)?

If your OOP approach involves instanceof, then Rust equivalent is to use enum.

What does a device manager do? If it just displays objects and their properties, you could have a NamedBagOfProperties trait that has a method to get name and a method that returns either a hashmap, or if you want to be super efficient, an iterator of key/value pairs.

If the properties are editable, then you could have a Property enum to handle various types you have GUI for, and a trait that supports get and set.

Rust is not very concerned with minimizing number of lines of code. If you have multiple objects that are similar-but-not-quite-the-same then you can use a macro to generate their trait impls, e.g. libstd does that for all i8, i16, i32, i64, i128. There's no base Number class. There's a bunch of unrelated types with nothing in common, and a macro that implements Add, Sub, etc. for each of them individually.

10 Likes

Opinions can differ, but to my mind the capability you cite in Golang isn't "field inheritance" but rather just a bit of syntactic shorthand that happens to work, for simple cases, just like field inheritance. And sure, one could imagine implementing this for Rust. In any case, this is precisely why I mentioned Golang.

1 Like

One way other than OOP of modelling objects that often share fields (and often don't share other fields) is the entity–component–system (ECS) pattern. That might fit here, though I'm not sure we have any ECS crates that are neither experimental nor defunct.

1 Like

I've really enjoyed this thread and this traits in fields thing has been something I've also wondered about given that I come from OOP in python. That said, this quoted fragment caught my eye. Do you mind elaborating on this and what you mean by efficient and why its so. I've come across the idea that sometimes having a Vec<(key, value)> and using a filter with a single next() call is going to be faster than a HashMap up to some X number of entries. Is that what you mean here or is it something else? If that is it, what is that X number generally?

Thanks!

The point of returning an iterator instead of a collection is giving implementation freedom about how the data is stored. It wouldn't even have to be stored at all, and generated on the fly (like Python's range vs xrange).

For example an iterator over struct's properties could optimize down to the same code as accessing the property directly:

although in practice if you've used a trait over multiple types then some form of dyn would be involved, so the code wouldn't be as aggressively optimized, but still each type could have its own most efficient way of getting its properties, without needing to build a Vec or HashMap.

3 Likes

Very cool, makes more sense now. Thank you for the explanation and example code!