Pre-RFC: Enum variants through type aliases

In rust PR #31179, @jseyfried and @petrochenkov discuss the use of enum variants through type aliases, which currently doesn’t work. It was closed pending an RFC since there were a number of unresolved use cases. Here’s an attempt at creating such an RFC; I’m still fairly new at this, so any feedback is appreciated.

  • Feature Name: type_alias_enum_variants
  • Start Date: 2017-12-28
  • RFC PR: (leave this empty)
  • Rust Issue: (leave this empty)

Summary

This RFC proposes to allow access to enum variants through type aliases. This enables better abstraction/information hiding by encapsulating enums in aliases without having to create another enum type and requiring the conversion from and into the “alias” enum.

Motivation

While type aliases provide a useful means of encapsulating a type definition in order to hide implementation details or provide a more ergonomic API, the substitution principle currently falls down in the face of enum variants. It’s reasonable to expect that a type alias can fully replace the original type specification, and so the lack of working support for aliased enum variants represents an ergonomic gap in the language/type system. This can be useful in exposing an interface from a dependency to library users while “hiding” the exact implementation details. There’s at least some evidence that people have asked about this capability before.

Since Self also works as an alias, this should also enable the use of Self in more places.

Guide-level explanation

In general, the simple explanation here is that type aliases can be used in more places where you currently have to go through the original type definition, as it relates to enum variants. As much as possible, enum variants should work as if the original type was specified rather than the alias. This should make type aliases easier to learn than before, because there are fewer exceptions to their applicability.

enum Foo {
    Bar(i32),
    Baz { i: i32 },
}

type Alias = Foo;

fn main() {
    let t = Alias::Bar(0);
    let t = Alias::Baz { i: 0 };
    match t {
        Alias::Bar(_i) => {}
        Alias::Baz { i: _i } => {}
    }
}

Reference-level explanation

If a path refers into an alias, the behavior for enum variants should be as if the alias was substituted with the original type. Here are some examples of the new behavior in edge cases:

type Alias<T> = Option<T>;

Option::<u8>::None // Currently prohibited
Option::None::<u8> // Ok
Alias::<u8>::None // Will also be prohibited
Alias::None::<u8> // Will be ok

Drawbacks

We should not do this if the edge cases make the implemented behavior too complex or surprising to reason about the alias substitution.

Rationale and alternatives

This design seems like straightforward extension of what type aliases are supposed to be for. In that sense, the main alternative seems to be to do nothing. Currently, there are two ways to work around this:

  1. Require the user to implement wrapper enums instead of using aliases. This hides more information, so it may provide more API stability. On the other hand, it also mandates boxing and unboxing which has a run-time performance cost; and API stability is already up to the user in most other cases.

  2. Renaming of types via use statements. This provides a good solution in the case where there are no type variables that you want to fill in as part of the alias, but filling in variables is part of the motivating use case for having aliases.

As such, not implementing aliased enum variants this makes it harder to encapsulate or hide parts of an API.

Unresolved questions

From @petrochenkov’s questions in the PR discussion:

Alias::<u8>::None::<u8> // Not sure where/how this would be used
Alias::<u8>::None::<u16> // Not sure where/how this would be used
Type::AssociatedType::Variant // How is this different from other cases?
4 Likes

What’s the reason that this is not already possible? I’m not familiar with the compiler source, but naïvely I’d imagine that it would take some extra effort to disallow this special case, so I’d suspect there’s a reason for it.

What exactly, variants through type aliases (Alias::Variant) or generic arguments in certain positions (Enum::<u8>::Variant)?

Both, I wonder why aliases don’t behave exactly like the original type. Seems the easiest solution to me, both to implement and to explain it.

But then again, I also don’t understand the reason why there’s a distinction between type aliases and reexporting a type, with different syntax and slightly different semantics.

Let’s start from the beginning.
Suppose we have a generic enum

enum E<T> {
    V,
    /* other variants */
}

// Make all variants available in "naked" form
use E::*;

and we want to create an example of variant V.

let v1 = E::V;
let v2 = V;

We get something like ERROR: cannot infer generic parameter `T` in both cases.

Indeed, we need to provide a generic argument for T, which is a generic parameter on enum, not variant. Variants themselves don’t have generic parameters.

For the “qualified” variant path it seems obvious we should write

let v1 = E::<u8>::V; // OK, we supplied arguments for the *enum*

but what should we do for the “naked” variant let v2 = V?
The solution is kinda hacky, but let’s specify enum’s arguments right on the variant, even if they don’t belong to it, because it’s the only possible place - there’s nothing except for V in the path.

let v2 = V::<u8>; // OK, but kinda hacky

So, what is the problem? Why E::<u8>::V has to be written in the hacky way as well (E::V::<u8>)?
The answer: “naked” variants entered the language first, “qualified” enums were added only few months before 1.0 and released in 1.0.0-alpha. There was strong focus on quickly achieving stability back then, so qualified enums inherited the way of supplying generic arguments from naked enums, and it stuck.


Now, let’s see how aliases change the picture.

  • First, we still need to provide arguments for enum, and only for enum.
  • Second, we can’t merge two lists of arguments (E::<A>::V::<B>), that doesn’t make much sense, generic arguments are positional, not named. (Okay, we can take both lists and unify them with each other accepting E::<u8>::V::<u8> and rejecting E::<u8>::V::<u16>, but why on earth would we do that if there are simpler options.)
  • Third, Alias in Alias::V can already contain parameter substitutions in it implicitly (those specified in type).
    type Alias = E<A>;
    let v = Alias::V<B>;
    
    so by accepting Alias::V<B> we ought to merge two lists of arguments, but we can’t merge two lists of arguments.

The only possible remaining design follows from these listed constraints:

// Suppose `Anything` can be both an enum or an alias, `Alias` can only be an alias, `NonAlias` can only be an enum.

// Canonical forms
let v = Anything::<A>::Variant; // OK, the canonical qualified form. Enum's generic arguments are actually supplied to the enum.
let v = Variant::<A>; // OK, the canonical naked form, out of necessity, `Variant` is the single available place to put parameters.

// Legacy forms
let v = NonAlias::V::<A>; // Legacy OK, can't break code.

// Everything else is an error
let v = Anything::<A>::V::<B>; // ERROR, can't merge substs
let v = Alias::V::<A>; // ERROR, can't merge substs
1 Like

TLDR: Both reexports and enum variant resolution work at name level and can be performed earlier in the compiler pipeline (enums are basically modules at that stage).

Resolving Alias::Variant requires delaying parts of variant resolution until a later stage - type checking, Alias::Variant have to use roughly the same resolution scheme as any associated item, like a method or an associated type. For variants we can afford this delay, that's why implementing Alias::Variant is in the plans, but for import we can't afford it, so making imports and type aliases identical is not a goal.

Thanks a lot for the detailed answer.

Is that because of the additional capabilities of type aliases that go beyond plain name aliasing, i.e. parameter substitutions in the type declarations?

Type aliases can refer to arbitrarily complex types (including associated types), so we should be able to break through all of them to find out whether Alias is actually an enum and has variant with necessary name, we can't do it during early name resolution.

Thank you for that explanation! So the question is what the syntax looks like for providing the type arguments when specifying variants. In your design, these two things kind of suck:

// Inconsistency between original type and alias when parametrizing type-qualified variants
let v = NonAlias::V::<A>; // Allowed because legacy
let v = Alias::V::<A>; // Not allowed because merging

// Inconsistency between type-qualified and naked variants
let v = Variant::<A>; // Allowed, no other place to put arguments
let v = Alias::V::<A>; // Not allowed because merging

Here, by merging you mean that we have to merge the type arguments to the alias with the type arguments specified as part of the alias, is that correct? I wonder if that’s true, though, because I was thinking that type aliases have to redefine their free variables, so that merging would be fairly straightforward:

type Alias<T> = Result<T, MyError>;
let v = Alias::Ok::<u8>; // Unambiguous meaning is Result<u8, MyError>::Ok

Or have I misunderstood aliases? (This is where I really miss better reference-level documentation.)

@djc

type Alias<T> = Result<T, MyError>;
let v = Alias::Ok::<u8>; // Unambiguous meaning is Result<u8, MyError>::Ok

I see what you mean, this is not merging, here we are supplying arguments for the alias, not for the enum, but put them on the variant.

Alias::Ok::<u8>; // OK, `Alias` expects 1 argument
Alias::Ok::<u8, u16>; // ERROR, `Alias` expects 1 argument
// But
Result::Ok::<u8, u16>; // OK, `Result` expects 2 arguments

So, with this scheme

  • If the previous segment is a type (alias or enum, doesn’t matter), the variant segment simply “gifts” its arguments to that previous segment.
  • If the previous segment is not a type (module, for example), the variant segment treats the arguments as arguments for the variant’s enum.
  • Previous::<u8>::Variant and Previous::<u8>::Variant::<u16> keep being errors.

Yep, looks like a valid scheme as well.
Although, with this scheme we are still passing arguments to the wrong segment and missing the opportunity to rectify pre-1.0 mistakes.

1 Like

Yes, your proposed rules about interpreting variant arguments sound right to me. Do you think these rules also sufficiently cover the case of variants of enum aliases in associated type position?

I understand what you’re saying that Result::<u8, u16>::Ok would make more sense than Result::Ok::<u8, u16>, but I think that’s essentially orthogonal to this RFC, so it should probably be a proposal on its own.

1 Like

That's how I would have done it intuitively and also the reason why I didn't understand the difficulties. Providing the arguments for the original type on the variants when actually using the alias would be breaking the abstraction IMO.

I agree with @djc that the position of the the arguments is an orthogonal question, unless I've misunderstood something.

So I was hoping someone from the lang team might provide some guidance on how to move on this. Any suggestions? Should I just create an RFC PR now?

1 Like

I’ve submitted a PR for this RFC: https://github.com/rust-lang/rfcs/pull/2338.

1 Like

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