Traits and Trait objects are one of the best parts of Rust, but generally have not had a need to reach for a
Box<dyn FooTrait>
in a long time, beyond boxing std::error::Error
. I normally reach for generics with impl FooTrait
and you can get a lot done with generics (static dispatch).Here's a terrible contrived example that I came up with, and yes, it is horrible! I promise though, it gets much better towards the end. Do note there will be some warnings as I haven't fully cleaned everything up for RA and Clippy offences - let's go!
As someone mentioned on Discord,
Any
is a poor man's enum. This code is a great idea of trying to solve something, and writing bad code without really arriving at a simple solution.
Refer to the bad example here, although it will not run due to a dependency on
downcast-rs
. You can also find all code examples in this Gist. So what are we trying to accomplish here? Well, it's to demonstrate that we can downcast from an Any
to its concrete type. Whether it is a good idea though, is the real issue at hand, and it is not.Firstly, we are playing with dynamic dispatch in the returned type
fn log_enchanted<'a, T: Any + Debug>(value: &T, l: &'a mut impl Recordable) -> &'a dyn Recordable
and secondly, we don't really need to take this approach at all.Keen eyed readers will notice we are making a redundant return, which can be avoided, but we also demonstrate that we can do this if we wanted to.
However, we can avoid the dynamic dispatch and just use plain old generics
fn log_enchanted<'a, T: Any + Debug, U: Recordable>(value: &T, l: &'a mut U) -> &'a U
noting how we are essentially doing where U: Recordable
(in this case using the Trait bound syntax instead of a where clause). FYI, I prefer to use a where clause as I personally find it much more readable, but since we only have two generics here, I'll allow it.fn log_enchanted<'a, T: Any + Debug, U: Recordable>(value: &T, l: &'a mut U) -> &'a U {
let value_any = value as &dyn Any;
// Try to convert our value its concrete type
match value_any.downcast_ref::<Dwarf>() {
Some(_) => {
l.set_text("Default text within the recorder".to_string());
l
}
_ => l,
}
}
Swappable backend, a real-world example
Let's use this knowledge and build a swappable backend for storage purposes. All we need are generics; enums are not needed either! You can run this example and see it in action.
use anyhow::{Context, Result};
use std::fmt::Debug;
use std::fmt::Display;
use std::fs::File;
use std::future::Future;
use std::path::PathBuf;
use tokio::io::{AsyncRead, AsyncWrite};
#[derive(Debug)]
struct S3Backend {}
#[derive(Debug)]
pub struct FileBackend {
path: PathBuf,
}
#[derive(Debug)]
pub struct FileBackendBuilder {
inner: FileBackend,
}
impl FileBackendBuilder {
pub fn new(path: &str) -> Self {
Self {
inner: FileBackend { path: path.into() },
}
}
pub fn build(self) -> FileBackend {
self.inner
}
}
pub trait Backend: Debug {
fn persist(&mut self, data: &[u8]) -> impl Future<Output = Result<()>> + Send;
}
impl Backend for FileBackend {
async fn persist(&mut self, data: &[u8]) -> Result<()> {
if let Err(err) = tokio::fs::write(&self.path, data).await {
return Err(err).with_context(|| format!("Failed to persist to {:?}", self.path));
}
Ok(())
}
}
#[tokio::main]
async fn main() -> Result<()> {
// This will be replaced with a source of data using Serde
let data = String::default();
let backend_builder = FileBackendBuilder::new("./output/output.json");
let backend = &mut backend_builder.build();
backend.persist(data.as_bytes()).await?;
Ok(())
}
The key takeaway here is that we specify our backend types as structs and just use a singular
Backend
Trait. Each backend type provides is own implementation of persist()
and this is required on all types that implement the Backend
Trait.I've included Tokio as an async runtime as I will be putting this swappable backend into use in an Axum based service.
Acknowledgements & Thanks
- Helix (noop_noob) (aka. helix65535) for your continued help and guidance.
- Many others in the Rust Discord channel.