Auto infer namespaces on struct and enum instantiations

There's two levels of readability involved here.

  1. I want a high-level overview of what the code is doing. Here, the takes_enum(Enum::Variant(EnumVariant { .. })) is line noise compared to takes_enum(.Variant(_ { .. })), because takes_enum is enough context to infer the enum argument, and EnumVariant is only used for the definition of that one enum variant.

  2. I want to understand the exact types involved, so that I can locate and edit them as necessary, and trace the exact execution path of the code. Here, having the types spelled out helps.

But I think we should career to 1 over 2. Specifically, because we already have type inference. What is the type of eggs in let eggs = foo_and_then_bar(&mut spam);? You don't know until you check the signature of foo_and_then_bar.

It doesn't hurt the explicitness of Rust to extend inference to the types of arguments. Explicit is not noisy, burdensome, not even local, after all. In addition, Rust does not have function overloading, so it's always clear from the function in question what the type of the argument is (otherwise type inference wouldn't kick in in the first place).

It comes down to that if you elide too much information, your code is harder to read. It's not the language's fault if you do that, though. The inference/elision discussed in these proposals is clearly beneficial in some notable cases (the biggest one being giving us effective kwargs for free and in a backwards compatible way), and when names are too generic, continue to spell out the paths/types/etc.

Does a type in the following example add any useful information to the code useful to understanding what it does? I would argue, no, it doesn't, unless your specific purpose is to edit the type, in which case you'll want to edit the function as well, so jumping to the function first doesn't even add any extra steps.

let call = RemoteCall::from_config(_ {
    target: "https://example.com/api",
    fallback: "https://example.com/bpi",
    timeout: Duration::from_secs(10),
    ..
})?;
let result = call.submit().await?;
The best typed without new inference placeholders alternative, without adding Ng builder types
let call: RemoteCall = remote_call::Config {
    target: "https://example.com/api",
    fallback: "https://example.com/bpi",
    timeout: Duration::from_secs(10),
    ..
}.try_into()?;
let result = call.submit().await()?;

Not horrible, but also lacks the power of the dot as well as doc discovery ("I'm on the page for RemoteCall, but how do I make one?").

4 Likes

That's something that could be fixed in the docs. I would say, it should be, because Rust's docs are awful in a number of ways: chief among them is the inability to build a prioritized and cohesive picture of how to use an object or trait, but it is also difficult to hide irrelevant details and show important ones. For instance, I went here to implement this trait, but the declaration is hidden by default, (sometimes doesn't even include the full definition), and I'm stuck staring at a worthless example of how to use it if I had already had an implementation instead. Traits should have an example implementation 'template', impls should be grouped, the sidebar should have better grouping, and I shouldn't have to click "unhide" multiple times to see something new, and anything under a doc header should probably be hidden by default unless there is nothing above it.

Traits are used much more often then they're implemented, of course the default page layout is going to be biased for that. Functions on the page are presented in the order they are in the source file (exception: required trait methods are hoisted above provided methods), and (for structs) grouped by what impl block they're in, complete with the comment on said impl block.

Rust's generated documentation is miles above what any scripting language has (basically, just semi structured ways to describe your API in prose), and the only thing I could argue that javadoc/doxygen do better is putting a summary of all the methods' signatures front and center, but that's partly because that's most of the useful information you'll get out of a javadoc/doxygen generated documentation.

I fail to see how both of these things can be true simultaneously. Either it's collapsed by default and you need to unhide it, or you don't need to unhide it, it's uncollapsed.

Either way, this isn't the thread to discuss rustdoc. If you have concrete constructive proposals on how to improve rustdoc and/or concrete stdlib documentation, please do open a new thread, though. As a community we're highly invested in making our docs as good as they can be. (Just do keep in mind that rustdoc docs are API reference docs, and their design caters to that over a more guide style book like is common for JS libs on e.g. readthedocs.)

1 Like

See here:

There are two [-] expansion boxes. Clicking on the first reveals nothing but the other. One shouldn't have to expand two different frames to see only a single line of text.

3 Likes

Yes, I'd love this. Swift has shown that this can be used to make very nice APIs.

6 Likes

To echo @skysch's reply, I read quite a lot of code on websites (mainly when reviewing code, but also occasionally found through searching). There are no IDE facilities in such places, so expecting IDE support for readability is not something I'd like to see at least.

6 Likes

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.