Enum path inference with `_Variant` syntax

I did like the idea (which might have been yours) that inference is already essentially allowed in irrefutable places, so that might be one way to make progress on this -- but that wouldn't apply for enums at all, so probably doesn't help OP.

Come to think of it, we could at least improve the diagnostic here if people try it: Suggest the correct name if `_` is used in a struct literal · Issue #98282 · rust-lang/rust · GitHub

1 Like

I'm going to be writing an RFC for path elision in patterns in the future: Name elision

It's not at the top of my list, but it is on it!

2 Likes

How about some kind of alias/shortcut attribute on variants?

enum AllowUnstable {
   #[alias = "Unstable"]
   Yes,
   #[alias = "Stable"]
   No,
}

which would then enable use of _::Stable and _::Unstable.

That's an interesting thought. I guess that would just be for the .Stable/.Unstable?

It's hard for me to weigh the extra language surface of that vs just doing something like this, though

pub use AllowUnstable::{Yes as Unstable, No as Stable};

(Perhaps in a prelude-ish module or something.)

1 Like

I still haven't seen anyone explain to me why this _::Stable, _::Unstable is somehow better than this

use AllowUnstable as U;

U::Stable, U::Unstable

The cognitive cost of a single rename at the start of the function (or maybe even a module) is significantly less than trying to infer the missing parts of the path in your head.

It's not like anyone is likely to write enum-variant-polymorphic code which has explicit names of variants, but the enum types themselves are to be inferred, potentially differently depending on the context.

4 Likes

The difference is availability.

With _::Yes, you know that this is the standard way of eliding the enum name, and that this is the Yes variant of the function argument enum type.

Alternatively generalized a bit, but still using the exact same logic:

With _::Yes you know that this is the standard way of eliding the function argument type, and that this is the Yes associated constants of the function argument type.

Worth making explicit here: this also requires that the function argument type is Enum and not some inference variable like impl Into<Enum>... this would make generalizing function argument types into a more breaking change than it is currently, but still an allowed inference failure.

For the same reason, unless its macro emitted code, this precludes enum polymorphic code.

With U::Yes, you have the Yes associated constant of some namespace U. This could be an alias of the function argument type, or it could be an unrelated type that has an associated constant of the right type, or it could be a namespace with a const or static of the right type. You can assume that it's an alias for the namespace of usable enum variants, but there's no guarantee of this. Additionally, the renaming import can be arbitrarily far away.

Add to this that IDE completions can transparently support completion at _::{|} or Enum::{|} even when Enum is not in scope, but will have no idea for E::{|} without a renaming import already in scope.

The reading is functionally equivalent between E::Yes and _::Yes (because in a reasonable codebase, you can indeed reasonably assume that it's an alias for the enum). The primary difference is in the authoring experience AIUI (and that may lessen the rationale for the feature).

This is because you need to know the name and path of the enum, and type it out in full at least once. For enums that are used only once in a file (which I argue could be common), it doesn't do anything helpful.

init(.Turbo, .Encabulator, .Prefabulated)

gives a much nicer and shorter API than:

use enums::TurboEnum as T;
use enums::EncabulatorEnum as E;
use enums::PrefabEnum as P;
init(T::Enable, E::Enable, P::Enable)

Name lookup based on context is as useful as having method names. It's much nicer to have iter.map(x) instead of use std::iter::Iterator as I; I::map(iter, x).

4 Likes

Well, that's debatable. Personally I find

init(TurboEnum::Enable, EncabulatorEnum::Enable, PrefabEnum::Enable)

to be quite reasonable and nice. Although I can't help but wonder why 3 supposedly very different and unrelated enums all have the same Enable, Disabled variants. In realistic APIs those enums would also be much more reasonable, and if they carry some data, then the enum name wouldn't be the biggest source of verbosity anyway.

Why does it matter? You need a value of type U, you get a value of type U. You can't supply a different value due to typechecking, and there may be many different ways to construct that value. Most of the time you wouldn't even pass raw enum variants around, you would have a variant returned by some function and bound to some variable, and you can name that variable whatever you like.

I also wouldn't want to make associated consts even more second-class to enum variants than they already are. The lack of variant aliases is very constraining in some context (e.g. if you're translating some C API, which has no qualms with several enum items with the same numeric value). At least currently we have a reasonable way to side-step it with associated consts (although they don't support all the ways a variant may be used). With special syntax for native variants, associated consts would stick out in code like a sore thumb.

I'm also super wary of the ways that such added implicit typing could interact with other implicit features of the language. E.g. like your example with Into<Enum>, how would all kinds of generic parameters interact with that feature? Could it cause some ambiguous interaction with trait resolution? _::Foo.bar() --- which bar method is called and where is it defined?

Hopefully, all such potential ambiguities would just result in a compile error, but at that point I expect half of the advantages of the short syntax to evaporate.

1 Like

These proposals all depend on inference, so that just wouldn't work, the same way that 1.0.tan() doesn't work and similar.

1 Like

What's certain is that we need something to disambiguate between such enum variants. That's why I've suggested that we could append enum name after variant name so there would be _YesAllowUnstable, _YesCapturing and so on. I don't think that we could invent any better disambiguator than an enum name.

The idea which @kornel suggested could be interesting:

I even thought previously about a similar alias but common for every enum variant, which was supposed to be appended as a suffix to a variant name e.g. #[alias = "Unstable"] enum AllowUnstable { Yes, No } then enables _::YesUnstable and _::NoUnstable.

But despite that looked simpler than AllowUnstable::Yes and AllowUnstable::No such feature had been still hard to justify: most likely in most of cases alias would be just a substring of enum's name — a few symbols shorter but producing a level of indirection without any significant gain.

That said, perhaps we don't need every enum variant to have its own level of indirection either.

Update on syntax

Recently I've revised the _YesAllowUnstable to make it have an explict boundary between enum variant and enum name. The idea has been that the _ (or whathever other inference symbol we choose) doesn't need to be in prefix position — it's possible to place it between enum variant and enum name.

The initial thought was to add something like Yes_AllowUnstable but this, unsurprisingly, was later dismissed.

So, next thought was to merge it with type ascription e.g. Yes: AllowUnstable and this turned to be promising. For example, this is how part of topic example looks with it:

let should_visit_node_again = match item.kind {
	// For use/extern crate items, skip rewriting attributes but check for a skip attribute.
	Use(..) | ExternCrate(_): ItemKind => {
		if contains_skip(attrs) {
			self.push_skipped_with_span(attrs.as_slice(), item.span(), item.span());
			false
		} else {
			true
		}
	}
	// Module is inline, in this case we treat it like any other item.
	_ if !is_mod_decl(item) => {
		if self.visit_attrs(&item.attrs, Outer: AttrStyle) {
			self.push_skipped_with_span(item.attrs.as_slice(), item.span(), item.span());
			false
		} else {
			true
		}
	}
	// Module is not inline, but should be skipped.
	Mod(..): ItemKind if contains_skip(&item.attrs) => false,
	// Module is not inline and should not be skipped. We want
	// to process only the attributes in the current file.
	Mod(..): ItemKind => {
		filtered_attrs = filter_inline_attrs(&item.attrs, item.span());
		// Assert because if we should skip it should be caught by
		// the above case.
		assert!(!self.visit_attrs(&filtered_attrs, Outer: AttrStyle));
		attrs = &filtered_attrs;
		true
	}
	_ => {
		if self.visit_attrs(&item.attrs, Outer: AttrStyle) {
			self.push_skipped_with_span(item.attrs.as_slice(), item.span(), item.span());
			false
		} else {
			true
		}
	}
};

Especially interesting is that we can "ascribe" multiple variants at once, since that seems to be quite viable alternative to proposals like path::Enum::{VariantA | VariantB}.

But still problematic is that we would utilize the X: Y syntax which might be also required for an actual type ascription or for named arguments or for anything else. Moreover, that syntax turned to look ambiguous in struct literals which is a really big deal. And I either didn't liked the space-gap between enum variant and enum name which prevents them being perceived as a whole.


Ultimately I've ended up with Variant.EnumName syntax. This may be either not perfect, although currently I'm convinced that it's the best solution that we can have.

Below is how the same example looks with it:

let should_visit_node_again = match item.kind {
	// For use/extern crate items, skip rewriting attributes but check for a skip attribute.
	Use(..) | ExternCrate(_).ItemKind => {
		if contains_skip(attrs) {
			self.push_skipped_with_span(attrs.as_slice(), item.span(), item.span());
			false
		} else {
			true
		}
	}
	// Module is inline, in this case we treat it like any other item.
	_ if !is_mod_decl(item) => {
		if self.visit_attrs(&item.attrs, Outer.AttrStyle) {
			self.push_skipped_with_span(item.attrs.as_slice(), item.span(), item.span());
			false
		} else {
			true
		}
	}
	// Module is not inline, but should be skipped.
	Mod(..).ItemKind if contains_skip(&item.attrs) => false,
	// Module is not inline and should not be skipped. We want
	// to process only the attributes in the current file.
	Mod(..).ItemKind => {
		filtered_attrs = filter_inline_attrs(&item.attrs, item.span());
		// Assert because if we should skip it should be caught by
		// the above case.
		assert!(!self.visit_attrs(&filtered_attrs, Outer.AttrStyle));
		attrs = &filtered_attrs;
		true
	}
	_ => {
		if self.visit_attrs(&item.attrs, Outer.AttrStyle) {
			self.push_skipped_with_span(item.attrs.as_slice(), item.span(), item.span());
			false
		} else {
			true
		}
	}
};

Vs original:

let should_visit_node_again = match item.kind {
	// For use/extern crate items, skip rewriting attributes but check for a skip attribute.
	ast::ItemKind::Use(..) | ast::ItemKind::ExternCrate(_) => {
		if contains_skip(attrs) {
			self.push_skipped_with_span(attrs.as_slice(), item.span(), item.span());
			false
		} else {
			true
		}
	}
	// Module is inline, in this case we treat it like any other item.
	_ if !is_mod_decl(item) => {
		if self.visit_attrs(&item.attrs, AttrStyle::Outer) {
			self.push_skipped_with_span(item.attrs.as_slice(), item.span(), item.span());
			false
		} else {
			true
		}
	}
	// Module is not inline, but should be skipped.
	ast::ItemKind::Mod(..) if contains_skip(&item.attrs) => false,
	// Module is not inline and should not be skipped. We want
	// to process only the attributes in the current file.
	ast::ItemKind::Mod(..) => {
		filtered_attrs = filter_inline_attrs(&item.attrs, item.span());
		// Assert because if we should skip it should be caught by
		// the above case.
		assert!(!self.visit_attrs(&filtered_attrs, AttrStyle::Outer));
		attrs = &filtered_attrs;
		true
	}
	_ => {
		if self.visit_attrs(&item.attrs, AttrStyle::Outer) {
			self.push_skipped_with_span(item.attrs.as_slice(), item.span(), item.span());
			false
		} else {
			true
		}
	}
};

Since in Rust things like CamelCase.CamelCase are very uncommon I don't think that it looks ambiguous with anything. But it either doesn't look "motley" amid other code (while :: in enum paths IMO looks). And because this syntax would rely on type inference I don't think that compiler would have a big trouble to deal with it.

Perhaps only the reversed Variant.EnumName order which in every other popular language always has been EnumName.Variant would be strange for some users until they see benefit.

What I value is the emphasis. For example when I see let allow_unstable = I expect that Yes or No would follow rather than stability::AllowUnstable or just the same AllowUnstable; but I either don't expect that Yes or No would come out of nowhere e.g. from glob-use in between of a bunch of similar statements at the very top of file. The let allow_unstable = Yes.AllowUnstable; seems to create the right emphasis.

To be fair, indeed something like let allow_unstable = _YesAllowUnstable; may create a wrong emphasis and it may look like I've just removed some symbols in an attempt to create "something" new.


Both variations may look like that. Unfortunately, sometimes only other people may point that.

I don't quite follow - what makes Outer.AttrStyle so much better than AttrStyle::Outer that it justifies the syntax churn?

2 Likes

Yeah, the point is to avoid writing enum's name at all in places where it's obvious from the context and would be needlessly repetitive. Instead of:

button.border_style(BorderStyle::Dotted);
button.border_style(Dotted.BorderStyle);

write:

button.border_style(_::Dotted);
button.border_style(.Dotted);
2 Likes

Personally, I don't see the value in that one. (And it's probably a breaking change, because let Variant = something_with_a_EnumName_field; is legal today. The prefix-dot form didn't have that problem.)

The big win for me of the general feature is that sometimes the type is contextually-obvious enough to not need to write it, but if it needs to be written out, then having it after instead of before seems not worth the effort, when EnumName::Variant could just be made to work without a use, like was previously mentioned:

2 Likes

In your proposal, when is it required to use Variant.EnumName instead of Variant? Your example's first pattern is Use(..) | ExternCrate(_).ItemKind.

If the answer is “always, that was a typo” — how is this meaningfully better (rather than just aesthetically) than Enum::Variant?

If the answer is “when {something else with the same name as the variant name} exists” — name in what scope? Local lookup — how is this better than Enum::Variant (as above)? Some larger scope — now new names outside the local scope impact the code's well-formedness?

...

Is the enum name required to be in scope? Either way — how is this better than Enum::Variant (as above)?

All paths, whether namespace::path or field.path go from general to specific. This would be the one only occurrence of “name (in context)” rather than “(in context) name”. This objectively makes the syntax a worse fit for Rust than just flipping the direction.

1 Like

It does exactly the same job as _::Outer but differently: it moves enum name into position where it's less intrusive rather than completely eliding it. In my opinion that difference is necessary in order to avoid the problem which @scottmcm mentioned:

That said, it's just an attempt to create a next iteration of _::Variant. I don't know whether in the end it would be possible to motivate such addition to the language itself. This is complicated task, at least for me.

How can you tell when the type is contextually obvious? And do we really have a lot of situations like that in Rust to claim that skipping writing types is a big win?


When there's no use EnumName::Variant or use EnumName::* at the top. In most of cases Variant.EnumName seems to be the right choice, and when we really though that EnumName is redundant in code we can import it into scope. IMO, logic is the same as when we decide whether to use EnumName::Variant instead of Variant but with Variant.EnumName the first choice could become more attractive.

This example shows that we can type enum name once per OR pattern in order to avoid repetitions. It seems to look better than something like Enum::{ VariantA | VariantB } and I think this makes it better than Enum::Variant.

Why does it matter? In most of cases I can read code with Variant.EnumName more effectively than code with EnumName::Variant. It has the same direction as value: Type, 0i32, and expr: Ascription, and I don't care that all paths are directed oppositely. Especially because the purpose of this syntax is to hide paths.

Well, the whole existence of inference is about not writing types, as is method syntax.

It's interesting that it's .map(|x| x.ok()), but .map(Result::ok). If _::Variant works, though, should .map(_::ok) work too? I don't know.

Here's a previous post with a bunch of examples of places where inference already allows APIs that can do things without saying the types involved:

But the situation that seems most clear to me is in struct literals.

Options {
    cookie_handling: CookieHandling::Skip,
    ..
}

That's pretty repetitive. Having just

```rust
Options {
    cookie_handling: .Skip,
    ..
}

seems quite reasonable to me.

2 Likes

Wait, that's what that was?

So with or patterns $pat | $pat, the meaning of one pattern depends on the other.

Yikes.

Method syntax is different because it guaranteedly has receiver expression that usually allows to guess the type. For example foo.bar() communicates nearly the same amount of type information as Foo::bar(foo) and, what matters, it's clear that in most of cases using the former would be redundant. Moreover, the thing on which methods are called has a bigger chance of already being "explicitly typed" e.g. it could be a method argument, new instance of enum/struct, or return value of a generic method like .collect() or .into().

I'd also say that method syntax is either a counterexample, since lack of explicit types allows to easily write confusing code with it. For example long method chains usually are an antipattern and already a lot of discipline is required to know where to introduce a temporary variable, how to name it, etc.

Perhaps the situation when we introduce enum variable could be added to that:

let cookie_handling = .Skip;
...
Options {
    cookie_handling,
    ...
}

But nothing else comes into my mind.

BTW, the proposed syntax also goes from general to specific. For example in Yes.AllowUnstable the Yes word is more general than AllowUnstable, or in Enabled.Cookies the Enabled word is more general than Cookies and so on. This doesn't match the hierarchy in which things are declared but IMO it's not that important.

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