Returning impl and Type Erasure
Depend on abstractions, not concretions.
Myself and at least a few others have run into confusion learning to use Rust’s impl
. For me, the confusion stemmed from not recognizing there are two different reasons for it to exist.
Syntactic Sugar
As briefly mentioned in StATiC DiSpATcH, impl
can be used as syntactic sugar for writing a generic. Instead of the generic syntax which can often be far more obtuse, you can neatly define the generic aspect inline and more succinctly:
1
2
3
fn Foo<B: Bar>(bar: B);
fn Foo(bar: impl Bar);
Another syntactic benefit is how closely it mirrors dyn
:
1
2
3
fn Foo(bar: impl Bar);
fn Foo(bar: &dyn Bar);
This has benefits for matching expectations, making it easier to swap between the two when it’s possible to do so, and compactness.
Zero Cost Type Erasure
Per Wikipedia:
Type erasure is the load-time process by which explicit type annotations are removed from a program, before it is executed at run-time.
This isn’t always clear though, because really with static dispatch that’s always what the compiler does. It takes our human readable type ladden code and strips out all the types as a consequence of converting your code to machine code where there are no types1.
Another way of thinking about type erasure is that it hides the concrete type from the programmer. Sound familiar? This is where impl
gets really cool and is a concept I’ve only ever seen in Rust.
impl
as a return type
Let’s say I want an abstracted return type rather than a concretion. How would I go about doing that? In most languages, this is accomplished with an interface
.
Remember though, an object reference in a typical managed programming language is effectively just shorthand for Arc<dyn Foo>
2. This means we get some downsides:
- We must use dynamic dispatch. This can add some overhead and hurt compiler optimization. In Rust, it also precludes the use of some advanced trait features (though this last downside applies in both directions, not all traits are object same).
- If we want to move the returned value, it must be boxed.
&dyn
can’t be returned for local state. Because we must useBox
we must allocate memory on the heap3.
This is a problem. We shouldn’t have to sacrifice code quality and ergonomics to account for the representation of our code in the computer. After all, that’s the entire point of using a programming language over coding directly in assembly.
Can we use generics? No. This doesn’t compile:
1
2
3
4
5
6
7
8
9
trait Foo{}
struct ConcreteFoo {}
impl Foo for ConcreteFoo {}
fn bar<F: Foo>() -> F {
ConcreteFoo{}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
error[E0308]: mismatched types
--> src/main.rs:10:5
|
9 | fn bar<F: Foo>() -> F {
| - -
| | |
| | expected `F` because of return type
| | help: consider using an impl return type: `impl Foo`
| expected this type parameter
10 | ConcreteFoo{}
| ^^^^^^^^^^^^^ expected type parameter `F`, found `ConcreteFoo`
|
= note: expected type parameter `F`
found struct `ConcreteFoo`
= note: the caller chooses a type for `F` which can be different from `ConcreteFoo`
For more information about this error, try `rustc --explain E0308`.
This makes sense if we think about it for a second too. This function isn’t generic, it only ever has one implementation. When you’re working with more complex types (or maybe just in older compiler versions?) you’ll get a compiler error that aligns with this realization.
Following the compiler’s excellent feedback then:
1
2
3
fn bar() -> impl Foo {
ConcreteFoo{}
}
This is extremely powerful. We can use an abstraction, rather than a concretion, without making any changes to what the compiler sees or produces for output.
Type erasure makes unwieldy, complex types workable
When you work with highly complex, layered types like the kind that emerge in functional programming the benefits are even stronger. Let’s consider this code:
1
2
3
4
5
6
7
8
9
pub fn do_things(values: Vec<Option<u32>>) -> TYPE? {
let result = values.iter()
.filter(|x| x.is_some())
// SAFETY: None values were filtered out
.map(|x| x.unwrap())
.map(|x| x * 2);
result
}
What is the type of Sum
? It’s pretty gnarly:
1
2
3
4
5
6
7
Map<
Map<
Filter<std::slice::Iter<'_, Option<u32>>, {closure@src/lib.rs:7:17: 7:20}>,
{closure@src/lib.rs:9:14: 9:17}
>,
{closure@src/lib.rs:10:14: 10:17}
>
Even if we didn’t have closures the type would still be pretty yikes. This is where impl
saves the day. Instead of returning the gnarly type we can return:
1
impl Iterator<Item=u32>
Flexibility
One of the advantages to returning an abstraction is that it can be swapped out without making a breaking change. This naturally follows from thinking through SOLID design principles, but it’s worth highlighting explicitly. In the Azure Boost project, there’s an internal SDK that my team owns for implementing services. One of the crates in the SDK is the standard log subscriber. For a variety of reasons, there’s one and exactly one right way to configure the subscriber. This makes it a perfect candidate for a library.
Subscribers have layers / middleware, so the full type could look something like:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Layered<
tracing_subscriber::reload::Layer<
EnvFilter,
Layered<
Filtered<
tracing_journald::Layer,
tracing_subscriber::reload::Layer<EnvFilter, Registry>,
Registry,
>,
Registry,
>,
>,
Layered<
Filtered<
tracing_journald::Layer,
tracing_subscriber::reload::Layer<EnvFilter, Registry>,
Registry,
>,
Registry,
>,
>
Apart from being unwieldly, if any layer is added, removed, or reordered this type is now different. A change to the implementation details becomes a breaking change, since in these paradigms the type is the behavior. Returning impl
avoids this.
The processor operates on “sizes” rather than “types” for the most part. You have words, half words, etc. The processor also tends to have different instructions for signed vs unsigned and integer vs floating point. There are types in this sense, but it’s a paltry subset of a programming languauge’s primitives. Moreover, this information is lost in compiling. When you read or write memory, it’s purely size based. The idea of it being an integer or a floating point number is implicitly baked into the code by which instructions operate on that data, but the data itself is typeless. ↩︎
Actual garbage collection algorithms are more complicated than this and have a variety of different tracking and clearing strategies, even within the same garbage collector. The point though is that it’s a shared ownership pointer, so for our purposes we can conceptualize it as if it were simpler. ↩︎
In limited scenarios, the compiler could be able to optimize this out but it’s very limited. I only know it’s possible because it accidentally happened in my benchmarking of static vs dynamic dispatch. ↩︎