Rust CLI Command Registry: Generics vs Box<dyn Trait>
Use generics for a fixed command set and Box<dyn Trait> for runtime command registration. Compare object safety, binary size, and dispatch overhead before choosing.
17 Nov 2025, 14:13 UTC

The decision and the constraint
You are building a command-line tool where each subcommand implements the same trait. The registry must either stay generic over the command type or erase it with Box<dyn Command>. The useful takeaway: use generics when the command set is closed and performance-critical; use Box<dyn Command> when commands are loaded at runtime, stored together, or exposed through a public library boundary.
Constraints that narrow the choice
Box<dyn Trait> requires object safety: methods must not be generic, must not return Self by value, and must not use Self in unsized positions unless annotated with where Self: Sized. Adding that bound makes the trait compile with trait objects but hides the method from dyn Trait users.
Generics avoid object safety because the compiler monomorphizes each concrete type. That enables inlining, but each new command adds compile time and binary size.
Option comparison
| Aspect | Generics <T: Trait> | Box<dyn Trait> |
|---|---|---|
| Dispatch mechanism | Static dispatch via monomorphization | Dynamic dispatch through a vtable fat pointer |
| Optimization | Inlining and specialization possible | Indirect call; inlining generally blocked |
| Compile time | Grows with each concrete type instantiated | One code path per method; faster builds |
| Binary size | Can bloat with many instantiations | Smaller; shared code path |
| Heterogeneous collections | Not supported directly | Supported: Vec<Box<dyn Trait>> |
| Object safety | Not required | Required for the trait |
| ABI stability | Not stable across crate boundaries without recompilation | Stable sized pointer and vtable layout |
Trade-offs in practice
Static dispatch suits a fixed set of commands where the compiler can inline run and optimize argument handling. The cost appears when you add many commands: each instantiation duplicates code.
Dynamic dispatch suits plugins, user-contributed commands, or a registry that chooses commands by name at runtime. You can store Vec<Box<dyn Command>> and dispatch by string. The cost is an indirect call per command invocation, which is usually negligible for CLI entry points but worth checking in hot loops.
Concrete implementation
Object-safe command trait:
trait Command {
fn name(&self) -> &str;
fn run(&self, args: &[String]) -> Result<(), String>;
}
struct Greet;
impl Command for Greet {
fn name(&self) -> &str { "greet" }
fn run(&self, args: &[String]) -> Result<(), String> {
Ok(println!("hello {}", args.join(" ")))
}
}
struct Version;
impl Command for Version {
fn name(&self) -> &str { "version" }
fn run(&self, _args: &[String]) -> Result<(), String> {
Ok(println!("1.0.0"))
}
}
Generic runner for a known command:
fn run_command<C: Command>(cmd: &C, args: &[String]) -> Result<(), String> {
cmd.run(args)
}
Dynamic registry for heterogeneous commands:
struct Registry {
commands: Vec<Box<dyn Command>>,
}
impl Registry {
fn add(&mut self, cmd: Box<dyn Command>) {
self.commands.push(cmd);
}
fn dispatch(&self, name: &str, args: &[String]) -> Result<(), String> {
self.commands
.iter()
.find(|c| c.name() == name)
.ok_or_else(|| format!("unknown command: {}", name))?
.run(args)
}
}
Hybrid boundary: keep the executor generic internally, but accept boxed commands at the public registry.
pub struct Executor<C: Command> {
cmd: C,
}
impl<C: Command> Executor<C> {
pub fn new(cmd: C) -> Self { Self { cmd } }
pub fn run(&self, args: &[String]) -> Result<(), String> {
self.cmd.run(args)
}
}
pub trait CommandFactory {
fn create(&self) -> Box<dyn Command>;
}
Validation steps
- Add benchmarks for
run_commandandRegistry::dispatchin the project root and runcargo bench. Required permission: read/write to thetarget/directory. Risk: CLI benchmarks include argument parsing overhead; isolate the dispatch call. - Build release binaries for both variants and compare size with
cargo build --releasefrom the project root, thensize target/release/your_binorcargo bloat. Expect the generic variant to grow per command type. - Inspect assembly with
cargo asm --release --bin your_bin run_command(requires thecargo-asmtool) or view the same function in Compiler Explorer. Confirm inlining in the generic path versus a vtable indirect call fordyn Command. - Test object safety by adding a generic method such as
fn boxed_clone<T>(&self) -> TtoCommandand attemptingVec<Box<dyn Command>>; the compiler will reject it.
Limitations
dyn Trait cannot be used with generic methods, Self returns, or unbounded associated types. Generics cannot express runtime command selection by name. The hybrid pattern adds wrapper types and an extra API layer.
A practical way to check the result is to build both variants in release mode, compare dispatch assembly and final binary size, then keep dyn Command at the registry boundary if it meets latency and compile-time budgets.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.