Pool of dependencies impl a trait

I am writing a library that is meant to be portable between different platforms. To separate the business logic from the actual platform specific code I am thinking about creating a trait that defines which operations the platform should support (ie. thread creation, mutex operations, etc.) and then let the user of my library foo decide which platform crate to use as dependency (ex. foo-posix, foo-win32, etc). Each platform crate should implement the trait.

One important thing, I'm specifically not using the heap as in the future I may be targetting heap-less OSs

Questions:

  • Is this schema possible? Does Rust allow to create a crate without a specific dependency underneath? Is it better to use features to enable/disable certain crates and then the user would use only one featured dependency?
  • How would you access the operations in the platform crate?
  • Creating an object of the platform crate and then calling the trait methods on it, or
  • Defining a common interface between all the platform crates and then from the user of those saying
    use foo-posix as platform; so that you can call the items directly

Thanks!

As one point of reference, Rust's standard library takes the use foo_posix as platform approach, but instead of the end user doing the use foo_posix as platform, the standard library does it in the (private) std::sys module.

This approach is really nice because things Just Work from an end user's perspective. You can also copy std's approach of having platform-specific files like sys/posix.rs and sys/win32.rs with a sys/mod.rs that uses conditional compilation to pull in the right implementation.

The main problem is that it's a closed solution. Kinda like when choosing between enums and traits, if you want to add support for a different platform, you'll need to make a PR that introduces a new sys/*.rs file. This approach isn't particularly extensible.

The conditional compilation also gets a bit tricky when the condition is something not tied to your target triple (e.g. OS or architecture) because then we start venturing into the realm of mutually exclusive cargo features, which have usability issues.

Yes, it's definitely possible!

What you do is define traits in the top-level crate and have all user code depend on that, then the final application will pull in the appropriate platform crate and pass that dependency to the code that needs it.

The embedded ecosystem has this exact problem, so you might want to read the embedded_hal crate's top-level docs to find out how it works.

(note: I'm interpreting "heap-less OS" as a no_std platform, because it's perfectly fine to pull in the alloc crate and get heap-allocated types without needing an OS)

If you want to run on no_std platforms in the future or switch to something that provides its own solution for threading and mutexes (e.g. an RTOS), you'll want to go with some form of dependency injection mechanism. That way users can control how their implementation of the Platform trait is initialised.

The only problem is your trait will get really complex with lots of associated types and maybe a GAT or two (now stabilised on nightly!).

trait Platform: Send + Sync {
    type Mutex<T>: Mutex<Value = T>;
    type JoinHandle<Ret>: JoinHandle<Output = Ret>;

    fn spawn<F, Ret>(&self, f: F) -> Self::JoinHandle<Ret>
    where
        F: FnOnce() -> Ret + Send + 'static,
        Ret: Send + 'static;

    fn new_mutex<T>(&self, value: T) -> Self::Mutex<T>;
}

trait JoinHandle {
    type Output;
    fn join(self) -> Result<Self::Output, Box<dyn Any + Send + 'static>>;
}

trait Mutex {
    type Value;
    type Guard<'a>: DerefMut<Target = Self::Value> + 'a
    where
        Self: 'a;

    fn lock(&self) -> Self::Guard<'_>;
}

(playground)

The only thing in that snippet which requires a heap allocation is the Box<dyn Any> returned from JoinHandle::join(). That was mainly to match std, but you could always implement things so joining on a thread that panicked translates to an abort or automatically continues unwinding the stack. By using associated types, we can pass things around by-value without needing heap allocations or trait objects.

2 Likes

Whoa that's a lot of information, thanks a lot, I'll go through it thoroughly