Skip to content

Returning a trait

Level: 201 · working knowledge

One line: A function cannot return Trait, because the caller has to know the size before the branch is taken — impl Trait fixes that when one type comes back, Box<dyn Trait> when several can.

trait Method { fn name(&self) -> &'static str; }

struct Star;
struct BlocStar;
impl Method for Star     { fn name(&self) -> &'static str { "STAR" } }
impl Method for BlocStar { fn name(&self) -> &'static str { "Bloc STAR" } }

fn method_for(seats: usize) -> Box<dyn Method> {
    if seats == 1 { Box::new(Star) } else { Box::new(BlocStar) }
}

fn main() {
    println!("{}", method_for(3).name());  // Bloc STAR
}

Why -> Method is not allowed

The compiler has to know how much stack space every return value needs, and it has to know it at the call site, before the function runs. Star and BlocStar are different types of different sizes, so "returns a Method" does not answer the question — there is no single size that is right for both.

A Box answers it. The value goes on the heap, and what comes back is a pointer, whose size is fixed no matter what it points at. Rust asks you to write that heap allocation down rather than inserting it quietly, which is why the return type says Box<dyn Method> and not Method.

impl Trait when the answer is one type

If only one concrete type can ever come back, say so with impl Trait and nothing is boxed:

fn single_winner_method() -> impl Method { Star }

This is not "some type decided at run time" — the compiler knows perfectly well it is a Star. It is the caller who is not told, so the function keeps the freedom to change its mind later without changing its signature. The value stays on the stack, the call is dispatched statically, and there is no vtable.

impl Trait also works as an argument type, where it is shorthand for a generic parameter used exactly once:

fn count(src: impl std::io::BufRead) -> usize { src.lines().count() }
// the same thing as: fn count<R: std::io::BufRead>(src: R) -> usize

The one thing you lose is the turbofish: with impl Trait in argument position there is no type parameter to name, so count::<File>(f) will not compile.

And the case that made impl Trait necessary rather than convenient: a closure's type has no name. Before it existed, returning one meant boxing it. Now it does not.

What the two spellings actually cost

impl Trait Box<dyn Trait>
How many types can come back exactly one any number
Where the value lives the stack the heap
Dispatch static — the call is compiled straight to the impl dynamic — one indirection through a vtable
Code size one copy per concrete type (monomorphization ↗) one copy, shared

A &dyn Trait is a fat pointer: two words, one to the data and one to the vtable. The verified run below prints 8 for &Star and 16 for &dyn Method, which is that second word made visible.

The verified output

Verified output of returning_a_trait.rs — regenerated by tools/run_examples.py, never hand-typed.

1. One function, two concrete types — chosen at run time
   1 seat(s) -> STAR      counts 1 seat(s)
   3 seat(s) -> Bloc STAR counts 3 seat(s)

2. `impl Trait` when the answer is always the same type
   single_winner_method() -> STAR

3. Why the box was needed: sizes
   Star               0 bytes   a unit struct holds nothing
   BlocStar           8 bytes   one usize
   &Star              8 bytes   one pointer; the type is known
   &dyn Method       16 bytes   TWO pointers: the data, and the vtable
   Box<dyn Method>   16 bytes   same pair, owned

4. The vtable is what makes the call land in the right impl
   STAR      -> 1 seat(s)
   Bloc STAR -> 5 seat(s)

See also

  • Static vs dynamic dispatch — the same two spellings in argument position, where it is a design choice rather than a compiler requirement, plus what makes a trait usable as dyn at all
  • What a trait is — the declaration being returned
  • Nullable pointersBox in its other role, making a recursive type possible
  • String vs &str — the same owned/borrowed split, one layer down

Po polsku

Nawyk z Javy albo C# jest tu dokładnie odwrotny do tego, czego wymaga Rust: tam zwrócenie interfejsu jest rzeczą zupełnie normalną, bo każdy obiekt i tak jest referencją o stałym rozmiarze. W Ruscie -> Method nie przechodzi, ponieważ miejsce wywołania musi znać rozmiar zwracanej wartości, zanim funkcja się w ogóle wykona — a Star i BlocStar mają różne rozmiary, więc „zwraca Method” nie odpowiada na to pytanie. Box<dyn Method> odpowiada: wartość ląduje na stercie, a wraca wskaźnik, który zawsze waży tyle samo. Rust każe tę alokację zapisać jawnie, zamiast wstawiać ją po cichu za plecami programisty.

Drugie zaklęcie, impl Trait, bywa streszczane jako „jakiś typ wybierany w czasie działania” — i to jest nieporozumienie warte zapamiętania. Kompilator doskonale wie, że wraca Star; nie wie tego wywołujący. Funkcja zachowuje przez to swobodę zmiany zdania bez ruszania sygnatury, wartość zostaje na stosie, wywołanie jest rozstrzygane statycznie i żadnej tablicy metod wirtualnych nie ma. W pozycji argumentu impl Trait to po prostu typ generyczny użyty dokładnie raz — a płaci się za to turbofishem: nie ma parametru typu do nazwania, więc count::<File>(f) się nie skompiluje.

Jest jednak przypadek, w którym impl Trait nie jest wygodą, tylko koniecznością: domknięcie (closure) nie ma nazwy typu, więc zanim ten zapis powstał, zwrócenie domknięcia oznaczało opakowanie go w Box. Warto też zapamiętać liczby z wydruku powyżej: &Star waży 8 bajtów, a &dyn Method szesnaście, bo referencja do obiektu cechy (trait object) jest grubym wskaźnikiem — jedno słowo maszynowe na dane, drugie na tablicę metod wirtualnych. To drugie słowo widać na wydruku i to ono jest całą ceną dynamicznego wywołania.

Szukaj po polsku: obiekt cechy · gruby wskaźnik · tablica metod wirtualnych · rust impl Trait vs Box dyn Trait · rust return trait object · rust Sized return value