Should we have another non-type never?

Today I saw a StackOverflow question that describes a problem that currently has no satisfying solution.

The problem, in short, is this: you cannot put e.g. panic!() or todo!() in a function returning impl Trait, as the compiler cannot infer the type, so it deduces !, but it does not implement the trait.

The never type cannot implement any trait; even with a magical language feature, it cannot implement e.g. Default. However, this is not a real problem here: we don't use ! as a type, and there is no risk of calling any method of it. The code is just unreachable.

The treatment of never-returning as a type is neat, but this makes me think perhaps it's a bad idea: maybe we should have a #[no_return] attribute for functions (or something alike), that will truly work for any type, not just coerce for any type?

Edit: I copy my later insight here:

However, thinking about it more, we don't need to separate !. We can just declare that if you have a value of type ! (with some rules about references because of unsafe code), the code is truly unreachable, and type checking is not performed, or not performed to some extent.

fn uh_oh<T: Default>(_: fn() -> T) -> T {
    T::default()
}

fn just_panics() -> impl Default {
    panic!()
}

fn main() {
    uh_oh(just_panics); // what impl does this use?
}

The problem is that it's still possible to use the return type even if the function is never called.

12 Likes

We can declare that the type of the function when it's used in a callback is fn ... -> !, but when it's called a stronger control flow check applies that will treat it as unreachable. That will be harder to implement though, as currently calling a function is a separate path expression and call expression.

However, thinking about it more, we don't need to separate !. We can just declare that if you have a value of type ! (with some rules about references because of unsafe code), the code is truly unreachable, and type checking is not performed, or not performed to some extent.

If we had a built-in

impl Default for ! {
    fn default() -> ! { panic!() }
}

then wouldn't this Just Work, in the sense that (a) it's type consistent, and (b) it has the only runtime behavior it could have that's consistent with all the type signatures?

Although impl Default for ! wouldn't be unsound, it wouldn't comply with the specification of Default, and so it would be an incorrect implementation and might easily lead to programs having incorrect (but defined) behaviour.

3 Likes

I don't see anything in the specification of Default that rules out the impl I showed; what are you thinking of?

Yes, but. Now the code can be compiling and running when it should be failing to compile with some "unconditional panic" warning/error. What we probably want is:

impl Default for ! {
    fn default() -> const ! { unreachable!() } // aka `comptime !`?
}

I can think of several scenarios (mostly involving todo!()) where I would want code like this to compile successfully. (It would be fine for it to do nothing useful at runtime.)

2 Likes

Even if this wasn't controversial, it would still not solve the issue for all traits because some can simply not be implemented for !.

Consider for example:

fn foo() -> impl Iterator<Item = u32> {
    panic!()
}

fn bar() -> impl Iterator<Item = String> {
    panic!()
}

There's simply no single valid type for panic!() because no type can implement the same trait twice with different associated types as that would be unsound.

8 Likes

Documentation of std::default::Default::default:

Returns the “default value” for a type.

Default values are often some kind of initial value, identity value, or anything else that may make sense as a default.

Panicking does not comply with this specification because it doesn't return a value at all – and a trait implementation only complies with the specification of a trait if it complies with the specification of all its methods.

I used to be annoyed at this too, but I have come to realise that all the ways it could be "fixed" would be worse, and also wouldn't actually work.

An idea that struck me as I was reading this thread however, what if it is allowed as long as the function (or the type of it) is never used. This would allow writing todo!() while still working on a new leaf function, but not if it gets used anywhere. I don't know if that is feasible though, as it is a kind of whole program analysis. You could perhaps make it an error at the call site, but this seems quite complex for little gain.

One workaround you can do is to add a dummy return on another branch in the function. E.g. return T::default() or an empty iterator, etc.

3 Likes

Isn't the correct fix for this sort of thing, e.g.

fn foo() -> impl Default {
    todo!() as u32
}

fn main() {
    let _ = foo();
}

You can as-cast ! to any other type (even on stable Rust), so all you have to do is specify a type for your always-panicking function.

5 Likes

We don't need a single valid type here, though. We need ! as Iterator<Item = u32> and ! as Iterator<Item = String>, which could be two different uninhabited types. (Consider "as" here as a type refinement operator, like u32 as 1..= in the various proposals for restricted range integers.)

Sure it does. A function that is declared to return an uninhabited type, complies with that type signature if and only if it does not return. That's what returning an uninhabited type means.

5 Likes

I still disagree. Panicking is not returning a value at all.

If a function is declared as, say, fn foo() -> u32, then its possible options are either returning a u32, entering an infinite loop, or panicking/aborting (which does not return). If it is defined as returning rather than panicking, panicking would violate the specification.

If the function is declared as fn foo() -> !, returning is no longer an option – it must enter an infinite loop or panic/abort. If it is defined as returning rather than panicking, then panicking still violates the specification (which is no longer possible to comply with).

1 Like

I think you're leaning way too hard on informal language. Nearly all Rust stdlib functions can panic. If Default impls were required never to panic, I would expect it to say so explicitly in the doc for Default.

5 Likes

Rust stdlib functions nearly always document the circumstances under which they can panic, and are assumed to not panic otherwise.

If they allowed undocumented panics for non-obvious reasons, this would make it very difficult to write a correct Rust program, because you would have to guard against the possibility that the stdlib would choose to panic rather than do what the code requested it to do. Perhaps the behaviour of panicking on <! as Default>::default() is "obvious" in a sense – but it becomes less obvious when it's buried deep in code with a generic T: Default bound that is assuming that default() is behaving in a way that matches its specification, and thus doesn't document that it might panic if default() panics (because this isn't an expected outcome). This is expecially true if the function in question doesn't return a T.

4 Likes

Without more code or annotations, that results in a dead code warning today, but hopefully that will no longer be the case soon.

5 Likes

I have never heard anyone make this claim before and I don't think it's backed up by the text of the stdlib documentation.

you would have to guard against the possibility that the stdlib would choose to panic [at any point]

Yes. Yes, you do have to do that. I've been being paranoid about that ever since I learned the language!

I would really like it if the situation were more like you seem to think it is; in particular, if all functions that return a Result were guaranteed not to panic except when the concrete Result<T, E> is uninhabited, it would help a lot.

It would also help a lot, and would probably be an easier incremental step from where we are today, if there were a blanket guarantee that, unless explicitly documented otherwise, all stdlib functions only panic in one of these three circumstances:

  1. memory allocation fails
  2. a documented caller-must precondition is violated
  3. their return type is uninhabited

Regardless, any time you see an uninhabited type in return position, you know that function will never return, so I still don't buy your objection. Of course the correct impl of Default for ! is an unconditional panic. There is nothing else it could be.

1 Like

It could be “no such implementation”, which I think would be significantly better. In particular, I would expect that the presence of ! would cause other compound types to not impl Default. I would be unpleasantly surprised if

#[derive(Default)]
struct SomeStuff {
    int: i32,
    never_mind: !,
}

silently produced a Default::default that unconditionally panicked. And I think it would be even worse if users of a hypothetical type such as

#[derive(Default)]
struct Labeled<T> {
    label: String,
    data: T,
}

suddenly had to cope with a panicking Default::default in generic contexts.

7 Likes

I don't think this is true. There are many functions that can panic if allocation fails, and only a handful of them document that. And that is the most important panic condition that can be feasibly hit by arguably bug free programs.

4 Likes