Inconsistent type inference of closure arguments

I am trying to create a callback API, and have problems with closure type inference.

In this example type inference works:

fn main() {
    let f = |t| t + 1.;
    assert_eq!(f(0.), 1.); 

    let f = |t| f64::cos(t);
    assert_eq!(f(0.), 1.);
}

But the following example, despite being similar, does not compile:

fn main() {
    let f = |t| t.cos();
    assert_eq!(f(0.), 1.);
}

The error is

error[E0282]: type annotations needed
   |
   |     let f = |t| t.cos();
   |              ^  - type must be known at this point
   |
help: consider giving this closure parameter an explicit type
   |
   |     let f = |t: /* Type */| t.cos();
   |               

And indeed, adding type annotations fixes it:

fn main() {
    let f = |t: f64| t.cos(); // type annotation added
    assert_eq!(f(0.), 1.);
}

Similar behavior is observed, when type inference is provided not by calling a closure directly, but by passing it to other function that accepts impl Fn(f64) -> f64 as an argument. For more elaborate examples, see code at Rust Playground.

The other case I found, is that unary minus is also preventing type inference (see also similar issue Inconsistent type inference in closures which had no response). The behavior of unary minus makes the following example look ridiculous:

let f = |t: f64| -t + 1.; // type annotations required
assert_eq!(f(2.), -1.);

// let f = |t| -t + 1.; // doesn't compile without type annotations
// assert_eq!(f(2.), -1.);

let f = |t| 1. - t; // works fine
assert_eq!(f(2.), -1.);

This behavior confuses me. Why inference works when Add, Mul, Sub, Div operations are used, but breaks with Neg? Why inference works with qualified f64 methods, like f64::exp(t), f64::sin(t), or f64::powi(t, 2), etc, but it breaks with usual dot notation like t.exp(), t.sin(), or t.powi(2). To me it looks like a bug in compiler, but I am not competent to justify that opinion.

1 Like

Isn’t this part fairly obvious? You’re explicitly naming the type when you say f64::exp(t). t cannot be anything but f64 there. Whereas t.exp() gives the compiler no hint as to what the type of t is supposed to be. It could be f32, f64, another standard floating-point type, or any user-defined type that happens to have an exp method.

Rustc could leave the type undecided at the declaration site and get back to it once it has had a look at where the closure is used, and infer the type based on that. It's one of the limitations (not a bug, just something not implemented) of type inference in Rust that it doesn't do that. The body of a closure must type-check only based on its immediate context. This is why it's usually recommended that closures be defined inline where they're needed.

Thank you for your detailed response. It makes it clear why inference works when f64::exp(t) is used. Also I agree that this behavior should not be called a bug, but a limitation.

Can you elaborate on the meaning of this? Indeed I assumed that Rust keeps argument type unresolved until the first use of the closure, then it goes back and type-checks the body of closure. How else would it be able to compile an example with let f = |t| t + 1.;?

Also, for erroneous example like

let f = |t| t + 1;
let _ = f(2.);

compiler complains that we cannot add f64 and {integer}, so it does know the argument type from the function call. So I still find it very strange, that binary arithmetic operators do work, but Neg doesn't, and f64 methods also do not work.

A type of a floating-point literal can be either f32 or f64, so when you call (0.).cos(), the compiler doesn't know if you want f32::cos or f64::cos.

Nope. It know the argument types from t + 1. This is something borrowed from Haskell: 1 doesn't have a type, but does have a kind of proto-type: {integer} is not a concrete type it's “one of integer types”. Compiler knows “enough” about that closure to decide which exact type would be used later, even if doesn't know the precise type of t, yet.

It's done to hide the fact that things don't work like you expect them to work in simple cases… but it can only be stretched to some extent, not enough to compensate for the inability to do inference later.

I agree, but my point was that compiler knows about f64 type of t, not that it knows that literal 1 if of proto-type {integer}.

When you use . to make a method call or field access, the compiler eagerly wants to know the type of the LHS. I don't know of a canonical issue for that behavior, but you can find a few examples by searching for E0282. You can sometimes work around this by using a qualified path expression instead.

For example, these qualified path versions of the binary operations compile.

    let f = |t| <_ as Add<_>>::add(t, 1.0_f64);
    let f = |t| <_ as Sub<_>>::sub(1., t);

However so does this...

    let f = |t| <_ as Add<_>>::add(<_ as Neg>::neg(t), 1.);

...so I agree that something seems inconsistent here. It's almost if unary operator "desugaring" is like so instead.[1]

    let f = |t| <_ as Add<_>>::add(t.neg(), 1.);

The reporter of this issue also decided it was a unary vs binary operator issue, and the (incomplete) PR seems to agree.


  1. N.b. I know from other issues that there is no actual desugaring from operators to Rust source code; inference and sometimes even execution order differs from the notional desugaring. Always take it with a large grain of salt when something like the reference calls an operator expression "equivalent to" some other expression. ↩︎

5 Likes

The treatment of negation vs addition does look inconsistent. Minimal example:

    (|t| t + t)(1i32); // compiles
    (|t| -t)(1i32); // doesn't compile
2 Likes

There is lots of wackiness with name lookup for unresolved types.

trait Abs { fn abs(self) -> Self; }

impl Abs for i32 { fn abs(self) -> Self { panic!("trait abs!"); } }

fn main() {
    // Calls 'i32::abs'.
    let x = 2; // x is an {integer}
    let _y: i32 = x; // Resolve x to i32
    let _q = x.abs(); // Resolve 'x.abs()' to 'i32::abs(x)'.

    // Calls <i32 as Abs>::abs().
    // Without the 'Abs' trait in scope -or- with it in scope but not implemented for i32 it produces:
    //  error[E0689]: can't call method `abs` on ambiguous numeric type `{integer}`
    let x = 2; // x is an {integer}
    let _q = x.abs(); // Resolve 'x.abs' to 'Abs::abs'
    let _y: i32 = x; // Resolve x to i32 and 'x.abs()' to '<i32 as Abs>::abs(x)'.
}

It obviously keeps going over the x.abs() line to try and resolve the type of x (it has to because it doesn't know if x implements Abs until it determines the final type). Just once it does its seems to be only willing to resolve a trait method rather than an inherent one.

Like you, I can't see why the compiler couldn't do the resolution properly later on; other than changing method resolution like that being a breaking change.

1 Like