Strategy at compile time and at run time

A strategy is the part of an algorithm that a caller chooses: the comparison of a sort, the formatting of a report, the retry policy of a request. C++ shows it as a base class with virtual methods or as a template parameter. Rust has three answers with different costs, and the interesting part of this lesson is which one to pick.

This lesson writes the same report with a boxed trait object, with a type parameter, with a function pointer and with an enum, prints the four results, and ends with the missing bound that stops the generic version from calling a method.

A strategy at run time

The run-time version stores the strategy behind Box<dyn Formatter>, so the type of the report does not mention the formatter at all. A program can read a configuration string and build the box that matches it, which is the case the other three versions cannot serve as directly.

The report has a title and a formatter, and render hands the title to the strategy. The two instances print the same title in two shapes.

trait Formatter { fn format(&self, text: &str) -> String; } struct Plain; struct Shout; impl Formatter for Plain { fn format(&self, text: &str) -> String { text.to_string() } } impl Formatter for Shout { fn format(&self, text: &str) -> String { format!("{}!", text.to_uppercase()) } } struct Report<'a> { title: &'a str, formatter: Box<dyn Formatter>, } impl<'a> Report<'a> { fn new(title: &'a str, formatter: Box<dyn Formatter>) -> Report<'a> { Report { title, formatter } } fn render(&self) -> String { self.formatter.format(self.title) } } fn main() { let plain = Report::new("quarterly numbers", Box::new(Plain)); let shout = Report::new("quarterly numbers", Box::new(Shout)); println!("{}", plain.render()); println!("{}", shout.render()); }

rustc --edition 2024 runtime.rs
./runtime

quarterly numbers
QUARTERLY NUMBERS!

The choice can come from a command-line flag or a configuration file, and the function that builds the box is the only place that knows which name maps to which formatter. A new formatter is a new struct and one line in that function, and the report keeps its type, which is the property that makes this version easy to put behind a plugin boundary. The price is paid per report rather than per call, because the box and its allocation are built once.

One type, one allocation per report and one virtual call per render. The report could hold a Vec<Box<dyn Formatter>> and chain the strategies, which is how a pipeline of small transformations is written. The previous lesson covers the vtable behind that call.

A policy at compile time

The same choice can be a type parameter. The compiler writes a version of the report per formatter, inlines the call and stores the formatter inside the struct with no pointer at all. That is the C++ policy of a template argument, and in Rust the bound says which methods the parameter must have.

Both reports print in one line here, so the output shows the two versions and their two shapes.

trait Formatter { fn format(&self, text: &str) -> String; } struct Plain; struct Shout; impl Formatter for Plain { fn format(&self, text: &str) -> String { text.to_string() } } impl Formatter for Shout { fn format(&self, text: &str) -> String { format!("{}!", text.to_uppercase()) } } struct Report<F: Formatter> { title: String, formatter: F, } impl<F: Formatter> Report<F> { fn new(title: &str, formatter: F) -> Report<F> { Report { title: title.to_string(), formatter } } fn render(&self) -> String { self.formatter.format(&self.title) } } fn main() { let plain = Report::new("total", Plain); let shout = Report::new("total", Shout); println!("{} {}", plain.render(), shout.render()); }

rustc --edition 2024 compile.rs
./compile

total TOTAL!

The two reports have two different types, Report<Plain> and Report<Shout>, so they cannot share a vector unless one of them is boxed. That restriction is the price of the inlining: the compiler must know the strategy at each call site. The lesson on bounds covers the where clause form of the same declaration.

A function pointer is enough

A strategy that is one function with no state does not need a trait at all. A field of type fn(&str) -> String holds any function with that signature, and the struct stays one type, so a vector of reports works again.

struct Report<'a> { title: &'a str, apply: fn(&str) -> String, } fn plain(text: &str) -> String { text.to_string() } fn shout(text: &str) -> String { format!("{}!", text.to_uppercase()) } fn main() { let plain_report = Report { title: "sum", apply: plain }; let shout_report = Report { title: "sum", apply: shout }; println!("{}", (plain_report.apply)(plain_report.title)); println!("{}", (shout_report.apply)(shout_report.title)); }

rustc --edition 2024 pointer.rs
./pointer

sum
SUM!

A closure that captures a prefix converts to the same pointer when it captures nothing, and stops converting when it does, because a function pointer cannot carry the captured value. At that point the field becomes Box<dyn Fn(&str) -> String>, which is the run-time version with a smaller trait. The field is one word, the call is one indirect jump and every report has the same type. A function pointer cannot capture a value, so a formatter that needs a width or a prefix moves to a closure behind Box<dyn Fn> or to a struct that implements the trait. For a pure function the pointer is the smallest of the four shapes.

The strategy as an enum

When the set of strategies is small and known in advance, an enum is the cheapest answer of all. The value lives in the struct, no allocation happens, and the compiler checks that every case is handled in the match.

The program applies two cases to the same word. The enum is a few bytes and the array of cases needs no box.

enum Case { Upper, Lower, } impl Case { fn apply(&self, text: &str) -> String { match self { Case::Upper => text.to_uppercase(), Case::Lower => text.to_lowercase(), } } } fn main() { let cases = [Case::Upper, Case::Lower]; for case in &cases { println!("{}", case.apply("Rust")); } }

rustc --edition 2024 enum_strategy.rs
./enum_strategy

RUST
rust

A test of the enum covers every case in one file, which is a real advantage over a trait that downstream crates implement: the library cannot test code it has never seen. The trade is the same one every closed set makes, and the compiler enforces it in both directions, so a new case is a build error in the library rather than a surprise at run time.

The cost is a branch per call and a closed set: a user of the library cannot add a third case, which the trait version allows and the enum forbids. That is often the right restriction, because every case is in one file that the library tests. The previous lesson made the same trade for the operations of a data type.

The bound that is missing

The generic version works because of the bound on the impl. Remove it and the struct still compiles, because a struct may hold any type, but the method that calls format has nothing that promises the parameter has it. E0599 names the type parameter, and the help line adds the bound.

trait Formatter { fn format(&self, text: &str) -> String; } struct Report<F> { title: String, formatter: F, } impl<F> Report<F> { fn render(&self) -> String { self.formatter.format(&self.title) } } fn main() { let report = Report { title: "x".to_string(), formatter: 1 }; println!("{}", report.render()); }

rustc --edition 2024 bound_err.rs

error[E0599]: no method named `format` found for type parameter `F` in the current scope
--> bound_err.rs:12:24
|
10 | impl<F> Report<F> {
| - method `format` not found for this type parameter
11 | fn render(&self) -> String {
12 | self.formatter.format(&self.title)
| ^^^^^^ method not found in `F`
|
= help: items from traits can only be used if the type parameter is bounded by the trait
help: the following trait defines an item `format`, perhaps you need to restrict type parameter `F` with it:
|
10 | impl<F: Formatter> Report<F> {
| +++++++++++

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0599`.

The bound belongs on the impl and, for a struct that stores the value, on the struct as well. A bound on the struct is a promise about every value of that type, and it makes the error shorter the next time. The next lesson keeps a list of strategies and lets a caller add and remove them while the program runs.

Exercises

  1. Add a third formatter that wraps the text in quotes and run all four shapes with it.
  2. Write a function that takes impl Fn(&str) -> String and build a report from a closure that captures a prefix.
  3. Measure the four versions with the harness of the performance lesson and explain the order.

Questions

A new set of questions in a new order every time. Choose one answer for each, then check.

  1. What decides the strategy in the run-time version?

    Answer

    A value of type Box&lt;dyn Formatter&gt; chosen while the program runs

    The box can hold any formatter, so the choice happens when the box is built.

  2. What does the type parameter version avoid?

    Answer

    The allocation and the virtual call

    The compiler writes one version per formatter and inlines the call.

  3. When is a function pointer the right shape for a strategy?

    Answer

    When the strategy is one function with no state and a fixed signature

    A plain function has no captures, so one word holds it.

  4. What does an enum strategy give up compared with a trait?

    Answer

    The ability to add a case outside the module that defines the enum

    The enum is closed, while anyone may implement the trait in their own crate.

  5. Why do two reports with different formatters have different types in the generic version?

    Answer

    The formatter is part of the type, and the compiler writes a version per type

    Report&lt;Plain&gt; and Report&lt;Shout&gt; are two distinct types, so they cannot share a vector.

  6. What does this program print? trait Formatter { fn format(&self, text: &str) -> String; } struct Upper; struct Plain; impl Formatter for Upper { fn format(&self, text: &str) -> String { text.to_uppercase() } } impl Formatter for Plain { fn format(&self, text: &str) -> String { text.to_string() } } fn main() { let formats: Vec<Box<dyn Formatter>> = vec![Box::new(Upper), Box::new(Plain)]; for format in &formats { print!("{} ", format.format("ok")); } println!(); }

    Answer

    OK ok

    The upper formatter runs first and the plain one second, in the order of the vector.

  7. What does this program print? trait Formatter { fn format(&self, text: &str) -> String; } struct Plain; impl Formatter for Plain { fn format(&self, text: &str) -> String { text.to_string() } } struct Report<F: Formatter> { title: String, formatter: F, } impl<F: Formatter> Report<F> { fn render(&self) -> String { self.formatter.format(&self.title) } } fn main() { let report = Report { title: "total".to_string(), formatter: Plain }; println!("{}", report.render()); }

    Answer

    total

    The plain formatter returns the text unchanged.

  8. What does this program print? fn shout(text: &str) -> String { format!("{}!", text.to_uppercase()) } struct Report<'a> { title: &'a str, apply: fn(&str) -> String, } fn main() { let report = Report { title: "hi", apply: shout }; println!("{}", (report.apply)(report.title)); }

    Answer

    HI!

    The function pointer is called with the title and uppercases it.

  9. What does this program print? enum Case { Upper, Lower, } impl Case { fn apply(&self, text: &str) -> String { match self { Case::Upper => text.to_uppercase(), Case::Lower => text.to_lowercase(), } } } fn main() { let cases = [Case::Lower, Case::Upper]; for case in &cases { print!("{} ", case.apply("Rust")); } println!(); }

    Answer

    rust RUST

    The array starts with the lower case, so the outputs follow its order.

  10. What is the first line of the compiler output for this program? trait Formatter { fn format(&self, text: &str) -> String; } struct Report<F> { title: String, formatter: F, } impl<F> Report<F> { fn render(&self) -> String { self.formatter.format(&self.title) } } fn main() { let report = Report { title: "x".to_string(), formatter: 1 }; println!("{}", report.render()); }

    Answer

    error[E0599]: no method named format found for type parameter F in the current scope

    Without a bound the parameter has no methods, and the help line adds the missing bound.

The code on this page was built with rustc 1.96.1 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container.

Article author: Arthur Isaev

Banner image