Skip to Content
Course content

194: Trait Objects and Dynamic Dispatch Revisited

Click on the "Edit" button in the top corner of the screen to edit your slide content.

We've talked about traits before, and we've touched on dyn. But if you're like me, the first time you tried to build a real-world system using trait objects, you probably hit a wall regarding "object safety." It's one of those Rust concepts that feels like a riddle until you actually break your code a few times.

To really get a handle on dynamic dispatch revisited, let's build a pluggable text processing pipeline. Imagine we're building a tool where a user can chain together different filters—like converting text to uppercase, trimming whitespace, or masking sensitive words—without the main program knowing exactly which filters are being used at compile time.

Sketching the Filter Interface

First, we need a trait that defines what a "filter" is. Since we want to store these filters in a collection and run them sequentially, we can't use generics for the collection itself (unless we only ever had one type of filter). We need dyn.

trait TextFilter {
    fn apply(&self, input: &str) -> String;
}

Simple enough. Now let's create a couple of concrete implementations. I'll start with a basic uppercase filter and a trimming filter.

struct UppercaseFilter;
impl TextFilter for UppercaseFilter {
    fn apply(&self, input: &str) -> String {
        input.to_uppercase()
    }
}

struct TrimFilter;
impl TextFilter for TrimFilter {
    fn apply(&self, input: &str) -> String {
        input.trim().to_string()
    }
}

Hitting the Wall with Object Safety

Here is where I usually make a mistake when I'm moving too fast. I often think, "I might want to clone these filters later," so I'll try to add a clone method to the trait. Let's see what happens when I try to do this the "obvious" way:

trait TextFilter {
    fn apply(&self, input: &str) -> String;
    fn clone_filter(&self) -> Self; // I'm trying to return the concrete type here
}

The moment I try to put these into a Vec<Box<dyn TextFilter>>, the compiler will scream at me. It'll tell me that TextFilter cannot be made into an object because it has a method that returns Self.

Why? Because dyn TextFilter is a trait object. At runtime, Rust doesn't know if the concrete type is an UppercaseFilter (which might be 0 bytes) or some massive struct with a huge buffer. If a method returns Self, Rust has no idea how much space to allocate on the stack for that return value. This is the core of "object safety."

Making the Trait Object-Safe

To fix this, I have to stop returning Self. If I really need a way to duplicate the filter, I have to return a Box<dyn TextFilter> instead. This moves the return value to the heap, where the size is just a pointer, regardless of the underlying concrete type.

trait TextFilter {
    fn apply(&self, input: &str) -> String;
    fn clone_box(&self) -> Box<dyn TextFilter>;
}

impl TextFilter for UppercaseFilter {
    fn apply(&self, input: &str) -> String {
        input.to_uppercase()
    }
    fn clone_box(&self) -> Box<dyn TextFilter> {
        Box::new(UppercaseFilter)
    }
}

impl TextFilter for TrimFilter {
    fn apply(&self, input: &str) -> String {
        input.trim().to_string()
    }
    fn clone_box(&self) -> Box<dyn TextFilter> {
        Box::new(TrimFilter)
    }
}

Wiring the Pipeline Together

Now that our trait is object-safe, we can actually build our pipeline. We'll use a Vec of Box<dyn TextFilter>. This is the essence of dynamic dispatch: the Vec doesn't care about the concrete types; it just knows that whatever is in the box implements TextFilter.

struct Pipeline {
    filters: Vec<Box<dyn TextFilter>>,
}

impl Pipeline {
    fn new() -> Self {
        Self { filters: Vec::new() }
    }

    fn add_filter(&mut self, filter: Box<dyn TextFilter>) {
        self.filters.push(filter);
    }

    fn process(&self, text: &str) -> String {
        let mut result = text.to_string();
        for filter in &self.filters {
            result = filter.apply(&result);
        }
        result
    }
}

fn main() {
    let mut my_pipeline = Pipeline::new();
    my_pipeline.add_filter(Box::new(TrimFilter));
    my_pipeline.add_filter(Box::new(UppercaseFilter));

    let input = "  hello rustaceans!  ";
    let output = my_pipeline.process(input);
    println!("Result: '{}'", output); // "HELLO RUSTACEANS!"
}

Notice how process just iterates through the Vec. Every time it calls filter.apply(), Rust performs a vtable lookup to find the actual implementation for that specific object. It's a tiny bit slower than a static dispatch (generics), but it gives us the flexibility to add new filters at runtime without recompiling the Pipeline struct.




📋 Practical Task

Implementing a Redaction Filter for the Text Pipeline

Using the Pipeline and TextFilter architecture from the lesson, create a new concrete filter called RedactionFilter.

  • The RedactionFilter should be initialized with a String containing a word to be censored (e.g., "secret").
  • The apply method should replace all occurrences of that word in the input text with "[REDACTED]".
  • Ensure you implement clone_box correctly so that the RedactionFilter remains object-safe.
  • In your main function, add the RedactionFilter to a pipeline alongside the UppercaseFilter and verify that it correctly masks the target word before or after the casing change.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.