Rust has two major mechanisms for delegating logic: enums and trait objects, and it may be unclear when to use one or the other. In this post, I will walk through how each works, what the tradeoffs are, and how to choose the right option for your code.
Enums: Closed Set of Types
An enum, or enumeration is a type which can be one of several distinct variants, each possibility containing some addition data. Enums are one of Rust’s core data types, can look like any of the following, as examples:
Note that enums are, by their nature, a closed set of types. You define a finite list of variants, and those are the only ones which can exist. In the future, you can add variants yourself by modifying the definition of the enum type (and possibly changing call sites / users of the enum to handle the additional variant), but users of your enum type can’t add additional variants themselves.
Info 1 Non-exhaustive Enums
Skip this content.Enums may be marked “non-exhaustive” to force users of the crate in which the type is defined to include a catch-all match pattern, enabling the addition of future variants to the enum without breaking compatibility for users.
Note that users of the enum within the current crate are not affected by the “non-exhaustive” annotation.
The advantage of enums is that they are fast. Because the total list of variants is known at compile-time, selection of what code to execute based on the variant at hand is only a branch instruction.
Trait Objects: Open Set of Types
Trait Objects allow for the uniform treatment of different concrete types which all implement the same trait, like so:
By contrast, a trait object is an open set of types. Trait objects are Rust’s key mechanism for dynamic dispatch, allowing selection at runtime at which concrete type’s trait method implementation should be executed.
This dynamic dispatch does come with a cost, as the vtable must be checked to determine the location of the appropriate instructions to execute.
The advantage with trait objects is that users can define their own types which implement the trait, and can then be used wherever a trait object is expected.
Note as well that trait objects may be represented as Box<dyn Trait>
(an owned trait object), or
as &dyn Trait
or &mut dyn Trait
(borrowed trait objects). In either case, they represent an
open set of types which uses dynamic dispatch for method execution.
Summarizing Trade-Offs
Summary of trade-offs between enums and trait objects
↺Delegation Construct | Set of Types | Performance | Restrictions |
---|---|---|---|
Enum | Closed | Fast (branch) | N/A |
Trait Object | Open | Slow (vtable) | Object Safety |
When to Use Them?
With the trade-offs understood, the question is when to use one or the other. This is going to be dependent on your context.
In general, if the need for delegation is only internal, meaning you control all the variants which may at some point need to be constructed in the future, you’re likely better off with an enum. It’s faster, subject to fewer rules (no “object safety” equivalent), and makes it easy to see a list of all the variants which may exist.
If the need for delegation is exposed externally, and you want to be maximally flexible for users of your crate, then a trait object is the better option. While you pay a performance cost for the dynamic dispatch, the flexibility gained is sometimes irreplaceable.