Code architecture for struct of structs: enum vs generic traits vs

Hello,

I'm a newcomer to Rust after working quite a bit with dynamic languages such as Matlab, Python and Julia. I am currently in the process of translating a pet project of mine from Julia to Rust and would like to hear your opinions regarding the code structure.

The aforementioned pet project is a library for simulating electrical motors. The active part of an electrical motor consists of a rotor and a stator lamination. The stator has a winding, of which there are multiple types. The rotor may have magnets or a winding itself. There are also multiple types of laminations (e.g. slotted and non-slotted).

In the previous iteration of the library, I used the following architecture to represent a motor:

struct Motor{
    Rotor: ActivePart,
    Stator: ActivePart,
}

struct ActivePart{
    Lamination: Lamination,
    Winding: SomeWindingType,
}

struct WindingType1{
    ...
}

struct WindingType2{
    ...
}

The real code has of course quite a few more variants, but I hope I get the point across. Basically, a motor is a struct of structs of structs ... . In the Julia implementation, I used generics for SomeWindingType to avoid loss of performance, which can be translated easily via generic traits to Rust. However, Rust also offers me the possibility to use an enum instead. And if I wouldn't mind the performance loss, a dynamic trait would also be an option, however I actually do mind, so this is out of the question :wink:

The problem with the generic approach from my perspective is: For structs high up in the hierarchy (e.g. ActivePart), I would get quite a few <T,Q,R,S,...> in the struct definition, due to the high variance of substructs. This leads to a LOT of specialized functions, which from my understanding would result in bloated binaries and high compilation times (which is actually a problem I encounter in Julia a lot due to my approach).

Using enums would solve this problem nicely. Also, the different variants can be expected to have roughly the same memory footprint, which also points me towards using enums. However, if I want to publish the library as a crate, other users wouldn't be able to define e.g. their own winding type (which is where traits would come in handily).

So I am very interested in your opinion. How would you structure this code? If you have a third option I didn't list, I am eager to hear it as well!

Just to make sure we're all using the same terminology,

  • when you say "generics" in the context of Julia, are these statically-defined generics that get monomorphised by the compiler? (i.e. fn foo<T>(value: T) in Rust)
  • when you say "dynamic trait", are you referring to trait objects? (i.e. Box<dyn Foo>)

The general strategy I use when picking between enums, generics, and trait objects is

  • Are there a fixed set of things that make sense here? If yes, use an enum
  • Do I need to switch between implementations at runtime? If yes, use trait objects
  • Will using generics mean code higher up the stack turns into generic soup? (e.g. struct Foo<'a, A, B, C> where A: AsRef<str>, <A as Bar>::Associated : Into<B> + Send, C: for<'b> Fn(&'b str) + 'a) If yes, erase some of the type parameters using trait objects
  • otherwise, use generics

For something like this, I would probably use a mix of trait objects and enums, with trait objects at the high level and enums used as part of the trait implementation.

Your high-level objects tend to encode general "strategies", while the low-level objects tend to contain details and specific algorithms. By leaving the high-level things open for extension, people can come along and write their own implementations, while reusing the concrete low-level objects.

Considering you are coming from more dynamic languages like Matlab and Python, you probably don't need to worry about the "performance cost" of using enums/trait objects instead of statically-defined generics. CPUs are fast, and calling a trait object method only takes a couple nanoseconds.

You often won't notice/care about this cost unless you are processing loads and loads of identical motors in a hot loop, in which case something with this level of flexibility will always be unfriendly to the branch predictor/cache. Your computer really prefers bulk operations with minimal branching.

1 Like

Yes, both of your assumptions are correct. Julia is JIT-compiled, but specializes the same way Rust does for generics. However, the JIT compiler abstracts most of the "generic soup" away (i.e. you only see it during the upper struct definition, not for each and every struct method). This is because Julia is essentially duck-typed and the concrete function implementation is created the first time a function is called for given specific arguments.

It is true that I wouldn't really care about performance coming from Matlab / Python, but from Julia it is another story (Julia is not too shabby due to the function specialization by the JIT compiler). I must admit that I fear the overhead of trait objects (or "dynamic traits", as I called them above) because Julia heavily encourages generics (due to the reasons described above).

I really like the strategy you described, so thank you very much! It shows me that I really need to think hard about each component (in Julia, I simply used generics for everything, but this resulted in long compilation times, which in hindsight shows the flaws of this strategy).

One piece I'll add here is that it's very common to have one layer of dyn Trait dynamic dispatch at the outside (to hide the unimportant details, allow storing them heterogeneously, and enable easy separate compilation) but then have the inside be fully static dispatch and generics.

Because one dynamic call for a meaningful chunk of work is completely fine. Web servers are an easy example, here -- you can have all the individual handlers be fully static dispatch, but the routing layer would figure out the right thing to call and then do the single dynamic call to that handler.

So maybe try to find a place where it would make sense to put that difference. Like maybe you could have a Vec<Box<dyn MotorTrait>>, but the motors you put in that list would each use generics internally? (I have no idea whether that's at all relevant to what it actually does, but maybe it gives an idea.)

Also, one way to avoid making a decision to impl<T: ?Sized + YourTrait> YourTrait for &mut T, like iterators do. (And maybe for Boxs of the trait and such too.) That way the user of the library can decide for themselves which to use.

1 Like

Could you explain to me a little bit more what this code does (as I am still very much in the learning phase)? Is this a combination of trait objects and generics, i.e. the user of the library can chose whether he wants to call a library function as monomorphized version or with dynamic dispatch? This would be a great feature :slight_smile:

So suppose I have the following function in my library:

pub fn print_the_numbers<I: Iterator<Item = u32>>(it: I) {
    for x in it {
        println!("{}", x);
    }
}

That's generic, as you can see.

So that means that if you decide to call it like

print_the_numbers(0..10);
print_the_numbers((10..20).map(|x| x * x));

Then each of the calls will monomorphize a different copy.

However, if you call them like this

print_the_numbers(&mut (0..10) as &mut dyn Iterator<Item = _>);
print_the_numbers(&mut (10..20).map(|x| x * x) as &mut dyn Iterator<Item = _>);

Then they're both using print_the_numbers::<&mut dyn Iterator<Item = i32>> and thus there will only be the one copy of the function generated.

Playground link of me checking I typed everything right

1 Like

This is very useful, I didn't hear about the as syntax until now. Thank you very much for the code example, this explains it nicely :slight_smile:

Note that that's mostly for explicitness in the example. If I was going to do it "for real", I'd probably do it something more like this:

// Non-generic function, so this one is only compiled once,
// making it more obvious that there's no extra monomorphizing
// going on when you actually call it.
fn print_the_numbers_dyn(it: &mut dyn Iterator<Item = i32>) {
    print_the_numbers(it);
}

print_the_numbers_dyn(&mut (0..10));
print_the_numbers_dyn(&mut (10..20).map(|x| x * x));

Because typing out all the as &mut dyn every time would be horrible.

https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=d3e8c2f69afe06d7503946158eae64af

2 Likes

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.