Ideas around anonymous enum types

For me that could be a reason to support some variant of this set of proposals, even though I still believe that these proposals complicate the language and raise significant teachability issues. Thus I would like to see more about how such an "extension to encode bounds" could work. Without such justifying details I still come down on the side of :-1: on these proposals.

The whole point of indexed sum types rather than type union types is so that foo::<u32> returns enum(u32, u32) and not a canonicalized form of it.

We could have a lint against producing noncanonical indexed enums that aren't immediately canonicalized, but it would be incorrect to somewhat arbitrarily forbid certain combinations of type definition features.

2 Likes

I'm not entirely sure what you mean by "encode bounds should intersect". Do you just mean?

let a : enum(A, B, C) = ...
let b : enum(B, C, D) = ...

match (a, b) {
    (a, b) : (enum(B, C), enum(B, C)) => ...
    _ => ...,
}

Edit: Actually the above match wouldn't work (in the anonymous enum proposal) because you can't coerce into less normal types. You could use a macro to get that behavior or you could just do the following:

let a : enum(A, B, C) = ...;
let b : enum(B, C, D) = ...;

let a : enum(B,C) = a.try_into()?;
let b : enum(B,C) = a.try_into()?;

Teaching anonymous enums

I would like to talk about the teachability of this idea, because I think it is important but has admittedly not been a priority for me as I wanted to get a concrete idea of what the implementation was first.

void int openFileParseInt() throws FileNotFoundException, ParseException {}

try {
    int i = openFileParseInt();
    ...
} catch (FileNotFoundException f) {
    ...
} catch (ParseException p) {
    ...
}

Many languages (above java) use Exceptions for error handling, and I think many intro programmers struggle with Error handeling in rust because it is just so different than how other languages do it. Especially java and python. Programmers are use to throwing errors and catching them up the call stack.

But rust doesn't operate like that, a function only ever returns 1 Error and if you want to have a process that could fail in more than one way you have to

  • Panic
  • Handle the failure at the fail location
  • Create a new enum, Impl the Display Trait, Impl the Error Trait, Ideally implement the From trait for every possible variation.
  • Box the trait into a trait object and downcast ref it at the call sight.

In my opinion none of the above are ideal. If I am working on a production product with a deadline I don't want to

  • have it crash when it fails
  • Write error handling code everywhere an error could occur
  • Spend time creating a boilerplate Error type and maintaining it every time changes occurs
  • Have to deal with downcasts every time I care about an error, also not available without alloc

I work in java exclusively at my day job, and while I have opinions about java, I do enjoy the ease of passing the buck up the call stack. Microservice request failed? Was it and http authorization error, do I need to revalidate my authorization, was it an sql error, do I need to ask the metadata server to check if my connection information is up to date. That's an easy thing to do in java but admittedly annoying to write in rust.

The big reason the details on Anonymous Enums can be a bit complicated, is that they are trying to allow familiar syntax.

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

match open_file_parse_int() {
    Ok(i: i32) => ...,
    Err(f: io::Error) => ...,
    Err(p: ParseIntError) => ...,
}

This is just a try catch, try the function, if it catches a file not found exception the io::Error branch will be called, if it catches a ParseIntError that branch will be called.

The reasoning for the Type matching is because it is familiar to most programmers as a try catch.

This is also true for the trait inheritance. It is very common in other languages to simply not care which Exception occured.

try {
    int i = openFileParseInt();
    ...
} catch (Exception e) {
    ...
} 

Speaking for me personally at my day job, this syntax is especially useful when an unexpected error occurred or the retry logic has exhausted, I can perform analytics, log the error, and ensure that monitoring software knows of the failure.

The reason an anynoumous enum would want Product Safe Traits is so that programmers coming from a different languages, and even rust programmers could share the same paradigm.

match open_file_parse_int() {
    Ok(i: i32) => ...,
    Err(e) => log!(e), // since enum(Error, Error) impl Error we can treat it as one
}

I don't think it would be much of a stretch to say, hey you know that primitive you use to return multiple types of errors, well you can use it on non-error code too.


Speaking personally of why I want anonymous enums in rust is because it can allow me to write similar code to what I would write in my day job language, without having to actually think to hard about it. Yes adding anonymous enums is another thing a rust programmer will have to learn eventually. But I think they actually lower the hurdle for new programmers to understand rust's error handling. and with throws syntax it could be even more familiar.

I bet if you showed this to a sophomore java programmer who has never seen rust, they could tell you what this code does. Same if you were to show your java coworker this in your new rust micro service, they might sweat a little less knowing that if you leave it will be their responsibility to maintain it :slight_smile:

fn open_file_parse_int() -> i32 throws io::Error, ParseIntError

match open_file_parse_int() {
    Ok(i: i32) => ...,
    Err(f: io::Error) => ...,
    Err(p: ParseIntError) => ...,
}

Do they need to know all the inner workings of the enum to use it for it's primary use case? I don't think so, because I bet most people who use Exceptions don't know how they work either.

In my opinion the teachability of the language as a whole benefits from anonymous enums.

4 Likes

While I understand the motivation, it's exactly the reason I'd be hesitant to introduce new features like this just because they seem convenient and familiar. Learning a new language is a hassle, but you have to pass the hurdle only once.

Conversely, those features introduced to ease the on-boarding of developers familiar with other languages not only complicate the language, but risk introducing flawed paradigms from those languages as well - or paradigms that make sense for those languages (like throws), but not so much in Rust.

1 Like

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