{
"$type": "site.standard.document",
"bskyPostRef": {
"cid": "bafyreigo7j4v4z43mro25jhex4acey4nac5tmh6kmedf6p7gbzx7f5xoe4",
"uri": "at://did:plc:ivbknywyskln22er3nkssdhl/app.bsky.feed.post/3mgy6zrdb7472"
},
"path": "/t/could-borrow-checking-with-origins-unblock-sound-specialization/24079#post_1",
"publishedAt": "2026-03-13T16:44:26.000Z",
"site": "https://internals.rust-lang.org",
"tags": [
"a question",
"borrow checking without lifetimes",
"\"The Unreasonable Effectiveness of Multiple Dispatch\""
],
"textContent": "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.\n\nI'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.\n\n## Why Rust doesn't have specialization (and Julia does)\n\nBefore 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.\n\nThe real reason Rust can't do what Julia does comes down to three things:\n\n**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.\n\n**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.\n\n**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.\n\nThe 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.\n\n## The soundness hole, concretely\n\nHere's one example of unsound specialization (RFC 1210) today:\n\n\n trait Bomb {\n type Assoc: Default;\n }\n\n impl<T> Bomb for T {\n default type Assoc = ();\n }\n\n impl Bomb for &'static str {\n type Assoc = String;\n }\n\n fn build<T: Bomb>(t: T) -> T::Assoc {\n T::Assoc::default()\n }\n\n\nWhen 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.\n\nThe root cause: **lifetimes are abstract during type checking but concrete during codegen** , and specialization can dispatch differently based on lifetimes.\n\n## How origins change the picture\n\nUnder 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.\n\nLet's revisit the `Bomb` example under origins. Consider a call site:\n\n\n let s = String::from(\"hello\");\n let result = build(s.as_str());\n\n\nThe 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.\n\nNow consider:\n\n\n let result = build(\"hello\"); // string literal\n\n\nHere `\"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.\n\nUnder 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.\n\nA 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.\n\n## The pitch\n\nOrigins 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.\n\nWhat 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:\n\n 1. Is this origin `'static`?\n 2. Are these two origin parameters structurally the same?\n 3. Does an explicit `where` clause declare an outlives relationship?\n\n\n\nIf none of those hold, we fall back to the default impl.\n\nThis 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.\n\n### Axis 1: `'static` specialization (works everywhere)\n\n`'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`.\n\n\n impl<T> Cache for T {\n default type Strategy = DynamicLookup;\n }\n\n impl<T: 'static> Cache for T {\n type Strategy = StaticLookup;\n }\n\n // propagates fine, solver knows T: 'static from the where clause\n fn cached_lookup<T: Cache + 'static>(key: &T) -> T::Strategy {\n T::resolve(key)\n }\n\n\nNo disagreement between phases is possible because `T: 'static` is visible to both the solver and codegen.\n\n### Axis 2: Same-origin specialization\n\nThis 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:\n\n\n impl<'a, 'b> Merge for (&'a str, &'b str) {\n default type Out = String;\n default fn merge(self) -> String {\n format!(\"{}{}\", self.0, self.1)\n }\n }\n\n impl<'a> Merge for (&'a str, &'a str) {\n type Out = &'a str;\n fn merge(self) -> &'a str {\n if self.0.len() > self.1.len() { self.0 } else { self.1 }\n }\n }\n\n\nAt a concrete call site, origins are place expressions. The compiler can see directly whether they're the same:\n\n\n let s = \"hello\";\n let result = (&s, &s).merge();\n // both origins are {shared(s)} -> same -> specialized impl\n // result: &str\n\n let a = \"hello\";\n let b = \"world\";\n let result = (&a, &b).merge();\n // {shared(a)} vs {shared(b)} -> different -> default impl\n // result: String\n\n\nNo 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)}`.\n\n#### Method-only same-origin specialization: propagates through generics\n\nWhen 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:\n\n\n impl<'a, 'b> FastConcat for (&'a str, &'b str) {\n default fn concat(&self) -> String { /* allocate + copy */ }\n }\n\n impl<'a> FastConcat for (&'a str, &'a str) {\n fn concat(&self) -> String { /* optimized: same source */ }\n }\n\n fn do_concat<'a, 'b>(x: &'a str, y: &'b str) -> String {\n (x, y).concat() // return type is String either way, safe to specialize at mono time\n }\n\n\nThe caller committed to `String`, codegen produces `String`. Which code path runs is an implementation detail the caller doesn't observe through the type system.\n\n#### Associated type same-origin specialization: restricted to concrete call sites\n\nWhen the associated type _changes_ between the default and specialized impl, things are trickier. Consider:\n\n\n fn call_merge<'a, 'b>(x: &'a str, y: &'b str) -> <(&'a str, &'b str) as Merge>::Out {\n (x, y).merge()\n }\n\n\nWhen 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.\n\nThe 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:\n\n\n // Direct call site: compiler sees both origins concretely\n let s = \"hello\";\n let result: &str = (&s, &s).merge(); // same origin -> specialized -> Out = &str\n\n // Direct call site: different origins, solver picks default\n let a = \"hello\";\n let b = \"world\";\n let result: String = (&a, &b).merge(); // different origins -> default -> Out = String\n\n // Generic context: origins are abstract parameters, falls back to default\n fn generic<'a, 'b>(x: &'a str, y: &'b str) -> String {\n (x, y).merge() // always returns String, even if origins happen to be same at the call site\n }\n\n\nThis 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.\n\n### Axis 3: Outlives bounds from where clauses\n\nIf 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:\n\n\n impl<'a, 'b> Strategy for (&'a Data, &'b Data) {\n default type Out = Conservative;\n }\n\n impl<'a, 'b> Strategy for (&'a Data, &'b Data)\n where 'a: 'b\n {\n type Out = Optimized; // safe because we know 'a outlives 'b\n }\n\n // This works: the solver sees 'a: 'b from the where clause,\n // so it can commit to Out = Optimized and codegen will agree\n fn process<'a, 'b>(x: &'a Data, y: &'b Data) -> <(&'a Data, &'b Data) as Strategy>::Out\n where 'a: 'b\n {\n (x, y).resolve()\n }\n\n\nThe 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.\n\n#### What about overlapping outlives impls?\n\nYou might worry about cases where two specialized impls have overlapping outlives bounds that are incomparable:\n\n\n // rejected: ambiguous overlap (neither 'a: 'b nor 'b: 'c implies the other)\n impl<'a, 'b, 'c> Strat for (&'a T, &'b T, &'c T) where 'a: 'b { ... }\n impl<'a, 'b, 'c> Strat for (&'a T, &'b T, &'c T) where 'b: 'c { ... }\n\n\nThis 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:\n\n\n // fixed: provide the intersection\n impl<'a, 'b, 'c> Strat for (&'a T, &'b T, &'c T) { default ... }\n impl<'a, 'b, 'c> Strat for (&'a T, &'b T, &'c T) where 'a: 'b { default ... }\n impl<'a, 'b, 'c> Strat for (&'a T, &'b T, &'c T) where 'b: 'c { default ... }\n impl<'a, 'b, 'c> Strat for (&'a T, &'b T, &'c T) where 'a: 'b, 'b: 'c { ... }\n\n\nNo new machinery needed. Just extend the existing specificity check to include lifetime bounds in the partial order.\n\n## Where this could be confusing\n\nMost 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:\n\n\n fn process_one_source(data: &[u8]) {\n let a = Cursor { data, pos: 0 };\n let b = Cursor { data, pos: 5 };\n (a, b).merge() // same origin, obvious: both constructed from `data`\n }\n\n fn process_two_sources(x: &[u8], y: &[u8]) {\n let a = Cursor { data: x, pos: 0 };\n let b = Cursor { data: y, pos: 5 };\n (a, b).merge() // different origins, obvious: `x` vs `y`\n }\n\n\nThe one genuinely confusing case I can see is closures with `impl Trait + '_`:\n\n\n // both closures capture same borrow, same origin\n fn make_a(data: &[u8]) -> (impl Process + '_, impl Process + '_) {\n (|| data[..5].to_vec(), || data[5..].to_vec())\n }\n\n // closures capture different borrows, different origins\n fn make_b(x: &[u8], y: &[u8]) -> (impl Process + '_, impl Process + '_) {\n (|| x[..5].to_vec(), || y[..5].to_vec())\n }\n\n\nThe 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.\n\n## Summary\n\nThe idea in a nutshell:\n\n * **Type-based specialization** : works as RFC 1210 intended, full propagation through generics\n * **`'static` specialization**: works everywhere, propagates through generics, `dyn Trait` compatible\n * **Same-origin method specialization** : propagates through generics (return type is unchanged, so no type-level disagreement is possible)\n * **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\n * **Outlives from where clauses** : works everywhere, same as any other declared bound\n * **Overlapping impls** : handled by existing RFC 1210 lattice rule, extended to include lifetime bounds\n\n\n\nWhat am I missing? Is there a case where this would still produce a disagreement between the type checker and codegen?",
"title": "Could borrow checking with origins unblock sound specialization"
}