Auto infer namespaces on struct and enum instantiations

The problem of enum variants being used out of context already exist via use (and re-exports can further obfuscate what is used). Code authors can always make bad choices and write hard to understand code, but when someone publishes code on a website, it's in their interest to make it readable, so you can expect them to choose a syntax that is clear. The long fully-qualified version will always exist, and be available where needed.

When the abbreviated syntax is a normal part of the language, you can expect authors to start taking it into account. When the current syntax is EnumName::Variant, then people designing APIs might want to avoid repeated words, and maybe name variants like AreDogsGood::Yes. But when the alternative syntax becomes a supported option, authors will have to consider it, and name things accordingly, like WhoIsGood::DogsAreGood, so that users can write .DogsAreGood instead of .Yes. This is already the case in Swift — . syntax is common, so APIs don't suffer because of it, the APIs are designed to be most clear with it.

The important aspect is that it can improve readability. It can even improve readability over current Rust status quo, because better enum ergonomics may encourage API designers use enum instead of bool and other alternatives.

6 Likes

Thanks for the replies. I updated the main post with the current cons and pros.

The comments about making rust-analyzer support this even if the language doesn't made me realize that rustc could support this, even if Rust doesn't, in the same way that you can use -> _ to let rustc tell you what the type should be based on the body. :thinking:

Best part is if the lang team ever decides to go for this, the implementation will already be ready. :slightly_smiling_face:

7 Likes

It turned out that aliasing the types is worse than just keeping them namespaced.

Here is a list of my problems with use some_mod::SomeStruct as SomeModSomeStruct

  • Copying the expression SomeModSomeStruct {..}.into() to another file is painful, because IDE does not know anything about these types. - They are defined on top of another file. - Whereas the some_mod::SomeStruct {..} symbols are known in the entire project because they are public. However, .{..} expressions would be perfectly fine in this case.

  • It doesn't add too much. A big heap of code compared to a bit of readability. For other files, I kept the structs namespaced because I didn't want to see 10 or more lines of aliases. Again, .{..} expressions would be perfectly fine in this case.

  • Nothing ensures consistency. Obviously, I would use those aliases everywhere wherever I use those types, and I don't want to see, e.g., 3 different symbols for the same struct. Moreover, not every file needs all the aliases, so it's even harder to maintain because, for every file, I will see different definitions. But again, .{..} expressions would be perfectly fine in this case.

I also tried to improve the code size with the * operator, and I tried to move the expressions closer to where I use them. And here is another problem. The use SomeEnum::*; is also problematic, because if I match with one of its variants, and I delete it, I won't get a compile error.

I also improved our code by using .into(). It works perfectly for enums, but I have to define the From trait to the desired type. This is not a big deal, but with .{..} I wouldn't need to implement it.

How would you not get a compile error? Do you have an example where deleting a variant of an enum all of a sudden matches on some other thing silently?

enum Foo {
    Shared,
}

struct Shared { … }

fn match_on_foo(var: ???) {
    using Foo::*;
    match var {
        Shared => …,
    }
}

In this code, deleting Foo::Shared could conflate with Shared all of a sudden, but…how does this not also have compilation errors of its own? What type for var would possibly make it still work? I guess if it was some type which impl From<ThatType> for Foo and Shared and it was match var.into(), it'd be a problem, but…that certainly seems weird.

I'm all OK with preferring namespaces, but this claim seemed odd since I couldn't see how the code could (sensibly) be structured and not be confused in some other way..

Try this out:

Code example

enum Something {
    Foo,
    Baz
}

fn main() {
    use Something::*;
    let something = Something::Baz;
    match something {
        Foo => println!("foo"),
        Bar => println!("bar"),
        Baz => println!("baz")
    }
}

You will get a warning, but in our case, we have a lot of warnings that we haven't fixed yet so I only see the errors.

I'm all OK with preferring namespaces, but this claim seemed odd since I couldn't see how the code could (sensibly) be structured and not be confused in some other way..

Unfortunately, the project that I'm working on is not public, making a new big example project is really time-consuming. HelloWorld apps could probably be simplified to println!("Hello World"), so those aren't the bests either. However, I think I can share some code sections from the previous versions:

Interesting code parts

A.rs:

use render::msg::{
    keyboard::Msg as KeyboardMsg,
    window_mouse::{Action as WindowMouseAction, Msg as WindowMouseMsg},
    Msg,
};
...

B.rs:

use render::msg::{
    item_mouse::{Action as ItemMouseAction, ItemType, Msg as ItemMouseMsg},
    viewport_mouse::{
        Action as ViewportMouseAction, Msg as ViewportMouseMsg, OuterDragAction,
        OuterDraggableElement,
    },
    window_mouse::{Action as WindowMouseAction, Msg as WindowMouseMsg},
    Msg,
};

...

WindowMouseMsg {
    pos: window_pos,
    action: WindowMouseAction::DownOnLinker(item.get_id()),
}.into()

C.rs:

use render::{
    msg::{item_mouse, keyboard, viewport_mouse, window_mouse, Msg},
    Render,
};

...

use viewport_mouse::{OuterDragAction::*, OuterDraggableElement::*};
match outer_drag {
    Over => {}
    Drop(element_type) => match element_type {
        ...
    },
};

I hope this helps. This is definitely more sensible, but of course, this is not too much. If you are thinking about something that would probably help I would answer whether I could do that or not, or what's my problem with that.

1 Like

Ah, I see. the now-deleted variant is now treated as a variable capture and you end up with unreachable warnings on subsequent match arms. Note that this only happens with field-less enum variants. I had overlooked this behavior since I just could not see the symbol with an initial capital letter as a local variable name :slight_smile: .

1 Like

Echoing @mathstuf 's opinion, I would love to see such a syntax sugar only applied to enums, because for an enum, we usually only care about its values, not its type, but for a struct, we care about more, like its methods and trait implementations, which means we need to know which type it is.

1 Like

This is true for C enums, but is not my experience for Rust enums -- because they're so much richer they often also have extensive and interesting methods and trait implementations too.

For a braced struct, I find that I often care less about the type name than for enums, because the field names help. .Skip doesn't tell me nearly as much as CookieHandling::Skip, but .{ window_length: 4096, dictionary_size: 1 << 16, level: 9 } is useful without knowing that those are on a CompressionOptions struct.

1 Like

I can partially agree on that. But when it comes to a complex struct, like structs served as a descriptor for constructing another struct, it is not so easy to infer information from field names.

For example, a descriptor struct I encountered when configuring a graphics pipeline has like 15+ fields, and few people will need to specify them all, but rather most of users specify some of them and use ..Default::default() for the rest, then it is necessary to provide code inspectors with its type name as a context. In the "worst" case, maybe we just need defaults, then we don't have any context at all if we write something like .default() instead of Descriptor::default().

I think this is the core hangup with this general feature. Reasonable minds can say that such context is better provided than omitted for both enums and structs.

(I wouldn't say that's fatal to the proposal -- type inference in general can do many of these things, and most code is written with most lets not being type-annotated -- but it's the core complaint, and I don't think there's a gigantic difference between structs and enums for it.)

The question is: Do we want this feature everywhere?

My proposal only covers the inside of struct and enum instantiations.

example:

let car = Car {
    owner: .{
       name: String::from("David"),
       role: .Admin
    },
    engine: .{
        fuel: .Petrol,
    }
}

I think it's reasonable to say that

let car = _ {
    owner: _ {
       name: "David".into(),
       role: _::Admin
    },
    engine: .{
        fuel: _::Petrol,
    }
}

would never work, even if later the binding is used in a space that unambiguously types it as Car. This is similar to how the type is required to be known in order to call a method on a value; e.g.

let v = 5;
dbg!(v.count_ones());
drop::<u32>(v);

doesn't compile with error[E0689]: can't call method `count_ones` on ambiguous numeric type `{integer}` .

On the other hand, I think that with something like

let swap_chain = device.create_swap_chain(&surface, &_ {
    usage: _::all(),
    format: preferred_format,
    width, height,
    present_mode: _::Mailbox,
});

the type name of wgpu::SwapChainDescriptor is just noise, because knowing wgpu's API you know that functions take descriptors, and the descriptors are basically just a cheap way to do named (and defaultable) arguments.

1 Like

I could be mistaken, but I thought chalk was going to help out here.

I think in function calls we need additional context.

.Skip doesn't tell me nearly as much as CookieHandling::Skip

-- @scottmcm

The difference from my perspective is that the struct has named properties that define the context. For e.g: role: _::Admin and not just _::Admin

While functions do not have named arguments, and handle(_::Skip,_::Skip), is harder to understand. The same happens for tuples: Some(_::Skip).

However, I think it could work for return types as well. When we use it for return value, the function definition defines the return type. So OK, we see Some(_::Skip) but we understand that because the return type is Option<CookieHandling>, so the context is provided in the same file, the same block, very close to the usage.

This could work as well: let something: Something = _::Variant;

As with all things type inference: it depends on context, and you have to allow for nuance and for the programmer to make good decisions.

I can make similar arguments for just plain let type inference: let x = f(); tells you nothing about what's going on, so we shouldn't allow type inference there!

Maybe more pertinently, you already don't have to name the type for function calls, e.g. device.create_swap_chain(&surface, &default()). Type inference figures it out for you.

I realize it's almost certainly not your intent, but a logical reading (which may be a bit slippery slope?) of your argument would disallow generic return types that aren't a) determined exclusively from the input types or b) immediately explicitly specified. E.g. if I write let x = iter().collect();, how do I know the type of x? It comes from how I use x.

It's worth noting that API naming is in fact slightly different in an ecosystem that expects enums to be named every time. Rust has the "don't stutter" guideline, and you get CookieHandling::Skip plus call(CookieHandling::Skip). In Swift (though it's been ages) I'd more expect to see CookieHandling.SkipCookies and call(.SkipCookies) (or perhaps call(cookieHandling: .Skip)).

In any case, my position is that if we do want to allow wildcard construction of values, then:

  • It should only be struct literals and enum variants to start with, and
    • (Yes I know that enum variants are kinda actually just associated const/fn/structs, but they feel closer to field names in how intrinsically tied to the type they are, plus they're known to be constructors of Self)
  • It should be allowed in any position where the type is known by type inference (in the same manner that the type has to be known to call a method).

We then just allow the programmer to decide when it makes sense to elide the type, and when it makes sense to spell it out, as is done for let bindings and generic return types (e.g. collect) today. It's even for that purpose (optionally annotating types) that we're working towards allowing let _: impl Trait to work: that has no assistance to type inference, and only serves as a note to the reader that the binding provides and is used exclusively for its implement of Trait.

4 Likes

I feel like this is more of a style restriction than one that would make sense to me as part of the language. We generally try not to have things that work in one part but can't be moved out to a let. (Modulo lifetimes and such, of course.)

And I do want people to be able to use this with things like drive_car("David", .{ fuel: .Petrol, .. }) where the type is uninteresting and can be inferred from the function call. (You can already make a macro today that works as drive_car("David", s!{ fuel: .Petrol }) using Default::default(), so it's not like the type being there is some fundamental restriction of Rust today.)

1 Like