Ideas around anonymous enum types

When reading the thread about go generics, I had an idea that I think is worth sharing.

Let's assume that

  • the syntax (i32 | &str) would create an anonymous enum with two variants (i32 and &str).
  • anonymous enum automatically implements all traits implemented by all the variants.
  • if and match expression would be extended to be able to return value of different types for each branch. The type of this expression would be either an anonymous enum (or a regular enum iif the enum implements the From trait for each possible branch).
  • for can be used as an expression, and can iterates on tuples.
    • Inside the loop, the type of the value would be an anonymous enum with all the types of the tuple.
    • If all values generated by the loop share the same type, the value created by this statement would be a [T] or, if the size is known (no break/continue/…) to a [T; n].
    • If the types aren't the same, if could be collected as a [(A | B | C | ...)] with A, B, C, … the types of the elements or if the size is known in a tuple (A, B, C).
    • Of course iterator would be extended to support the same functionality ((1f, 2u, "three").iter() would implement Iterator<Item=(f32 | usize | &str)>).

This open some interesting design ideas:

First, let's play with if block.

// explicitly create an anonymous enum
let x: (i32| &str) = 3;

let cond = false;
let y: (i32| &str) = if cond {
    3
} else {
    "three"
}
assert_eq!(x, y);

let y = if cond {
    3
} else {
    "three"
} : (i32| &str); // using type ascription since `3` is ambiguous
assert_eq!(x, y);

let y = if cond {
    3i
} else {
    "three"
};
assert_eq!(x, y);

Now, with loops:

let tuple = (1.0f, 2u, "three");
let (x, y, z) = for value in tuple {
    // value's type is `(f32 | usize | &str)` so value implements `Debug`
    printf("{}", value); // value of heterogeneous types can be used as long as they share some type in common, but only through those traits
    value
}; // implicit `.collect()` at the end of a for expression
// x is a f32, y an usize, and z a str
assert_eq!(tuple, (x, y, z));

and finally, let's play with functions:

trait SuperTrait {}
trait SomeTrait: SuperTrait {}

fn test<T: SomeTrait>(value: T) -> bool;

struct A;
struct B;
struct C;
impl SomeTrait for A {}
impl SomeTrait for B {}
impl SomeTrait for C {}

fn foo(values: (A, B, C) -> impl SuperTrait {
    for value in values {
        // `values` is a tuple of type `(A, B, C)` so the type of `value` is
        // the anonymous enum `(A | B | C)` which implements `SomeTrait`.
        if test(value) {
            return value; // the returned type can be any of `A`, `B` or `C`
        }
    }
}
// the concrete type of `foo` is `(A | B | C)` which implements `SomeTrait`
// and `SuperTrait` (since `SuperTrait` is required by `SomeTrait`).

I think it's really nice how everything could fit together. All the types would be statically typed, but it really feels that I was using some dynamically typed language.

5 Likes

The usual stumbling block for anonymous enum proposals is that nobody can seem to come up with a good syntax for matching on the variants. But using anonymous enums solely for their trait impls completely bypasses that problem, so that is an interesting idea.

As you may already know, I've wanted enum impl Trait return types for a long time. That's usually considered separate from anonymous enums because enum impl Trait wouldn't require you to spell out the variants anywhere or allow you to match on them, but it looks like you've essentially merged the two ideas. In past discussions everyone seemed to agree that just impl Trait shouldn't autogenerate an enum and we wanted some kind of explicit opt-in marker like the enum keyword, but I don't recall any knockout argument for that so maybe I could be talked out of it.

My main knee-jerk concerns are that:

  • I'd expect this to wreak havoc with type inference, but we'll need someone far better informed than me to comment on how big of a problem that really is
  • If we're only using trait impls and not proposing a match syntax, then it's unclear how many of the use cases for typical anonymous enum proposals are covered, so this might be little more than two alternate syntaxes for enum impl Trait.
2 Likes

One option is to built on top of generalized type ascription to solve it:

I would love something like that. Currently writing generic numeric code in Rust is quite tricky and verbose, and most of the time it just needs (f32 | f64).

5 Likes

And we back to Simplify error handling proposal where I have shown even prototype https://crates.io/crates/ferris-extensions :wink:

All this discussions show the need of community, I hope it will be dragged through finally )))

Also I like the idea in Pre-RFC with Pre-RFC: sum-enums enum keyword

enum(NoneError, ParseIntError)
4 Likes

To me, at least, there's only one syntax I've ever seen brought up that isn't horrible:

Given that, the stumbling block is instead arguments over whether enum(i32 | i32 | i32) and enum(i32 | i32) are the same type, and corresponding specialization-like questions around whether enum(&'a i32 | &'b i32) can be a legal type.

And don't forget everyone's favorite of arguing that enum(A | B | C) and enum(A | enum(B | C)) should flatten and be equivalent

or less controversially, enum(A | B) vs enum(B | A)

There's lots of annoying little corners to specify that people disagree on what's the "obvious" semantics.

7 Likes

This looks like a very interesting take on the problem as it addresses several issues with the same conceptually simple solution: sum union types with set semantics. The argument against implicit type widening seems weak to me because the very same bug exists without sum types already.

I’d be really interested in helping push this forward — where shall I start?

EDIT: by reading the other linked threads it became clear that the terminology here says “union type” where I said “sum type”, apologies for the mixup.

That can't possibly work – it would require making every trait into a lang item. This is impossible for 3rd-party traits and such special-casing is still highly undesirable for all core/std traits.

Please just don't. That's basically breaking type checking and makes Rust weakly-typed, while the From thing is just too magical for no good reason.

Just… why? What's the motivation that would warrant such a radical change? A lot of things can go wrong if we gradually start allowing all sorts of crazy things.

The restrictions Rust imposes are not mere accidents or oversights — they are in place to protect programmers from their own mistakes. Stuffing the language full of footguns like this, just because "they open up interesting ideas" really strikes the wrong balance. The bar for introducing additional complexity to the language should be much, much higher than "I would find this useful in some cases". Otherwise the language would end up being a complete mess.

9 Likes

That would be unsound. For example, types that implement the unsafe Pod trait mustn't have padding bytes. (u8 | u64) would automatically implement Pod, even though it can have padding.

Even if we excluded unsafe traits, I doubt that it would be sound. However, I could imagine a concept similar to auto traits. Let's call them composable traits for now:

composable trait Foo {}
impl Foo for i32 {}
impl Foo for &str {}

// (i32 | &str) implements Foo

Unlike auto traits, composable traits may have methods and associated types and constants. Note that neither auto traits nor composable traits need to be lang items @H2CO3.

I agree with @H2CO3 that this is a pretty radical change that would require a separate RFC. When you write an RFC for anonymous enums, you can add it to the "Future possibilities" section.

I don't think that's a good idea, for two reasons. First, it breaks type inference:

if foo {
    0usize
} else {
    1 // this should be an usize
}

Second, it is implicit and can cause confusion, for example:

fn return_iter() -> impl Iterator<Item = i32> {
    if foo {
        vec![1, 2, 3].into_iter()
    } else {
        std::iter::once(42)
    }
}

Assuming that Iterator is composable, this would compile fine. However, it is not at all obvious that it returns an anonymous enum, since the branches have different types.

I think it would be better to make the coercion explicit:

fn return_iter() -> impl Iterator<Item = i32> {
    if foo {
        vec![1, 2, 3].into_iter() as (IntoIter | _)
    } else {
        std::iter::once(42) as (Once | _)
    }
}

FYI f and u aren't valid suffixes, you need to write 1f32 and 2usize.

5 Likes

Or you could just use something like Either and implement your trait for it (perhaps even with a proc macro, I don’t know if such a macro exists yet).

Sidenote: Coming from Haskell, I’m a bit sad that Rust doesn’t have this type in the standard library.

2 Likes

How about this: type as pattern matching

1 Like

The main desire behind anonymous enum is that you don't want to worry about the precise definition and the exact type. Thus I wonder if this could be modeled with existential types in mind for the type itself but require a precise type for the purpose of matching, which arguably is concerned with the representation.

Create

  • A new lang_item trait: trait Enum<OneOfThisTuple>, see below. This marker trait denotes the set of enums that have variants for exactly each of the types in the tuple (potentially multiple variants and each type might be mentioned multiple times).
  • A new lang_item struct: struct Anonymous<OneOfThisTuple>. These represent the canonical form of the above trait. They can be coerced into larger ones, as the major selling point of having them be a lang item. Also from marked user-defined enums, see below.
  • Possibly some shorthand to make it easer to write the type: let x: (i32 | &str) = 3.
  • Let fitting enums declare that they are a representation for such an anonymous enum:
    enum Either {
        Left(i32),
        Right(u32),
    }
    // Like a Marker impl, compiler checks variants.
    impl Enum<(i32, u32)> for Either {}
    // Now this coercion works:
    Either::Right(0) as (i32 | u32)
    

This solves the matching problem, fundamentally by offloading it to the user. Any match must first coerce it into a concrete enum defined the usual way. This process can be defined to take the first variant with fitting type in declaration ordern, this being an intrinsic. This solves the problem of Enum<(T, U)> potentially decaying to Enum<(T,)> when T == U in that both would be converted into the first variant. Matching also simply uses the named variants of the proper enum, no special syntax needed. Local type definitions will likely help make this useful at the site of use.

fn duck(val: (i32 | u32)) {
    Repr { A(i32), B(u32) }
    impl Enum<(i32, u32)> for Repr {}

    match val as Repr {
        Repr::A(_) => {},
        Repr::B(_) => {},
    }
}

On the note of automatic trait impls, this could be restricted to object safe traits as those can be made to work by matching on all variant with fitting ref/ref mut qualifier and relying on coercion.

let disp: &fmt::Display = match val as Repr {
    Repr::A(ref v) => v,
    Repr::B(ref v) => v,
};
2 Likes