ProgrammingRust librarian / general tools developer

Explain how generics are implemented in Rust. What is the difference between generic parameters and parameters with trait bounds, and how does this affect the final machine code? What pitfalls arise when using generics?

Pass interviews with Hintsage AI assistant

Answer.

Generics allow writing code that is independent of a specific type. They are implemented using angle bracket syntax:

fn max<T: PartialOrd>(a: T, b: T) -> T { if a > b { a } else { b } }

Here, T is a generic type constrained by the PartialOrd trait.

Generic parameters are declared using <T>, but they can be constrained using trait bounds with a colon, for example, <T: Display>. This communicates to the compiler that only types for which the needed trait is implemented can be used.

In Rust, there are two forms of dispatch for generics:

  • Monomorphization: at compile time, the code generates separate versions of the function/struct for each used type. This is achieved by absorbing the trait bounds.
  • Dynamic dispatch: if dyn Trait is used, the call is made through a virtual table (vtable).

Impact on machine code: Using generics with trait bounds (without dyn Trait) leads to monomorphization: an increase in the binary size but maximum speed. Using dyn Trait saves on the binary size but incurs a performance hit.

Trick question.

Question: There's a function

fn do_something<T: Debug>(value: &T)

Will the compiler create a separate do_something function in the binary code for each type with which it is used, or will it use a universal implementation?

Typical incorrect answer: It will use one function for all types thanks to the trait bound.

Correct answer: The compiler creates separate copies of this function for each type (monomorphization), since the trait bound does not make the generic function "universal" via vtable. Universality only appears with dyn Trait (dynamic dispatch).

Example:

fn print_val<T: std::fmt::Debug>(val: T) { println!("{:?}", val); } // A separate version of the function will be created for each call with a different type

Examples of real mistakes due to lack of knowledge on the nuances of the topic.


Story

In a project with large generic objects, it was found that the binary file became significantly larger than expected. Later it turned out: the reason was in the widespread use of generic functions without constraints. Calls with dozens of types led to exponential growth of the executable file size (code bloat), which was revealed only during the release build on CI.


Story

One of the developers accepted a generic parameter with a trait bound, believing that such code worked with "dynamic" dispatch. This led to excessive memory consumption on the server and reduced performance due to the constant growth of code and its caching by the processor.


Story

In the library, they attempted to use a generic trait with a Self type (for example, trait Clone) as dyn Trait, which is not supported in Rust and resulted in a compilation error. The interface needed to be rewritten explicitly; otherwise, the generic API would not work in dynamic mode, and the interface would have to be changed at compile-time.