Rust App Structure Opinions Needed

I'm after some Rust design opinions.

I'm working on an application that will eventually be compiled for both Mac and Windows with platform specific parts implemented separately. I'm obviously trying to keep as much of the code as possible, platform agnostic.

I have a struct hierarchy that looks something like the following (names have been changed to protect the innocent :slight_smile:)

struct I {
    g: G,
    c: C

    ... other stuff
}

struct C {
    ... stuff
}

struct G {
    v: V

    ... other stuff
}

struct V {
    ... platform specific stuff
}

The struct V's implementation is platform specific and needs to access data that is in both G & C. I'm trying to figure out the best way to do this.
In other languages I would use some form of DI with interfaces to allow V to query for what it needs. I've discovered that this isn't really a Rust way of doing things and is actually fairly hard to get going.

My other thoughts are actually to restructure this to be less object-orientated and bring things together in as maintainable a fashion as possible.

I would change G and C to be impl's for I in separate files.

V would also be the same but conditionally compiled to pick up the platform specific implementation.

This sounds messy to me but I think it would solve the issue and, in my experiments, seems to work well.

Is this reasonable? Thoughts? Suggestions?

Can't you put your instance of V into I then? I'd just clone the data from C and G that is needed by V when constructing it. Or share it with interior mutability if the data must be updated during the lifecycle of I.

I don't understand what you mean by that.

This sounds reasonable and idiomatic. I don't see why your approach looks so messy to you so maybe your real-world problem has some caveat that you forget to share with us?

What I mean by "I would change G and C to be impl's for I in separate files." is that I would have something like the following:

impl I {
...
}

another file

impl I {
... G Specific fn's
}

impl I {
... C specific fn's
}

more of an organization thing that lets everything talk to each other without having a massive single .rs file. Does that make sense?

I think the reason it looks messy to me is that I still have my head in the world of other languages :slight_smile: Its just taking me some time to think like a Rust developer.

I didn't know that was possible but it turns out it is :sweat_smile:. I've never seen multiple impl blocks for one struct spread across modules tbh. Looks strange to me but maybe in bigger projects this is something people do, I don't know.

:slight_smile:

C# has something along the same lines with partial classes.

They are useful from an organization point of view. Kinda building complete objects by composing...

None of these structs are useable on their own anyway so I was thinking bringing them together like this isn't so bad and still keeps a level of organization without a massive .rs file.

Like I said, I would prefer a more DI solution but that doesn't seem to be so easy once move away from a very simple case.

One strategy I've used when the OS-specific implementations are completely different is to extract some sort of trait then put separate implementations in their own module that gets conditionally compiled in. To make instantiating things easier for other parts of your code, you can provide some sort of factory function or a type alias to use the correct implementation.

There are many ways to do this, but here is one example:

cfg_if::cfg_if! {
  if #[cfg(windows)] {
    mod windows;
    use windows::Client;
  } else if #[cfg(unix)] {
    mod unix;
    use Unix::Client;
  } else {
    mod unsupported;
    use unsupported::Client;
  }
}

pub fn client() -> impl MyClientTrait {
  Client::default()
}

Then your other types can accept some C: MyClientTrait when they are created.

Here is an example from some real-world code I wrote where we try to instantiate the right HTTP client based on the feature flags you used, or return None if no HTTP client is available.

Alternatively, if things are mostly similar between operating systems, but a couple small parts are different, you can pull just the OS-specific logic into its own struct and litter the methods internally with #[cfg] attributes to conditionally do one thing or the other. The idea being that you provide an OS-agnostic API that the rest of your codebase can rely on.

This is the approach I've taken when the scope for platform-specific code is quite small, although you need to keep an eye on things to make sure the conditional compilation doesn't grow and become unmaintainable (threading #[cfg] throughout your code really trashes the readability and makes it really easy to introduce bugs).

I'm curious where you heard this from.

Dependency injection - the practice of passing in something you need as a parameter rather than newing an instance up inside your code - is very much an idiomatic thing in Rust. You can see this all through the standard library and the ecosystem in terms of functions or structs that use generics to accept a dependency implementing a particular trait, rather than hard-coding a particular implementation. For example, hyper, the crate most frameworks and http libraries build on, accepts a generic Connector when you create a hyper::Client so you can provide your own way of creating connections.

It's just that we prefer to explicitly set the dependencies up rather than using an automagical system like C#'s Dependency Injection framework.

3 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.