Draft RFC: default types for traits

Click here for that RFC goodness.

This idea came out of the Never types and inference thread and an earlier RFCs issue.

5 Likes

From the RFC text:

It would probably be quite a perverse use of the Default trait if calling default() then immediately dropping the resulting value intentionally caused IO or something.

Ouch. First of all, since Default::default() isn't const, this is allowed, however "perverse" one imagines it to be. Therefore, assuming it's not the case would be incorrect.

But it doesn't need to be a "perverse" thing either. A perfectly fine use case is a default-constructed RAII guard, for example. That would be impossible to rely on if this RFC was realized.

(For these reasons, it should be obvious, but I strongly oppose this RFC.)

1 Like

I don't think this RFC would affect that at all, presumably the closest thing you could write that this RFC almost seems relevant to is:

let _guard: MyIoPerformingGuard = Default::default();

(although, I don't know why you wouldn't explicitly use MyIoPerformingGuard::default()). In that case this RFC wouldn't affect what is happening because inference will select <MyIoPerformingGuard as Default>::default(). This RFC is only for those cases where inference is otherwise unconstrained and has no idea what to do, i.e. those cases that currently error out with:

error[E0282]: type annotations needed
 --> src/main.rs:2:9
  |
2 |     let x = Default::default();    
  |         ^
  |         |
  |         cannot infer type
  |         consider giving `x` a type

(at least, this is my understanding of the RFC, re-reading I don't see it being very explicitly laid out in the text so it may be that my preconceptions of where this feature could be useful are be colouring my understanding).

let vals = my_iter.into_iter().map(|(_a, b)| b).collect(); 
... 
for val in vals { ... }

In this code the type of vals has not been specified, all that we know is that it is required to implement Default + Extend<B> + IntoIterator<Item = B> . The canonical type which satisfies these constraints is, arguably, Vec<B> .

We know that it implements FromIterator<Item=B> due to collect and IntoIterator due to for _ in vals. The comment from which this was adapted had defined its own function and trait bounds. However, choosing Vec<_> as the canonical type for the combination would be disputable in both versions:

  • Consider the case where B = Result<(), Error>. Then collect::<Result<(), _>> would lead to correctly typed code, with no dynamic allocation. Not erroring potentially hides a programming error, even changing the semantics of collect completely, as one terminates early while the other forcibly consumes all elements.

  • Should Vec<_> be chosen as the canonical trait instantiation even when IntoIterator is not given? If not, then adding more trait bounds could both lead to more or less type deduction, depending on the set of non-explicit and invisble bounds inferred from code. That does not make a compelling case for consistent reasoning. But on the opposite, if so, should it be guaranteed that adding a new bound that Vec<_> satisfies still results in Vec<_> being selected? I feel like yes, that's how other type deduction rules work, they can't fail if you add more already satisfied bounds. The RFC should at least suggest or discuss ways how to check that, I think.

I'd definitely like to solve the unimplemented!() problem for futures!

default<T> IntoIterator<T> = NeverOutput<T>;
default<T> Default + Extend<T> + IntoIterator<T> = Vec<T>;

How does semver work for these? If the first one existed, would it mean we could never add the latter, as it would change types?

Also, there should probably be a coherence note. The obvious thing there is that only the crate defining the trait can default the trait, but that will be trouble for things like IntoIterator that are in core, as it cannot mention Vec.

Could the RFC be broken down into stages:

  • Try to default to ! in all situations rather than just some as Rust does today.

Could we do this first, evaluate how it works in practice, then use that to inform the debate on other parts of this proposal. I think the above would be less controversial since there is no new syntax, it just means some valid programs pass that would have failed before. I don't know how this relates to async functions as I haven't really been following the discussion there.

I think that there are real improvements to be had in ergonomics with this RFC, or another similar one, and I think this RFC is an important addition to the debate. It would be nice to avoid new syntax though, and I havn't thought enough about the consequences of this - does it simplify code or make it more complicated by introducing "compiler magic". I think simplifies, since taking the example

fn gen_error() -> impl Error {
    unimplemented!();
}

I think it's reasonable to accept this program (if we have impl Error for !), since the return type of the function body is !. Also I don't think it gets counter-intuitive if we increase complexity

fn construct_ok<T, E>(val: T) -> Result<T, E> {
  Ok(val)
}

However in this case I think it would be clearer written like

fn construct_ok<T>(val: T) -> Result<T, !> {
  Ok(val)
}

. We would then need ! to coerce into any other type - I don't know if that is part of the never proposal.

This is just my 2 cents as somebody new-ish to writing rust full time, but I’m highly in favor of something that would fix the -> impl Future { unimplemented!(); } problem. I’ve run in to it way more than I would have thought (I’m a heavy user of impl, I find it really nice). I can’t comment much on this rfc, but I like that specific papercut that it solves.

Can you give an example of an RAII guard that would be created using Default::default()? I mean, hypothetically someone might do this but I think @Nemo157 is right in pointing out that it would be weird to not at least write MyIoPerformingGuard::default() (or more likely MyIoPerformingGuard::new(and_probably_some_argument_that_says_what_we_are_guarding))

Nonetheless, I'll concede that including default Default = () might be a bad idea - at least if it got used without raising a warning - though I think in practice it would be unlikely to cause problems.

I would say definitely not. If we only have a bound of FromIterator<B> and not IntoIterator<Item = B> then the type we need is one that collects values but never hands them back. () might make sense in this case if it implemented FromIterator<B>, although it's not clear whether the code expects the iterator to be drained or not (which is why () doesn't implement FromIterator<Item = B>). Once you add the IntoIterator<Item = B> bound though, you now need a type which consumes a sequence of Bs and then produces a sequence of Bs.

That's a good point, though in this case NeverOutput<T> doesn't implement Default so it would be fine. Maybe @HeroicKatora is right and we shouldn't be able to override less-specific default type declarations with more specific ones.

Yeah that is one of the main motivations of the RFC. An alternative solution would be if we somehow had !: Future<Output = T> for all T. Perhaps ! should be more magical and automatically implement (almost) all traits. Or perhaps there should be a way of writing trait impls which turn associated types into generic type arguments, something like:

// T not used on Future or !, just on the associated type.
impl<T> Future for ! {
    type Output = T;
    ...
}

Writing <! as Future>::Output would then produce a type variable, unifiable with anything. I don't know whether this completely breaks the type system though.

If the code doesn’t care about the concrete type, why would you want to specify a concrete type? In this example you provided, why choose Vec<T> over anything else?

default<T> Default + Extend<T> + IntoIterator<T> = Vec<T>;

Consider the following alternative. When a compiler encounters an impl Trait type that cannot be resolved and if the compiler can prove that no trait methods will ever be called for that type, it can just generate a concrete type like this:

struct Unused;
struct Unused2;

impl Future for Unused {
    type Item = u32;
    type Error = Unused2;

    fn poll(&mut self) -> Result<Async<Self::Item>, Self::Error> {
        unsafe { unreachable_unchecked() }
    }
}

// return type resolves to `Unused`
fn make_future() -> impl Future<Output = u32> {
    unimplemented!()
}

This completely eliminates the need for any manual defaults management.

This won’t work in cases where the trait methods are actually called (like in the default() example), but that’s a questionable use case because it’s unclear how the author of the calling code can figure out which implementation is called.

2 Likes

Not off the top of my head, but again, that doesn't mean the compiler should be free to assume constraints which are not encoded in the type system but which are instead only based on loose conventions of "plausibility". (I can imagine, for example, how this might silently break existing assumptions about unsafe code, which may have catastrophic results.)

Indeed – agreed, and this probably highlights an even more worrying issue. Second guessing the user is not a nice thing to do in any context. If the compiler can't decide what to do, why doesn't it just issue a diagnostic? Resolving ambiguity in a 100% automatic and correct way (I don't just mean "it typechecks" but "it's doing what the programmer really wanted it to do") is probably infeasible.

I just have a hard time believing that the trade-off of eliminating the rare annoyance associated with having to come up with a dummy type is worth the price of potentially doing something completely incorrect or at least dodgy, worse yet doing it silently.

That's basically what defaulting to ! is. Except that ! can't implement Future<Output = T> so the RFC proposes the NeverOutput<T> type on top of that.

Thinking about this again, this is solved by async/await, right?

async fn make_future() -> u32 {
    unimplemented!()
}

(Though that does move the panic to the first poll, iirc)

Are there other examples where the fallback really needs to be on the trait? (As opposed to collect-style ones where putting it on the method would be reasonable.)

How about?

fn make_iterator() -> impl Iterator<Item = u32> {
    unimplemented!()
}

But the compiler can automatically generate an Unused<T> that will implement Future<Output = T>, so it should work. What's the benefit of designing a dedicated NeverOutput type for this purpose (and manually assigning it as the default for a potentially huge number of traits) over just automatically generating a struct on demand?

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.