Can I rely on the type inference that caused issues in 1.80 to continue working?

[posting again since for some reason the previous post returns a 404]

I am facing an API choice where I could define a public trait either as (option 1):

pub trait Foo {
    type T;
    // ...
}

or (option 2):

pub trait Foo<T> {
    // ...
}

The second one is slightly preferable as it allows users to implement Foo for the same type but with different types T, though I don't expect this need to arise often and there are easy workarounds with the first option.

A bigger priority, however, is to allow users to write concise code. Going with option 2 above would mean that user code would need to rely on the same type inference mechanism that broke the time crate in Rust 1.80, e.g.:

struct A;
struct B;

// Users will typically only provide one implementation of `Foo` for a given type.
impl Foo<A> for B { ... }

fn func<T: Foo<U>, U>(arg: T) { ... }

// I want users to be able to write the below without type annotations.
// The call to `func` works because `U` is inferred to be `A` due to `B`
// having only 1 `Foo` implementation.
let arg = B;
func(arg);

The question is whether I can count on this to continue working in the foreseeable future and therefore commit to the second option, or if I should be conservative and go with the first option.

I personally find it a bit magical by Rust standards and fairly dangerous since merely adding an implementation of a trait in a crate can break user code. Considering that this has already caused issues (and interestingly is apparently forbidden in Haskell), is there a risk that this kind of inference could be eliminated in a future Rust edition?

I realize that this is akin to crystal ball reading, but I'd be grateful for any opinion that could help me choose the best option...

It's unlikely that only-one-impl type inference will totally go away, because too much code relies on it, so you’re not opening your library up to breakage. But I would avoid relying on it precisely because it is fragile — it gives implementors of your trait a tricky thing to worry about. Just use the associated type (option 1) instead, and give them a safe path to follow.

3 Likes

Thank you for stating what I wanted to hear :slight_smile:
I admit it does make me a bit nervous... And adding to your rationale, I just spotted this discussion where the possibility to get rid of this mechanism in future editions or at least make it a clippy warning actually seems to be on the table:

Note that traits like this -- with a generic parameter returned by a method -- are well known to be prone to breakage. AsRef in std::convert - Rust is the canonical example there, where it's incredibly common that adding new implementations breaks people, but it happens anyway.

I would encourage you to use option A unless you really have multiple implementations most of the time. It's like how IntoIterator uses option A, so that for loops don't have this problem, rather than asking people to pick IntoIterator<T> vs IntoIterator<&T> or something.

3 Likes

Thank you for this additional perspective, I guess that settles is then: option 1 it is!

There are interesting parallels between the one-impl inference rule and defaults which affect inference. In one way of framing it, we do have a singular default which affects inference — if only one applicable choice is remaining, then select it, even if it hasn't been exhaustively inferred for said selection.

With my (very fickle) future inference hat on, I think the most likely change to the one-impl rule is for any reliance on it to be made to warn unless the impl is somehow opt-in marked (perhaps #[fundamental] or #[inherent] or …) as expected to be unique. If and when defaults which affect inference become a thing which works, the marked impl can be a default for inference even when other impls also exist, and not only when it's the only remaining applicable impl.

1 Like

Interesting.

If I understand well, in the IRLO post I linked to, Josh Triplett also hinted at the possibility to opt into one-impl inference, but rather at the trait definition site rather than the impl.

Wouldn't opt-in at the impl site risk again breaking user code with seemingly innocuous changes though? Say that crate B already defines a #[fundamental] implementation of Foo where Foo is defined in crate A, and at some point crate C decides to implement a #[fundamental] Foo on a type that was already public. Wouldn't this potentially break inference in user code using those Foo-implementing types from crates B and C?

It depends on a lot of subtle factors, including ones that we don't know yet (e.g. how the potential feature would work and when it's allowed to be used), but generally speaking, yeah, it would still be a potential inference breakage. That's why a trait opt-in has been discussed as well, but that still doesn't do anything to the inference resolution pitfalls.

Inference and name resolution is an allowed "minor" breakage between semantic versions. It's a required concession to allow non-absolute name resolution to exist at all. But developers should aim to reduce the extent of minor breakage where possible. (And the cargo-semver-checks tool tries to track as many of the possibly breaking changes as it can and classify them as considered "major" or "minor" breaking.)

But what such a feature would mean is that adding new impls without the marker doesn't break any inference relying on selecting the marked impl as a default resolution. Actually properly implementing such might require defaults which affect inference and not be able to work just by gating the current logic, though, which is why I brought up Gankra's post. It's a hard problem for sure, but not one that's impossibly hard[1]. Just extremely difficult to make behave somewhat predictably together with the HM (bidirectional) type inference that we already have.


  1. Trivia: as far as hard problems go, Rust's type resolution is already Turing Complete without const and pattern exhaustiveness checking is NP-hard. So while we don't want to make it worse unnecessarily, compiling Rust in full generality is already, in fact, by some objective measures, actually impossibly hard. ↩︎

And higher-ranked type inference is undecidable, so there will always be some rough edges.

1 Like