[Idea] "fully typed patterns" and function parameter type inference for those

I don't know if this is something that was discussed somewhere already but I haven't run across it yet so I am going to put it up to discussion here:

How about we have a notion of "fully typed patterns", i.e. patterns from which the type can be fully inferred, and drop the necessity for providing the type for parameters in functions that are such "fully typed patterns"

For an example (using a axum handler, where this pattern could be useful)

// before
fn handler(Json(data): Json<MyType>) -> ... { ... }
// with "fully typed patterns"
fn handler(Json::<MyType>(data)) -> ... { ... }

As you can see, the type here is fully given by the pattern already and as such the type annotation should be redundant.

This is explicitly not proposing to drop the requirement that function parameters need an explicit type in general - only in the special case where the pattern of the parameter itself can fully specify that type. That means there should be no huge complexity cost for the type resolution process from that, as well as being fully compatible with existing code, i.e. this shouldn't be breaking.

Advantages

  • Less repetition in parameters of functions taking patterns

Potential disadvantages

I don't think there are any huge ones but maybe

  • Slightly increased compile times (?)
  • Slightly more complex error messages
  • More complex to generate rustdoc maybe, since we need to figure out the concrete type for the param?

I would love to hear your thoughts!

( as an aside: the name "fully typed pattern" is choosen arbitrarily by me and there is probably a better way to call it )

2 Likes

One similar idea (or extension) to this I had recently is to allow a sort of type ascription inside patterns, which would (when used in a function signature) negate the need to annotate the argument type separately.

For example:

fn foo((string, vec): (&str, Vec<i32>))
// could be written as
fn foo((string: &str, vec: Vec<i32>))

I don't know what the blocker is on type ascription in general (it seems to have stalled since 2016, if I looked correctly?), but maybe it could be extended to include this?

6 Likes

Well I agree that type ascription in general seems like a overdue thing, but I would explicitly exclude it from this ( if this ever happens ) and keep it separate, to avoid making this more complex than it needs to be, at least for now.

I think type ascription was mostly stalled because of the additional complexity which the team at the time didn't feel like time well spent to solve, although with the note that it should be picked up again later - might remember wrong here tho.

( although I agree that the tuple case in particular is a very natural extension to this )

2 Likes

This was discussed back in 2018: RFC: Generalized Type Ascription by Centril · Pull Request #2522 · rust-lang/rfcs · GitHub

I originally was in favour, but the discussion about the feature changed my mind. My position is now that having the : $ty always there is the right thing. Yes, Wrapping(x: u32) is enough to deduce that the parameter is Wrapping<u32>, but I no longer think that we should be making people do that deduction. The type should be directly clear, both for humans and things like proc macros.

Thus I would prefer -- both here and in things like statics -- to instead make more convenient construction and pattern syntax, for use in places where the type is contextually clear. (Like how we have Default::default() already, how C++ has conversions from initializer lists, or C# added target-typed new expressions.)

So, for example, rather than having

fn demo(Point { x, y }: Point<f32>) // decl

demo(Point { x, y }) // cal

if we added, say, Swift-inspired

fn demo(.{ x, y }: Point<f32>) // decl

demo(.{ x, y }) // call

We can remove a bunch of the unnecessary repetition without losing the syntatically-clear type.

5 Likes

I originally was in favour, but the discussion about the feature changed my mind. My position is now that having the : $ty always there is the right thing. Yes, Wrapping(x: u32) is enough to deduce that the parameter is Wrapping<u32>, but I no longer think that we should be making people do that deduction. The type should be directly clear, both for humans and things like proc macros.

Maybe that was unclear but I explicitly wasn't proposing to allow Wrapping(x: u32) - I agree that makes it substantially harder to read. I think Wrapping::<u32>(x) is reasonable though and already works today as a pattern.

That doesn't change how I think about it here. I still want the full type after the :.

To use your example, I'd rather

fn handler(.(data): Json<MyType>) -> ... { ... }

instead.

1 Like

I don't know if I agree with you there (feel free to try to convince me tho if you want to :slight_smile: ) but if that syntax was supported I would probably prefer

fn handler(_(data): Json<MyType>) -> ... { ... }

over

fn handler(.(data): Json<MyType>) -> ... { ... }

since _ already works as a "inferred" identifier in other places in rust today and . feels pretty unintuitive to me here, that might just be me tho.

That's the big bikeshed for the feature, yup :stuck_out_tongue:

I agree that _ { x, y } makes some sense, but _(data) in particular it's less obvious, since that's not actually type position, and _ is only inferred type today, not "inferred identifier".

Per https://rust-lang.github.io/rfcs/1506-adt-kinds.html#tuple-structs,

let x = Wrapping(4);

is a function call, so

let x: Wrapping<u32> = _(4);

would be an inferred function call, sorta? At that opens up all kinds of expectations that people could have that wouldn't actually make sense.

Thus I've been trying out the Swift-style let x: Ordering = .Less; style syntax in my examples (as opposed to let x: Ordering = _::Less; that's also often used) to see how it feels.

Probably not worth trying to solve that bikeshed in this thread, though. The important part of my post is the "infer in the pattern or expression, keep the type" direction, not the syntax for what that should look like.

6 Likes

I like this a lot but I think there's one problem that it wouldn't solve, which is that sometimes you run into really complex types and it would be nice if you could localize the ascription to each variable individually. For example something that I'm working on involves parameter types like this:

.on_begin(|Fetch((mut pos, time, /*etc*/)): Fetch<(Each<CursorPos>, Res<Time>, /*Etc*/)>| {
    // ..
})

which would be much smoother to edit and read if each inner parameter could be labeled instead, I think:

.on_begin(|Fetch((
    mut pos: Each<CursorPos>,
    time: Res<Time>,
    // etc: Etc,
))| {
    // ..
})
3 Likes

That's a closure, though, so has way more options and different inference possibilities.

I absolutely think we should allow things like

if let Ok(x: i32) = foo.parse() {

https://rust-lang.zulipchat.com/#narrow/stream/213817-t-lang/topic/type.20ascription.20*in.20patterns*/near/213985025

1 Like

Having some experience with the parsing side of type ascription... Please, no. It's a huge headache for little gain.

I would only go along top-level pattern type ascription:

match foo {
    (a, b): (&str, Vec<i32>) => {}
    _ => {}
}

But that's only useful if foo is being inferred (or if we have anonymous enums).

if let Ok(x): Result<i32, _> = foo.parse() {

There are other syntaxes that we can try instead that don't have the same issues as accepting infix : anywhere in a pattern:

foo.(as Type)
foo.as(Type)
foo.(: Type)
foo.<Type>

If you don't understand why I hate : ascryption everywhere so much, think what happens if you write if let Ok(Foo:Bar(1), _) = foo { when you meant if let Ok(Foo::Bar(1), _) = foo {. Even worse when it isn't a parse error if let Ok(Foo:Bar, _) = foo {.

1 Like

I totally understand for arbitrary ascription.

What's your intuition if it's only on bindings? Would that be as bad, or might it be tolerable?

1 Like

You replied as I was editing my post. I think this answers your question directly:

When : is only allowed at the top level, it becomes much easier to do parse recovery in a sane way.

would these issues still exist if we choose another character or keyword, let's say, idk, fits or something ( or we might be able to repurpose @ and just extend that syntax maybe? )

That would remove the ambiguity in

if let Ok((Foo:Bar(1),_)) = foo { ... }
// vs
if let Ok((Foo fits Bar,_)) = foo { ... }
// or
if let Ok((Foo @ Bar,_)) = foo { ... }

and would probably (idk, haven't done much with the compiler (yet), you are probably more qualified than me to judge something like that :slight_smile: ) enable way easier compiler errors / warnings.

I'm in favor of a different syntax cause you'd probably need it for named-field struct literals anyways.

I submit: Ok(x: <i32>), where the ascribed type is wrapped in angle brackets. I think it would sidestep most path issues while still reusing existing Rust syntax. Downside is that it could still be malformed as a type with generics (Ok(foo::<Bar>), but I think that would always be a parse error in practice (no parameter list, and a lowercase type is unlikely).

But I like pretty much any syntax as long as I can insert & remove entries in a pattern without editing in 2 places.

inventory uses generics that look like this: inventory - Rust

I am partial towards the pat.as<Type> syntax because it is the closest to existing syntax while also being far removed from allowed syntax that will make it easier to deal with. But there's something to be said about maintaining the symmetry with let bindings and fn arguments. I think that allowing : only in top-level patterns is a reasonable compromise. It is also the syntax that I've imagined if we were to ever have anonymous enums:

fn foo() -> Foo | Bar {
    if rand() > .5 {
        Foo
    } else {
        Bar
    }
}
fn main() {
    match foo() {
        foo: Foo => {}
        bar: Bar => {}
    }
}

If are to ever have that, then it would make sense for the same syntax to be used to aid inference and to select the implicit enum variant.

2 Likes

ok. two points:

  1. i think a good few of the (admittedly harrowing) issues @ekuber has solved have do with generalising type ascription to all patterns, which i don't think is what is this thread is/should be about:
    • this feature can be constrained just to places where colons are already accepted after patterns, so no match arms, no matches! macro, etc. the only places this syntax would be accepted (currently) is:
struct State<T>(pub T);

struct MyState;
let State(state: MyState);
fn foo(State(state: MyState), x: f32, y: f32);
  1. if this syntax is (understandably) deemed too problematic wouldn't an even simpler version be less controversial?
// no generics, so just name is enough to fully infer:
struct InferByName {
    pub x: f32,
    pub y: f32,
}

fn do_something(InferByName { x: f32, y: f32 }, num: usize);

this could even allow more elaborate examples without extending syntax. all this would require is making function parameter types optional where simple inference can deduce the type by itself:

struct MyState {
    num: usize,
}

fn takes_state(State(state @ MyState { .. }), x: f32, y: f32);

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