After reading a blog post borrow checking without lifetimes it made me wonder whether this could be a solution to the specialization unsoundness issue.
For example in shipping specialization: a story of soundness the author describes the classical example of why specialization is unsound:
trait Bomb {
type Assoc: Default;
}
impl<T> Bomb for T {
default type Assoc = ();
}
impl Bomb for &'static str {
type Assoc = String;
}
fn build<T: Bomb>(t: T) -> T::Assoc {
T::Assoc::default()
}
fn main() {
let s: String = build("Uh oh");
drop(s) // typeck and trans disagree about the type of `s`
}
The core of the issue is that different parts of the compiler disagree on which impl
to use because of the ambiguity of lifetimes.
- The Conflict: When you call
build("Uh oh")
, the compiler sees the input has the type&'static str
. It has two potentialimpl
s ofBomb
it could use:
- The generic
impl<T> Bomb for T
, whereT
would be&'static str
. - The specialized
impl Bomb for &'static str
.
- The Ambiguity: The rules of specialization say to pick the most specific implementation. The compiler's type checker can see that
impl Bomb for &'static str
is more specific and correctly deduces thatT::Assoc
should beString
. - The Disagreement (The Bug): However, the code generation part of the compiler gets confused. It sees the generic function
build<T: Bomb>
and has trouble proving that the specialization is always the right choice for the erased lifetime that a&'erased str
has. The blanketimpl
covers&'a str
for any'a
, while the specialized one is only for'static
. This lifetime erasure creates a soundness minefield. Unsure if it can safely commit to the specialization, it falls back to the most general, "safest" option: the blanketimpl
.
The proposed origin system solves this by making the borrow information a concrete and unambiguous part of the type itself, eliminating the abstract lifetime 'a
that causes so much trouble.
Let's re-imagine the types using origins:
- A string literal like
"Uh oh"
doesn't have an abstract'static
lifetime. It has a concrete origin pointing to the program's static data segment. Let's call this origin{'static_data}
. - The type of
"Uh oh"
is now&{'static_data} str
.
Now, let's look at the impl
s in this new lifetime system:
- Blanket
impl
:impl<T> Bomb for T
(This remains the same). - Specialized
impl
:impl Bomb for &{'static_data} str
When the compiler sees the call build("Uh oh")
, the type of the input is unambiguously &{'static_data} str
.
The trait solver now has a much simpler decision:
- Does
impl<T> Bomb for T
match? Yes,T
can be&{'static_data} str
. - Does
impl Bomb for &{'static_data} str
match? Yes, it's an exact match.
Because &{'static_data} str
is a specific, concrete type, it is undeniably more specific than the generic parameter T
. There is no lifetime erasure to create confusion. Every part of the compiler, from the type checker to the code generator, agrees on the type. There is no ambiguity to cause a disagreement. The compiler can confidently select the specialized impl
and know that T::Assoc
is String
throughout the entire compilation pipeline.
It seems that the mojo language has learnt its lesson from Rust and they are using origins concept for handling lifetimes as well.
I would appreciate if anyone can point out the flaw in the reasoning and explain why borrow checking with origins will not fix the unsoundness issue of specialization.