Name elision

I have a fair amount of code that looks generally like this (more parameters and some with more fields destructured).

fn foo(Foo { a, b, c }: Foo) {}

I am using destructuring to ensure that if I ever add another field to the Foo struct, it must be handled and not silently ignored by this method, avoiding a potential bug

Presumably we can all agree that repeating Foo in this situation is quite verbose, especially given that the real name is quite a bit longer. Rust requires the type to be explicitly specified for all parameters. However, it shouldn't be necessary to repeat this. There are two alternatives:

  1. Use _ and elide the type from the LHS.

    fn foo(_ { a, b, c }: Foo) {}
    
  2. Omit the type from the RHS.

    fn foo(Foo { a, b, c }) {}
    

Either option provides the compiler all the information it needs to know from the header alone. I honestly don't have a preference either way, as they're both quite clean and consistent with the rest of Rust.

On a semi-related note, why does rustdoc show the destructuring? I've noticed this before with the mutable binding of an argument. Surely the caller neither cares nor needs to care about how the function is handling it?

14 Likes

I'd like to see the _ { ... } syntax work in many different places, including both construction and destructuring. That syntax would be quite universally usable.

I don't have any objection to the latter syntax; it does seem cleaner for this particular case. I think both should work, but I'd prioritize the former because it works in many other places as well.

I've seen many threads discussing proposals for the _ { ... } syntax, and this seems like an entirely consistent part of that syntax to me.

I think, at this point, we need someone to champion _ { } ("structure name elision") as a language proposal, and write up a more detailed explanation of:

  • Every context in which it should work
  • Everything it should work on (structs, unions, single-variant enums, ...)
  • When the compiler has enough type information to infer it (can it work in contexts where further inference is needed, or only in contexts where the compiler knows the exact type already?)
17 Likes

The mut case got fixed recently.

In theory no part of an argument's pattern of is relevant to callers, only the type, but the names of bindings are in practice a big exception because of how helpful they are for documentation. For a destructuring pattern the only way to have names to show is to show the pattern, and this might help considerably to show the meaning of the argument, e.g.

fn foo((file, line): (&str, usize))

(maybe this isn't necessary if the names are the same as the fields on the struct, though?)

fn foo(Foo { a, b, c }: _) { .. } makes sense, might fits better, and might parse easier, but only fn foo(_ { a, b, c }: Foo) { .. } handles type parameters well of course.

I think the _ { ... } syntax sounds interesting, but it'd require more discussion presumably, so maybe : _ gives the easiest route? I donno..

2 Likes

This seems like a good argument. If you write Foo { file: source_file, line: error_line }, there's value in showing those names in documentation. If you write Foo { file, line }, we'd just be repeating the documentation of Foo. Foo should already be a link to the definition of Foo, so inlining the fields of Foo doesn't seem helpful in the documentation.

I don't think the : _ will be necessary to parse that syntax.

Type parameters as in Foo<T>? You could write Foo::<T> { a, b, c }.

Is there a reason the _ has to be there in the _ { ... } syntax? To me it seems just noise, I would avoid it if not necessary.

2 Likes

In this context possibly not, but there are contexts in which it would be required. For instance, in struct construction it'd be required to avoid ambiguity: func(_ { field }) is constructing a value and passing it to func, while func({ field }) would be ambiguous.

The _ also works in cases where something more needs specifying, such as _::Variant(x), or perhaps even _::new(x).

I'd prefer to see the same syntax used everywhere, both for consistency (since it will be required in some places), and for the didactic value of a "something was omitted/inferred here" marker.

1 Like

This previous discussion is related: Pre-RFC: Struct constructor name inference

6 Likes

Two future compatibility things I think should be considered in relation to this discussion are:

IIRC centril was a proponent of making fn foo(Foo { a, b, c }) {} work. Essentially by starting with Foo { a, b, c }: Foo and saying that this is just a pattern with type ascription, and that the Foo { a, b, c } pattern alone gives an unambiguous type to the pattern, so that the type-ascription-pattern wrapping it is unnecessary. This simple rule of having an unambiguously typed pattern for argument bindings would allow other things like fn foo((file: &str, line: usize)) too.

9 Likes

If we ever get function arguments with a default value, the type could often be omitted too:

fn split(s: &str, delimiter = ',');
1 Like

This was discussed around one of the type ascription RFCs, actually.

The thing that convinced me that it's not a good direction is the implication on macros. While I agree that to a human fn foo(Foo { a, b, c }) is plenty clear, I think think it makes sense to have : Foo be there and unelided so that, for example, a macro could easily drop it into a struct. (This also continues the notion that you can think of $fancy_thing: type in a parameter list as temp: type but with a let $fancy_thing = temp; in the body.)

So I think the pattern is the place to elide stuff, which also makes since as it's not actually part of the signature, so is consistent with the "elide inside an item, but be specific for the external view" general rule.

But +100 to having some sort of ☃ { ... } syntax for inferred struct literals and patterns. I think between that and @ekuber's RFC about default arguments, we might get a sea change in how APIs deal with large numbers of parameters.

(Yes, the snowman is placeholder token.)

3 Likes

Love it, we need more emojis in our source code :stuck_out_tongue_winking_eye:

Jokes aside, where should this syntax be allowed? Only in patterns, or also in expressions? Or just in function arguments? And should tuple structs support the :snowman_with_snow: as well?

Function arguments are just irrefutable patterns, so they shouldn't be special -- if it works in a function argument it should work in other pattern positions too.

Personally I'd say definitely to braced-struct patterns and braced-struct literals.

Tuple structs I don't really know, because in expressions they're just function calls (RFC#1506), so the parallel to "well there are lots of places where _ mean to infer the type" isn't so good. As a workaround one could just do foo(a, b, ☃ { 0: c, 1: d });, but that also makes me think that part of the goodness of this syntax is the expectation that the field names are what makes this readable in the first place, and maybe it would be good for foo(a, b, ☃(c, d)); to not work, since it feels like maybe it should have just taken a normal tuple at that point. At least it seems like enough justification that it could be left out of an MVP -- maybe later we'd go "oops, should have included that", but we can find out over time.

That also brings up the related feature for enums, like being able to ☃::V4(v4) => v4.is_link_local(), in a match:

3 Likes

Seems like consensus is tending towards elision over omission, so I'll proceed with the assumption that that is what is desired.

Initial observation as to where "name elision" (stealing this from @josh) could work. The internal types here are, of course, irrelevant.

struct Foo { a: (), b: () }
struct Bar((), ());
enum Baz { A, B, C }
enum Qux { A }

// Struct destructuring, both in function arguments and bodies
fn alpha(_ { a, b }: Foo, _(c, d): Bar) {}
fn beta(foo: Foo, bar: Bar) {
    let _ { a, b } = foo;
    let _(c, d) = bar;
    match (foo, bar) {
        (_ { a, b }, _(c, d)) => {}
    }
}

// Enum matching
fn gamma(baz: Baz) {
    match baz {
        _::A => {},
        _::B => {},
        _::C => {},
    }
}

// Struct, enum construction
fn delta() -> (Foo, Bar, Baz) {
    (
        _ { a: (), b: () },
        _((), ()),
        _::A,
    )
}

// Constant, static declaration
const FOO: Foo = _ { a: (), b: () };
static BAR: Bar = _((), ());
const BAZ: Baz = _::A;

// Types with only one valid value: elision of the _entire_ value
fn epsilon() -> Qux {
    _
}
// maybe a bit more useful example?
fn zeta() -> Result<(), ()> {
    Ok(_)
}

There will naturally be some situations wherein it's clearer to not elide the type; in my opinion this should be left up to the user to decide.


Presumably unions are similar to enums here, but I have never used them myself, so I've omitted them. I know next to nothing about how Rust's type inference works, so I'll leave discussion of that to those who have the requisite knowledge.


Note that one major instance where it is not possible to elide the type is in method calls. While it may seem that Duration::new could initially be elided as _::new, consider that there could be a TotallyNotADuration::new method that also returns Duration for some reason. The compiler would thus be unable to determine which should be called.

2 Likes

In theory, we could have an inference rule that allows saying "if you're in a context that expects a T, and you get _::method(...), and T::method exists and returns a T, infer T::method(...). However, that might be a little too magical, and perhaps unsuitable for an initial run at name elision.

A couple more cases that should allow elision:

// nested structures
struct S1(u32);
struct S2(S1);
let some_s2 = S2(S1(42));
match some_s2 {
    _(_(n)) => dbg!(n);
}

// struct-like enums with one variant
enum E {
    ES { x: u32, y: u32 },
}
// construction
let e: E = _ { x: 1, y: 2 };
// pattern matching
match e {
    _ { x: 1, y: 2 };
}

I considered this and ruled it out for the initial implementation, which is why I didn't mention it.

I (perhaps naïvely) assumed that nested structures would be implied, but I certainly understand if that wasn't the case. All of those that you listed could certainly be feasible, of course.

Note that today, out of these types

struct Unit;
struct Empty {}
struct Zero();

All can be formed with TypeName {} but only Unit can be formed with no braces. (And only Zero() has an eponymous constructor that can be called.)

2 Likes

I find it a bit counterintuitive that enum variants can be elided, but only if the enum has a single variant. I'd prefer if enum variants can't be elided ever, for consistency (besides, single-variant enums aren't used that often anyway, so they don't need the most convenient syntax).

4 Likes

This one certainly could, but I don't think I'm convinced by should. I don't see this as common enough to be worth the sugar -- especially since adding another variant would make it not work.

_::ES { x, y }, sure, but eliding the whole thing I'm less convinced.

I think there's a bit of a pattern here, actually -- as a first pass it could be that this is "allow _ in more places where types are allowed". And E::ES isn't a type -- E is a type, but E::ES isn't a type. Similarly, _(3) for Wrapping(3) isn't a type, it's a function name. And again, _ for Unit is eliding a const, not a type, whereas _ {} for Empty {} is again eliding a type.

Maybe a first stab at this would be "add _ to the type grammar, and look at all those places"? Reminds me of the "allow -> _ syntactically, so the error can tell you what to put" change...

8 Likes