Generics details 7: final impls
Table of contents
Problem
Allowing an impl to be specialized can lead to higher performance if there are parameter values for which a more optimized version can be written. However, not all impls will be specialized and there are some benefits when that is known:
- The values of associated types can be assumed to come from the impl. In many cases this means leaking fewer implementation details into the signature of a function using generics.
- The bodies of functions from the impl could be inlined into the caller even when using a more dynamic implementation strategy rather than monomorphization.
However, not all impls can opt-out of specialization, since this can create incompatibilities between unrelated libraries. For example, consider two libraries that both import parameterized type TA
and interface I
:
- Library
LB
that defines typeTB
can define an impl with type structureimpl TA(TB, ?) as I
. - Library
LC
that defines typeTC
can define an impl with type structureimpl TA(?, TC) as I
.
Both of these are allowed under Carbon’s current orphan rules. A library LD
that imports both LB
and LC
could then query for the implementation of I
by TA(TB, TC)
and would use the definition from library LB
, which would be a conflict if library LC
marked its impl definition as not specializable.
Background
Rust currently does not support specialization, so for backwards compatibility impls are final by default in Rust’s specialization proposal.
Proposal
We propose that impls can be declared final
, but only in libraries that must be imported by any file that would otherwise be able to define a higher-priority impl.
Details
Details are in the added final
impl section to the generics details design document.
Rationale based on Carbon’s goals
This proposal supports the following of Carbon’s goals:
- Performance-critical software: the ability to inline functions defined in
final
impls will in some cases improve performance. - Software and language evolution: reducing how much implementation details are exposed in a generic function’s signature allows that function to evolve.
- Code that is easy to read, understand, and write: reducing the list of requirements in a generic function signature is an improvement to both readability and writability. Furthermore,
final
impls are a tool for making code more predictable. For example, making the dereferencing impl for pointers final means it always does the same thing and produces a value of the expected type.
Alternatives considered
No final
, all parameterized impls may be specialized
In addition to the problems listed above, we ran into problems in proposal #911 trying to use the CommonType
interface to define the type of a conditional expression, if <condition> then <true-result> else <false-result>
. The idea is that CommonType
implementations would specify how to combine the types of the <true-result>
and <false-result>
expressions using an associated type. In generic code, however, there was nothing to guarantee that there wouldn’t be a specialization that would change the result. As a result, nothing could be concluded about the common type if either expression was generic. If at least the common type of two equal types was guaranteed, then you could use an explicit cast to make sure the types were as expected. Some method of limiting specialization was needed.
We considered other approaches, such as using the fact that the compiler could see all implementations of private interfaces, but that didn’t address other use cases. For example, we don’t want users to be able to customize dereferencing pointers for their types so that dereferencing pointers behaves predictably in generic and regular code.
final
associated constants instead of final
impls
We considered allowing developers to mark individual items in an impl as final
instead. This gave developers more control, but we didn’t have examples where that extra control was needed. It also introduced a number of complexities and concerns.
The value for a final let
could be an expression dependent on other associated constants which could be final
or not. Checking that a refining impl adheres to that constraint is possible, but subtle and possibly tricky to diagnose mistakes clearly.
If an impl matches a subset of an impl with a final let
, how should the narrower impl comply with the restriction from the broader?
interface A {
let T:! type;
}
impl [U:! Type] Vector(U) as A {
final let T:! Type = i32;
}
impl Vector(f32) as A {
// T has to be `i32` because of the `final let`
// from the previous impl. What needs to be
// written here?
}
We considered two different approaches, neither of which was satisfying:
- Restate approach: It could restate the
let
with a consistent value. This does not give any indication that thelet
value is constrained, and what impl is introducing that constraint, leading to spooky action at a distance. It was unclear to us whether the restatedlet
should usefinal
as well, or maybe some other keyword? - Inheritance approach: We could have a concept of inheriting from an impl, and require that any impl refining an impl with
final
members must inherit those values rather than declaring them. Inheritance between impls might be a useful feature in its own right, but requires there be some way to name the impl being inherited from.
Consider two overlapping impls that both use final let
. The compiler would need to validate that they are consistent on their overlap, a source of complexity for the user. An impl that overlaps both would have to be consistent with both, but would not be able to inherit from both, a problem with using the inheritance approach.
Ultimately we decided that this approach had a lot of complexity, concerns, and edge cases and we could postpone trying to solve these problems until such time as we determined there was a need for the greater expressivity of being able to mark individual items as final
. This discussion occurred in: