Extendable Enums (as traits)

Currently, there is no way to extend an enum from the outside, and for regular enumerables this should remain the case. This topic will consider an approach to use traits as enum extenders. The solution is to make use of trait hierarchies and consider them as enum roots. The idea is to add the syntax enum trait

// Will be the "parent" enumerable
pub enum trait Enum { ... }
// Will be an "enum member"
pub struct Member1;
impl Enum for Member1 { ... }
// Control flow
let value: Enum
match value { // because Enum is of an 'enum trait', this match arm is recognized as one by the analyzer.
    Member1 => ...,
    Member2 { ... } => ...,
    Member3 ( ... ) => ...,
    Member4 _ => ..., // without brackets, this simply acts as a cast and makes `value` usable in match arm.
    _ => ... // This should be required as the trait is defined as an enum trait (=extensible, non-exhaustive)
}

Under the hood, the TypeId struct and type_id() intrinsic as referenced by the Rust Stdlib would act as the discriminator and essentially replace the match arms with type id comparators.

match value.type_id() {
    TypeId::of::< Member1 >() => ... // Assume to be Member1 in this scope.
    ...
}

The usage is to define extensible by other modules enums, but not require too much storage space like comparing string identifiers or other. Examples include having an "error" type for a parser that could be extended with custom tokens and custom errors. Note, that the compiler should always require the wildcard match, as these enums are non-exhaustive.

Since traits and structs are always imported by path, name collisions can be ignored, since this part is considered by import logic.

Per default, this should be the default implementation, which any enum trait member should implement (built-in)

pub enum trait Enum {
    pub fn discriminator(&self) -> u128 {
        self.type_id()
    }
}

The trait 'Any' should become an enum trait. This allows for direct matching and is backwards compatible, since enum traits can still act as regular traits.

let any: Any;
match any {
    String _ => println!("Received a string: {value}"),
    usize _ => println!("Received a number: {}", "+".repeat(value)),
    _ => println!("I don't know this type."),
}

How would an instance of Enum be represented, given that it could potentially be any type? Enums solve the problem by statically knowing all the possible types, but this is explicitly avoided for the proposed enum trait.

A comparison with existing solutions would also be nice. For example, how would this be different than making a trait Enum: Any, using Box<dyn Enum> for owned instances and downcast[_ref,_mut] methods instead of the proposed match extensions?

4 Likes

@SkiFire13 This suggestion would mostly be like syntactic sugar to the elements which you named, except the compiler would be a 'better friend', since it has information on what kind of logic is going on.

Regarding how this would be represented, on it's own Enum is just a trait, it has an in-built method discriminator() and the size would be defined by the member types. This isn't a problem for implementing the super trait, or declaring a particular member of the Enum.

The problem comes when you want to re-assign value = X::new() to an existing variable. I think the solution here would be to disallow mutability of enum traits in safe scopes. What do you think?

Syntactic sugar for a hidden Box would mean implicit allocations and it would not be available in targets without access to alloc.

In what ways would it be a "better friend" though?

This seems confusing to me given you're using Enum as a type in your code example (e.g. let value: Enum), and traits are not types.

The member types may not yet be known when the size is needed. For example the size might be needed when compiling the crate that contains the trait definition, but the actual implementors might be in downstream crates.

I might be missing something but I don't see a supertrait here.

1 Like

This can already be done:

enum TokenKind {
  A,
  B,
  Other(&'static str)
}
enum ParserError {
  InvalidToken {
    kind: TokenKind,
    // ... text for span, location, file
  }
}

What would be the difference from regular traits? They can be implemented by external types, have a reasonable reference size (&vtable = usize or &dyn Trait = (usize, usize)), and there's already crates that allow you to downcast-match (match_cast), but you can also define the macro you'd need yourself as it's very straightforward.

Generally, instead of inheritance you're looking for, composition yields far better code. To extend errors from another crate, write your own error type that contains errors from several other crates. Those other crates will never be able to reason about external types anyway (because they don't exist (yet)), and you'll be able to exhaustively check for all errors your crate/program can encounter (which is the goal of error handling).

Standard proposal advice: you should spend way more time on the problem space, because you need to get people to accept there's a problem before they'll engage with you on your proposed solution.

Notably, enums being closed and dyn Trait being open seems well-established, so you need to motivate why you need something other than those things.

Anything adding a new category to the type system is generally a last resort. You should at least cover things like why a SizeLimitedBox<dyn Trait, 64> or something wouldn't do what you need.

2 Likes

This sounds like compiler/language implementation of inventory. It does its job admirably, but it numerous gaps including, but not limited to:

  • it is essentially a global registry
  • dynamic loading of libraries (essentially) can ask that this be mut (which makes it verbotten in Rust 2024 given that static mut was removed)
  • unloading of libraries with this gets…complicated if any instances have the type which "live" in the to-be-unloaded library
  • dead code optimizations can interfere; can an impl be removed? By the compiler? Linker?
1 Like

Well this is problematic as it doesn't lint something as simple as typos, this is what I meant with 'better friend' (@SkiFire13). The compiler would properly lint, help and error match arms, consider if a structure is actually valid. This kind of extendable enum is not supported By Rust currently.

Another possible approach to this problem using existing language features:

enum MyEnum<T> {
    // Variants that are known to the crate, and can always be matched on.
    Member1,
    Member2(i32),
    // Users can put their own enum in here to "add more variants"
    Other(T),
}
1 Like

Including an enum Never {} if they don't want anything else, which in a couple weeks won't need to be matched on.

3 Likes

This can only be extended once. Consider the error type from nom parser. It contains an error for every possible combinator of the parser. Let's say you use three packages that come with their own logic and errors. The only real solution for this is for all errors to be their own type and implement an error trait, and if you want to match individual structs, this is impossible.

Edit: I've made more thoughts and I think the enum trait itself is not needed, matching a variable which is under an enum for its' type would be instead the only thing needed.

This doesn't really answer my question since I was comparing it with downcasting trait objects, where the compiler will also catch typos.

If each of the packages has their own unique error type, I can implement:

enum CombinedError {
    NomError(nom::Err),
    SynError(syn::parse::Error),
    PomError(pom::Error),
}

impl From<nom::Err> for CombinedError {
    fn from(value: nom::Err) -> Self {
         Self::NomError(value)
    }
}
// And equivalents for pom and syn errors

I can then do a big match across all errors; for example:

fn is_incomplete(err: &CombinedError) -> bool {
    match err {
        SynError(_) -> panic!("syn is never given incomplete data"),
        PomError(pom::Error::Incomplete) | NomError(nom::Err::Incomplete) => true
        _ => false,
    }
}

Assuming that I'm happy with this, what new abilities do I get from your proposal that would make it worth refactoring away from a combined enum to your new feature?

1 Like

It seems that this proposal aims to replace use of std::any with a nicer syntax, said otherwise replace (as seen in the first message)

match value.type_id() {
    TypeId::of::< Member1 >() => ... // Assume to be Member1 in this scope.
    ...
}

by

match value {
    Member1(m) => …
    ...
}

Does it enable new patterns that you can’t achieve with std::any? Is there use-case that are heavily simplified by this sugar?