Ideas around anonymous enum types

65 posts later, I feel compelled to reiterate this in stronger terms. This is a crucial motivation problem, and it remains largely unaddressed. I'm pretty convinced we've explored just about all the design space there is, and we're converging on the best possible anon enums design other than "do nothing" or "do enum impl Trait instead", so it's probably time to stop putting off assembling a serious case for "is this better than 'do nothing' or 'do enum impl Trait instead'?"

The only concrete motivating examples I've seen raised in this entire thread are typical error handling ones, which are exactly where enum impl Trait is not only conceptually simpler but also nicer for readability and writability. To make a compelling case here, we need to be talking about examples that aren't error handling and/or that require you to care about listing out or matching on the error variants, yet a regular enum is somehow not good enough. I consider it obvious that some of these cases exist, and that it's trivial to whip up contrived toy cases, but I don't currently believe they're common or painful enough to justify even a tiny language change, much less one this big. If I'm wrong, then we need to see more.

8 Likes

This feature merely codifies the paradigm we already use to handle errors in rust in a way that is familiar with other users. Instead of writing this

enum IntFromFileError {
    ioError(io::Error),
    parseError(ParseIntError),
}

impl fmt::Display for IntFromFileError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
       match self {
           ioError(io) => write!(f, "{}", io);
           parseError(ParseIntError) => write!(f, "{}", ParseIntError);
       }     
    }
}

impl Error for IntFromFileError  {
}

impl From<io::Error> for IntFromFileError {
    fn from(error: io::Error) -> Self {
        IntFromFileError::IoError(error)
    }
}

impl From<ParseIntError> for IntFromFileError {
    fn from(error: io::Error) -> Self {
        IntFromFileError::parseError(error)
    }
}

fn int_from_file() -> Result<i32, IntFromFileError>

You would write

fn int_from_file() -> Result<i32, enum(io::Error, ParseIntError)>

I intend this feature to help rust programmers write less. If it provides an interface familiar with other programmers I don't see that as a bad thing.


I proposed Varadic Anonymous Enums, although I did not include them in the core proposal, they are at the bottom, as I figured they could be a feature added after an initial implementation.

fn int_from_file() -> Result<i32, enum(...Error)>
// same thing
fn int_from_file() -> Result<i32, enum(io::Error, ParseIntError)>

Effectively they act as type inference for the enum based on trait, they would get their pre-monomorphic types based on assignments.

The big reason I left this out of the core proposal is because this would also introduce Trait matching on top of type matching, because varadic enums would probably be non-exhaustive.

match error {
    io : IoError => ...
    parse: ParseIntError => ...
    e : impl Error => ...
}

Would this not handle that pain point though? I proposed this, but little discussion occurred on it, If this is a day one required use case I can move this up into the core proposal.

Edit: I don't see why we couldn't have enum impl Trait syntax either, the proposal specifies how an indexed based enum can have the nice type matching many want, it doesn't exclude the existence of these other features.

1 Like

enum impl Trait and anonymous enums solve the same problem. They let you express things like the following (playground):

fn return_trait() -> impl SomeTrait {
    if ... {
        something()
    } else {
        something_else()
    }
}
// error[E0308]: `if` and `else` have incompatible types

The problem is that impl Trait is existential, so all branches have to return the same concrete type. Furthermore, impl Trait is opaque, so two functions that both return impl SomeTrait are incompatible, even if their concrete type is the same!

The difference between anonymous enums and enum impl Trait is that enum impl Trait is also opaque. This means that you can't match on the concrete types in an enum impl Trait:

fn foo() -> Result<(), Foo> {...}
fn bar() -> Result<(), Bar> {...}

fn handle_both() -> Result<(), enum impl Error> {
    foo()?;
    bar()?;
}

match handle_both() {
    Ok(()) => {}
    Err(Foo { context }) => {} // doesn't work!
    Err(Bar { context }) => {} // doesn't work!
}

Note that this is not just a flaw in the syntax, but a fundamental limitation, because enum impl Trait is opaque.

This is similar to trait objects, although the concrete type of trait objects is unknown to the compiler, whereas the concrete type of opaque types is just unknown to the programmer. The effect is roughly the same. Of course, they have different merits and drawbacks, e.g. opaque types can be optimized better, trait objects are better for compile times, etc.

OTOH, anonymous enums allow you to match on the concrete types. But this can also be seen as a drawback: For example, when used for error handling, every function has to declare all possible error types. When an error type is added to one function, it also has to be added to all transitive dependencies of the function. This is one of the reasons why checked exceptions in Java are so unpopular.

I'm undecided what the best solution is here, but I don't think we should add anonymous enums just for error handling, especially since it suffers from some of the same problems as Java checked exceptions.

1 Like

This is an issue that the Varadic Enum and trait matching would solve though

fn int_from_file() -> Result<i32, enum(...Error)>;
fn propagate() -> Result<i32, enum(...Error)>; // calls int_from_file

match propagate() {
    io : IoError => ...;
    parse: ParseIntError => ...;
    e : impl Error => ...; // catch other errors
}

Maybe I'm missing something because I have this feeling people think that this is a feature that can not be handled by anonymous enums and keep wanting to default back onto unions. But anonymous enums can support all of these use cases.

People seem to want

  • Matching on Type
  • Impl Error when all Variants impl Error
  • Not having to propagate changes in function headers
  • Catch all branch for when new or uninteresting Errors occur

And anonymous enums can handle all these situations if we want them to.

  • Type matching desugars into index matching
  • Product safe traits to implicitly derive traits when variants share a common trait
  • Varadic syntax that desugars into variant types based on assignments
  • Trait matching desugars into index matching

On top of that they also provide

  • Consistent handling of generic cases
  • Ability to differentiate between two of the same type with different life times (index match required)
  • Optimizations based on having opaque type information

Part of this might be my fault. I was thinking of anonymous enums as a building block and would often in discussion talk about them in the abstract, when others (reasonably) didn't care about their use outside of error handling or in edge cases.

The proposal document is sparse, it is merely a technical document that talks in the abstract and served as a summary to what was being discussed for those who didn't want to read all 200 forum posts :slight_smile: I will work on rewriting it, showing it's usage for it's primary purpose, which is Error handling, and add description beyond how it works, but why it works the way it does. The example code above is what Error handling with this proposed anonymous enums would look like (bike shedding excluded).

4 Likes

enum(...Error) is basically just a different syntax for enum impl Error.

Allowing to match on types but always requiring a "catch all" match arm seems like a good solution for this. I hadn't thought of this.

1 Like

While I write up the new proposal, which includes Trait Matching, this brings up a question.

We have said that it is useful if an enum implements all the traits shared by it's variants. The reason for this is because in Error handling, if the user doesn't care about the specific Error, they should still be able to easily log it as the enum would derive display.

// current where enum inherits shared errors
match failable() {
   Ok(o) => ...
   Err(e) => ... // e is an enum in this case, but it derives Error from variants
}

However trait matching also handles that use case.

match failable() {
   Ok(o) => ...
   Err(e: impl Error) => ... // e is some concrete type that impl Error
}

Should the whole 'enums implement all product safe traits that are shared by their variants implicitly` be droped in favor of 'use trait matching'?


I'll exclude it from this next proposal, we can add it back in if it adds value at a later point.

Edit: I can see it being useful in streaming contexts iter.filter().map(). Enums wouldn't support that use case if product safe enums are removed, I'll leave it out for now, it can always be added later.

There's a difference between "supports downcasting to a type" and "a set of known types" (even if the you do the former from the latter).

Even if you don't have specific support for always downcasting from enum impl Trait, you can always enum impl Any to get downcasting.

That's the difference between checked and unchecked exceptions in Java. It's the difference between the documentation and the signature specifying what concrete types can be produced.

It definitely is an interesting idea to extend enum impl Trait with the ability to specify some concrete types which are provided, but if it's always a nonexhaustive enum it still both needs to still be opaque and is firmly in the enum impl Trait feature rather than "anonymous enums."

1 Like

I was thinking about this. I was wondering if enum impl Trait could allow for easier downcasting than Any, and I figured that it would ultimately have the same problems as Any it comes to non 'static types. There hasn't been much discussion about how the anon enum types would play with lifetimes.

Another thing I was thinking about is how we might not need to modify the match syntax if we were explicit about how the type data would work as Any does.

match (x.type_id(), x) => {
    (TypeId::of::<A>, a) => ...,
    (TypeId::of::<B>, b) => ...,
    (_, c) => ...,  
}

Of course this isn't as clean as type annotation pattern matching.

One of my early ideas that I never brought up was that the enum would have a TypeId as the discriminant.

I figured if the discriminant was always of predetermined size then the compiler could make some optimizations. Basically creating two variants of fn -> Result<_, enum>, one that returned errors, and one that wrote to a previously stack allocated one using the TypeId as the discriminant.

// when you wrote
fn failable1() -> Result<_, enum(A)> {
    enum(A)
}
fn failable2() -> Result<_, enum(A, B)>
fn failable3() -> Result<_, enum(A, B, C)>

// rust would also write
fn failable1_no_err(err : &mut enum) -> Result<_, !>{
    err = enum(A); // assign typeid as discriminant and write A
    return Err(_);
}
fn failable2_no_err(err : &mut enum) -> Result<_, !>
fn failable3_no_err(err : &mut enum) -> Result<_, !>

// when you wrote
let result = failable3()

// rust would write
let result;
failable3(&mut result);

If we simply return the enums, you would need to coerce up the stack, in addition this adds to the size of Result, which since errors are suppose to be the exception and not the rule, removing the cost of the err variant as much as possible would be beneficial. It also reduces the need for coercions. you go from type -> enum -> enum -> final enum to just type -> final enum. Of course this only works if you are merely propagating the errors.

Although if you are going through the hassle to generate multiple versions of the same function, you can create different functions for different final destinations. So I think you could still make this optimization with only a byte discriminant.

This could all be wrong by the way, I'm not making a claim that this would actually be an optimization, it's was just my intuition.

What made me abandon the typeid idea though was that in generic contexts the branch logic gets convoluted just like in the union case, and when you can't separate between multiple of the same type you run into lifetime issues. If you keep the types separate you keep the lifetimes separate and you won't implicitly mix them. When you do flatten types it's through a coercion to a different type which can be specified to have a shorter lifetime, the min of parent variants.

The rewritten draft is up, proposal still subject to change: [rendered]

Quick Changes

  1. Coercion was made explicit
  2. Trait matching was added
  3. Type matching had it's desugaring simplified to match trait matching
  4. implicit trait inheritance was removed
  5. enum impl trait was added
2 Likes

FWIW, a variant I haven't seen so far (or may have missed in the deluge) is a hybrid between anonymous enums and enum impl Trait:

enum(Foo, Bar);                           // explicit, closed list of types
enum(Foo, Bar, ..)                        // partially specified, open list of types
enum(Foo, Bar) impl Error + Logging       // trait requirement, auto-impl on the enum
enum(Foo, Bar, ..) impl Error + Logging   // partially specified list, with traits
enum(..) impl Error + Logging             // equivalent of `enum impl Trait`

Other than combining the syntaxes, everything else follows the ideas for anonymous enums and enum impl Trait.

Advantages

  • explicit in which types are guaranteed to be members, if any. Those are the only types that can be explicitly matched on. If the list of types is open (with ..), match blocks require a general _ => ... matching arm.
  • explicit in which traits are required on each member type, and that those traits are auto-implemented (is "lifted" the right term?) for the whole enum
  • general over anonymous enums and enum impl Trait

Disadvantages:

Partially open list of types may make auto-assignments, conversions/coercions even more hairy than they already are - especially with duplicated types, some of which may be opaque.

Further bad example: with enum(&'a Foo, ..) declared, a user intends to try and match on the specified member type to handle that specific case. However, because of a mismatch (e.g., wrong lifetime, or the type is actually &&Foo), the compiler silently generates and uses an opaque entry instead.


Disclaimer: still not a fan of any of the proposals, including this one.

2 Likes

I really like where things are going! Just a quick remark: I think that trait matching should also be extended to work with regular enums, and probably with if let.

The only think I dislike it the enum impl Trait part, but @lordan already suggested nealy what I was going to propose! First of all, I think that it shouldn't be possible to match on the concrete types generated by enum impl Trait since they are not part of the API. At the same time, I really like the idea of non exhaustive anonymous enum.

Using this syntax would make it explicit what a function returns (and what you can explicitly match on), while at the same time make it easy to propagate any new error type (and what only be accessed using trait matching or catch all).

If the .. part of a non-exhaustive anonymous enum cannot be manipulated directly, then those two would semantically be the same:

fn foo() -> enum(..) impl Error + Logging
fn foo() -> impl Error + Logging
1 Like

Something that isn't clear: does enum (..) impl Trait implements Trait itself? I'm not sure it's needed, since we can directly access the variant and match on them, but at the same time it could interact poorly with a generic function that takes a T that implements Trait.

fn create_foo() -> enum (..) impl Display;
fn log<T: Display>(t: T);

fn usage() {
    let foo = create_foo();

    // doesn't compiles since foo is an anonymous enum, and doesn't implements Display itself
    log(foo);

    // compiles, but feels unnecessarily verbose
    match foo {
        foo: Display => log(foo);
    }
}

@lxrec, I think that it was said that traits shouldn't be auto-implemented for anonymous. I don't remember why. For unsafe trait it's obvious, but I forgot if safe traits could be auto-implemented.

My memory is that nobody's come up with a reason why safe traits couldn't be auto-implemented, and that was just confused phrasing in past posts that were using unsafe traits as their examples. Besides, the implementation for any safe trait is trivially delegating to the variants with a match block, so unless we have a massive hole in our safety system, any impl that'd be unsafe to autogenerate ought to be just as unsafe to write manually today.

Unless I've wildly misunderstood something, an enum(..) impl Trait that doesn't even implement Trait would be a completely opaque type you can't do anything at all with besides pass around and drop, so I assume that is not what anyone intended.

One extra thing to consider is that currently dyn Trait explicitly opts out of otherwise applicable blanket implementations. This feels almost like a minor form of specialization. Without the explicit listing of the trait, it would be hard to say what the correct rule is to apply here.

See the example below. I’m not sure yet if there is any situation where this distinction between a blanket implementation and an automatic impl from an anonymous enum would be “relevant” beyond a diagnostic function like std::any::type_name, but I feel like there might be problems from this indeed.

trait Trait {
    fn method(&self) {
        println!("{}", std::any::type_name::<Self>())
    }
}
impl<T: ?Sized> Trait for T {}
trait OtherTrait {}
impl<T: ?Sized> OtherTrait for T {}

fn main() {
    let x = true;
    let y: &bool = &x;
    let z1: &dyn Trait = y;
    let z2: &dyn OtherTrait = y;

    z1.method(); // prints: bool
    z2.method(); // prints: dyn playground::OtherTrait
}

⟶ playground

Thanks!

And I assumed that enum (/* anything */) impl Trait means that all types in anything, including .. implements Trait, so even if you can't match on the concrete type(s) of .., you can bind a variable and call any function/method that take a Trait using this bind.

trait Trait { fn stuff(self) { /* ... */ } }
struct T;
struct U;
impl Trait for T {}
impl Trait for U {}

fn foo() -> enum (T, ..) impl Trait {
    if rand() % 2 == 0 {
         T as enum
    } else {
         U as enum // the concrete type of the return type is `enum (T, U) impl Trait`, but the only variant visible externaly by a programmer is T. U is opaque to other part of the code, but not the compiler.
    }
}
fn usage() {
    match foo() {
        t: T => t.stuff(),
        u: U => u.stuff(), // this line doesn't compiles since `..` is opaque
        u: impl Trait => u.stuff(), this lines compile, and will be monomorphised for every type hidden by `..` (only `U` here)
    }
}
1 Like

Yep, you must not “override” any blanket implementations for Any, otherwise this would be a problem:

use std::any::Any;

fn main() {
    let x: enum(i32, bool) = 42;
    // ^^^ implements Any based on dispatch
    let y: &dyn Any = &x;
    // ^^^ unsize coercion: enum(i32, bool) is sized and implements Any
    let z: &i32 = y.downcast_ref::<i32>().unwrap()
    // ^^^ created internally by casting y, so we get an invalid reference (UB)
    // ^^^ succeeds since y.type_id() gives is TypeId of `i32` by dispatch
    // see: https://doc.rust-lang.org/src/core/any.rs.html#195-204
}

I think that is a good idea.


I took out the auto trait implementation. The reason it was wanted in the first place was so you could treat the enum as a generic Error when you didn't care about a specific error. But with trait matching this use case is handled. It can always be added back in, but in my opinion the primary use case for it is now handled by trait matching.

match fail() {
    Ok(_) => ...,
    Err(e) => log!(e); // e is an enum
}

match fail() {
    Ok(_) => ...,
    Err(e: impl Error) => log!(e); // e is a concrete type that implements Error
}

I have been under the assumption that the type is transparent to the caller and documentation. So you could still write specific type matches or trait matches on it.

If I was writing a match on a function that returned enum impl error, and I noticed I was getting http::Errors and wanted to implement retry logic. It would be frustrating to have the compiler say, "I know it can be an http::Error, you know it can be an http::Error, but no I wont let you catch the http::Error".

For that reason I think it should be transparent to the callers and documentation.


Y is a reference to an enum(i32, bool), why would the typeId be equal to i32 and not it's actual type?

Edit: Oh wait I see what you mean, you were talking about overriding a trait implementation my bad.

If it's not visible in the signature, it shouldn't be transparent to the caller. In Rust, the equivalent of C++'s decltype(auto) doesn't exists because this would require global analysis, and non-local changes can have a wider impact than expected.

fn foo() -> enum(..) impl Trait { /* ... */ return A as enum }
fn bar() -> enum(..) impl Trait { foo() }
fn baz() {
    match bar() {
        a: A => ..., // Is this line valid? You need to look transitively to the *implementation* of bar() then baz() to answer this question
        _ => ...,
    }
}

If enum(..) are transparent, then changes to the implementation of foo() (like returning B as enum instead of A as enum) could have an impact on baz(), even if both A and B implements Trait, and that there is no direct relation between foo() and baz().

I agree that the main use-case is handled, and I wouldn't be opposed to ship-it as it (if I had any veto power :crazy_face:!) . I just feel that the following snippet is a bit verbose. If safe trait can be auto-implemented, I think it should be part of this proposal to remove this unnecessary verbosity.