A while ago I posted a question about whether Niko's borrow checking without lifetimes could help with specialization soundness. After some pondering, I believe the origins model could lead to a sound specialization implementation. Since I'm not a compiler expert I'm asking people to point out what I'm missing and why this still wouldn't cover the soundness hole. I've tried to find a fatal flaw and I can't, which probably means I'm missing something obvious.
I've experienced the power of multiple dispatch in Julia firsthand. Stefan Karpinski's "The Unreasonable Effectiveness of Multiple Dispatch" talk captures it perfectly, Julia's ecosystem achieves a remarkable level of code reuse across packages specifically because you can specialize generic algorithms on the concrete types of all arguments. I think Rust is genuinely missing out on being a language of high-level code reuse by not having specialization. The trait system is powerful, but without the ability to provide specialized implementations for more specific cases, it leaves a lot on the table, especially for scientific computing, numerical libraries, and performance-sensitive generic code.
Why Rust doesn't have specialization (and Julia does)
Before getting into the discussion, it's worth understanding why this is hard for Rust specifically. Julia has full multiple dispatch and it works really well, Julia aggressively compiles specialized method bodies for concrete type tuples ahead of time, and with PackageCompiler you get native binaries with all dispatch resolved at compile time.
The real reason Rust can't do what Julia does comes down to three things:
1. Lifetimes don't exist in Julia's type system. Julia's type lattice is Any > Abstract types > Concrete types. Parameters are always types or values. There's no equivalent of 'a. When Julia resolves f(x::Tuple{T,T}) vs f(x::Tuple{A,B}), T can only unify on actual data types. It can never unify two things that differ only in a lifetime annotation that gets erased later.
2. Julia doesn't separate type checking from dispatch resolution. In Rust, there are two distinct phases: the trait solver resolves associated types and checks bounds (pre-monomorphization), then codegen monomorphizes (post-erasure). Specialization creates a contradiction between these phases because the solver picks one impl but monomorphization picks another. Julia doesn't have this split. It sees concrete types, finds the most specific method, and compiles that. One pass.
3. Julia's coherence model is open. Rust has the orphan rule and requires that impls don't overlap. Julia accepts that method tables are open and potentially ambiguous. Ambiguities produce a runtime MethodError. Rust categorically rejects ambiguity at compile time.
The combination of "lifetime erasure exists" + "generic code is type-checked before monomorphization" + "coherence must be decidable without runtime fallback" is what makes specialization hard in Rust specifically. Julia traded all three of those for its dispatch model.
The soundness hole, concretely
Here's one example of unsound specialization (RFC 1210) today:
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()
}
When the type checker resolves <T as Bomb>::Assoc inside build, it can't decide between the two impls because it doesn't know whether T's lifetime is 'static or not. Lifetimes are abstract region variables at that point. It commits to the default impl, so T::Assoc = (). But after monomorphization, when lifetimes are concrete, codegen sees that T = &'static str and picks the specialized impl where T::Assoc = String. The type checker said (), codegen says String. You end up with a value whose runtime type doesn't match what the type checker believes.
The root cause: lifetimes are abstract during type checking but concrete during codegen, and specialization can dispatch differently based on lifetimes.
How origins change the picture
Under the origins model from Niko's blog post, abstract lifetime region variables are replaced with concrete loan sets tied to place expressions. &x has origin {shared(x)}, a concrete fact about where the borrow came from, visible at every compilation phase. The trait solver and codegen see the same information. There's no "abstract during checking, concrete during codegen" split for origins.
Let's revisit the Bomb example under origins. Consider a call site:
let s = String::from("hello");
let result = build(s.as_str());
The argument s.as_str() has type &{shared(s)} str. Under origins, this is a concrete, specific type. It is not &{static_data} str. The specialized impl Bomb for &'static str (which under origins is Bomb for &{static_data} str) doesn't match because {shared(s)} != {static_data}. The default impl fires, Assoc = (), and the type checker and codegen agree.
Now consider:
let result = build("hello"); // string literal
Here "hello" has type &{static_data} str. The specialized impl matches exactly. Assoc = String. The type checker and codegen both see {static_data} and both pick the specialized impl. They agree.
Under the current compiler, lifetime erasure strips both &{shared(s)} str and &{static_data} str down to just &str, so codegen can't tell them apart and may pick the wrong impl. Keeping track of origins preserve the distinction all the way through.
A common concern here is that lifetime erasure during codegen exists for a reason: if we monomorphized on every distinct origin, the code bloat would be enormous. A function like fn foo<'a, 'b, 'c, T, U> currently produces one machine code copy per (T, U) pair regardless of how many lifetime combinations exist. Monomorphizing on all origin combinations could explode that by orders of magnitude. This proposal does not ask for that. We are not demanding that codegen monomorphizes on all origins in general. Origins participate in specialization dispatch only in the three narrow cases listed below ('static, same-origin, and explicitly declared outlives bounds). Everything else continues to erase lifetimes exactly as it does today. The code bloat cost is proportional to the number of specializations the programmer actually writes, not to the number of distinct origins in the program.
The pitch
Origins still have variance, coercion, and subtyping relationships for regular Rust code. A reference with a longer-lived origin can still be passed where a shorter-lived one is expected. Outlives reasoning works as before. Nothing about existing Rust code changes.
What this proposal does is not participate origin subtyping in specialization dispatch unless explicitly declared via where clauses. For specialization purposes, we can dispatch on three things:
- Is this origin
'static? - Are these two origin parameters structurally the same?
- Does an explicit
whereclause declare an outlives relationship?
If none of those hold, we fall back to the default impl.
This makes the proposal somewhat asymmetric: type-based specialization (e.g. T: Copy) works fully and propagates through generics, same as RFC 1210 intended. Lifetime-based specialization is more limited. It can't always propagate through generic context because the solver can't prove origin relationships when they're still abstract parameters. But it still captures the most important cases. Let me walk through each axis.
Axis 1: 'static specialization (works everywhere)
'static is a concrete, globally known origin at every compilation phase. The solver knows whether T: 'static holds before monomorphization. This means 'static specialization works for both methods and associated types, propagates through generics, and is compatible with dyn Trait.
impl<T> Cache for T {
default type Strategy = DynamicLookup;
}
impl<T: 'static> Cache for T {
type Strategy = StaticLookup;
}
// propagates fine, solver knows T: 'static from the where clause
fn cached_lookup<T: Cache + 'static>(key: &T) -> T::Strategy {
T::resolve(key)
}
No disagreement between phases is possible because T: 'static is visible to both the solver and codegen.
Axis 2: Same-origin specialization
This is the most interesting case. Under origins, every borrow is tied to a concrete place expression. Two references either come from the same place or they don't:
impl<'a, 'b> Merge for (&'a str, &'b str) {
default type Out = String;
default fn merge(self) -> String {
format!("{}{}", self.0, self.1)
}
}
impl<'a> Merge for (&'a str, &'a str) {
type Out = &'a str;
fn merge(self) -> &'a str {
if self.0.len() > self.1.len() { self.0 } else { self.1 }
}
}
At a concrete call site, origins are place expressions. The compiler can see directly whether they're the same:
let s = "hello";
let result = (&s, &s).merge();
// both origins are {shared(s)} -> same -> specialized impl
// result: &str
let a = "hello";
let b = "world";
let result = (&a, &b).merge();
// {shared(a)} vs {shared(b)} -> different -> default impl
// result: String
No ambiguity. Origins are structural facts about where the borrow came from. There's no variance or coercion between them for specialization purposes. {shared(x)} is just {shared(x)} and {shared(y)} is just {shared(y)}.
Method-only same-origin specialization: propagates through generics
When only the method body changes but the return type stays the same, codegen can safely pick the optimized path at monomorphization time without any type-level disagreement:
impl<'a, 'b> FastConcat for (&'a str, &'b str) {
default fn concat(&self) -> String { /* allocate + copy */ }
}
impl<'a> FastConcat for (&'a str, &'a str) {
fn concat(&self) -> String { /* optimized: same source */ }
}
fn do_concat<'a, 'b>(x: &'a str, y: &'b str) -> String {
(x, y).concat() // return type is String either way, safe to specialize at mono time
}
The caller committed to String, codegen produces String. Which code path runs is an implementation detail the caller doesn't observe through the type system.
Associated type same-origin specialization: restricted to concrete call sites
When the associated type changes between the default and specialized impl, things are trickier. Consider:
fn call_merge<'a, 'b>(x: &'a str, y: &'b str) -> <(&'a str, &'b str) as Merge>::Out {
(x, y).merge()
}
When the solver type-checks call_merge, it sees 'a and 'b as distinct abstract parameters. It can't prove they're the same origin, so it commits to the default: Out = String. But at a concrete call site where both arguments happen to share the same origin, codegen could see that the specialized impl matches, and now the caller committed to String while codegen wants to produce &'a str. This is exactly the same kind of disagreement between phases that causes the original soundness hole.
The fix is straightforward: same-origin associated type specialization only fires at direct call sites where the compiler can see the concrete origins, not through generic forwarding. In generic context, it always falls back to the default. This way the solver and codegen always agree. No deferred resolution needed:
// Direct call site: compiler sees both origins concretely
let s = "hello";
let result: &str = (&s, &s).merge(); // same origin -> specialized -> Out = &str
// Direct call site: different origins, solver picks default
let a = "hello";
let b = "world";
let result: String = (&a, &b).merge(); // different origins -> default -> Out = String
// Generic context: origins are abstract parameters, falls back to default
fn generic<'a, 'b>(x: &'a str, y: &'b str) -> String {
(x, y).merge() // always returns String, even if origins happen to be same at the call site
}
This restriction is narrow. It only applies to same-origin specialization that changes associated types. Everything else propagates through generics normally: all type-based specialization (T: Copy, etc.), 'static specialization, same-origin method-only specialization, and outlives bounds from where clauses.
Axis 3: Outlives bounds from where clauses
If a function declares an outlives relationship in its where clause, the solver knows about it before monomorphization, just like any other trait bound. This means specialization based on outlives can work fully, including associated types, as long as the bound is explicit:
impl<'a, 'b> Strategy for (&'a Data, &'b Data) {
default type Out = Conservative;
}
impl<'a, 'b> Strategy for (&'a Data, &'b Data)
where 'a: 'b
{
type Out = Optimized; // safe because we know 'a outlives 'b
}
// This works: the solver sees 'a: 'b from the where clause,
// so it can commit to Out = Optimized and codegen will agree
fn process<'a, 'b>(x: &'a Data, y: &'b Data) -> <(&'a Data, &'b Data) as Strategy>::Out
where 'a: 'b
{
(x, y).resolve()
}
The key rule: outlives relationships that the compiler discovers only at a concrete call site (not declared in where clauses) do not participate in specialization dispatch. This is consistent with the same-origin restriction. We only dispatch on information the solver has at trait resolution time, not information that emerges later. If you need an outlives-based specialization to fire through a generic function, declare the bound.
What about overlapping outlives impls?
You might worry about cases where two specialized impls have overlapping outlives bounds that are incomparable:
// rejected: ambiguous overlap (neither 'a: 'b nor 'b: 'c implies the other)
impl<'a, 'b, 'c> Strat for (&'a T, &'b T, &'c T) where 'a: 'b { ... }
impl<'a, 'b, 'c> Strat for (&'a T, &'b T, &'c T) where 'b: 'c { ... }
This is handled by the existing RFC 1210 lattice rule: if two impls overlap, there must be a strictly more specific impl covering the intersection, otherwise the compiler rejects it at definition site. This is exactly the same rule that already exists for type-based specialization. For example, you can't have two impls for T: Clone and T: Debug without providing one for T: Clone + Debug. It extends naturally to lifetime bounds:
// fixed: provide the intersection
impl<'a, 'b, 'c> Strat for (&'a T, &'b T, &'c T) { default ... }
impl<'a, 'b, 'c> Strat for (&'a T, &'b T, &'c T) where 'a: 'b { default ... }
impl<'a, 'b, 'c> Strat for (&'a T, &'b T, &'c T) where 'b: 'c { default ... }
impl<'a, 'b, 'c> Strat for (&'a T, &'b T, &'c T) where 'a: 'b, 'b: 'c { ... }
No new machinery needed. Just extend the existing specificity check to include lifetime bounds in the partial order.
Where this could be confusing
Most cases are intuitive. If you borrow through the same binding, references share an origin. If through different bindings, they don't. This is visible in the code:
fn process_one_source(data: &[u8]) {
let a = Cursor { data, pos: 0 };
let b = Cursor { data, pos: 5 };
(a, b).merge() // same origin, obvious: both constructed from `data`
}
fn process_two_sources(x: &[u8], y: &[u8]) {
let a = Cursor { data: x, pos: 0 };
let b = Cursor { data: y, pos: 5 };
(a, b).merge() // different origins, obvious: `x` vs `y`
}
The one genuinely confusing case I can see is closures with impl Trait + '_:
// both closures capture same borrow, same origin
fn make_a(data: &[u8]) -> (impl Process + '_, impl Process + '_) {
(|| data[..5].to_vec(), || data[5..].to_vec())
}
// closures capture different borrows, different origins
fn make_b(x: &[u8], y: &[u8]) -> (impl Process + '_, impl Process + '_) {
(|| x[..5].to_vec(), || y[..5].to_vec())
}
The signatures under elision look similar, but the '_ hides whether the origins are the same or different, which would affect specialization dispatch. However, I'd argue this isn't a new kind of confusion. Elided lifetimes in impl Trait return types are already one of the more confusing aspects of Rust today. Origin-based specialization raises the stakes (dispatch behavior changes instead of just borrow checker errors), but the compiler could mitigate this with a warning when same-origin specialization interacts with elided lifetimes in impl Trait return position.
Summary
The idea in a nutshell:
- Type-based specialization: works as RFC 1210 intended, full propagation through generics
'staticspecialization: works everywhere, propagates through generics,dyn Traitcompatible- Same-origin method specialization: propagates through generics (return type is unchanged, so no type-level disagreement is possible)
- Same-origin associated type specialization: works at concrete call sites where origins are visible; falls back to the default in generic context where origins are still abstract
- Outlives from where clauses: works everywhere, same as any other declared bound
- Overlapping impls: handled by existing RFC 1210 lattice rule, extended to include lifetime bounds
What am I missing? Is there a case where this would still produce a disagreement between the type checker and codegen?