Traits for compile-time interface checks

I'm creating a library with multiple backends selected by feature flags (e.g. by OS: LinuxPlatform, MacOsPlatform, WindowsPlatform), where only one backend should be compiled depending on the enabled feature and available just as Platform.

I want to ensure that all backends expose the same interface by creating traits (e.g. PlatformOps, FileOps, DeviceOps, etc.) - in this case IDE will highlight all missing methods.
But this means users of my library always need to import these traits to access the methods.

What is the usual best practice in this situation?
As I can see many libraries don't seem to use traits for this, but in this case I need to compile with every feature flag to make sure it's correct.

Of course, I could temporarily change impl File to impl FileOps for File during develompent, but that feels wrong.

That is true for every scenario in which traits are used. I believe you'll find that a majority of Rust users are fine simply importing the necessary traits explicitly. Some crates—like rayon—that heavily rely on (multiple) traits for providing their functionality, do tend to provide a prelude module re-exporting said traits so that users can easily glob-import them all together.

If there is truly exactly one backend available in any particular case, then, whether or not you use traits in your implementation, exposing that trait to your users is complicating their lives for no good reason. You can create a stable API without even using any traits:

pub fn show_notification(message: String) -> Result {
   backend::show_notification(message)
}

#[cfg_attr(target_os = "macos", path = "backends/macos.rs")]
#[cfg_attr(target_os = "windows", path = "backends/windows.rs")]
mod backend;

In this design, because each public function is only written once, outside of the backend, the backend can't change its signature accidentally, and must provide the corresponding implementation function. (This is how std implements its platform-specific things.)

You can do the same thing with a trait too, if you want, where the public function calls the trait function.

That said, if there can be multiple backends available under some circumstances, it does make sense to make the trait public. I just want to make sure that you’re making things only as complex as they need to be. A trait with always exactly one implementation (that isn’t an extension trait) shouldn’t be public.

9 Likes

You could also consider using something like:

With, for instance, a pub(crate) trait.

That way:

  • you will be implementing against a given interface, which should result in good DX and quite readable code internally;
  • Thanks to the attribute, you'll automagically get the behavior of having used impl<...> YourType<...> {, that is the behavior of an inherent impl block (hence the name of the crate and attribute).
    • By having used a private trait, you don't unnecessarily "pollute"/clutter your public API, and are free to keep adding methods (with no default body) to the trait within SemVer-compatible releases;
    • Counterpoint: by exposing the trait, you could allow a downstream user to define functions able to work with your types in a platform-agnostic way. So there could be value in making the trait be pub, if you invest enough enfort into the documentation of this all.

My personal tangent here would be that I believe #[inherent] should be built-in functionality of the language, since certain impl blocks can be useful both to satisfy some API, and to imbue the type with inherent functionality (which has way better docs and UX than trait-provided methods).

  • For instance: some_bool.not() ought to also be inherent, and not require that the Not trait be in scope.
1 Like

Interesting. This is essentially the same thing as an implicit interface implementation in C# which is far more common than an explicit interface implementation.

I use a very dumb solution: I have 6 build machines running on different platforms and processors. Every build machine will use crates created on it as well with all specific in. It's probably not good for large organizations, but works for mine perfectly.

TIL; yeah, that is very good "prior art" / an illustrating example for the difference :ok_hand: