Avoiding repeated match when dispatching a unit variants enum to types

I’m looking for feedback on a pattern for doing type-based dispatch from a fieldless enum.

The situation is roughly this:

#[derive(Clone, Copy)]
enum MyEnum {
    VarA,
    VarB,
}

struct VarA;
struct VarB;

trait MyVarTrait {
    fn do_trait() {}
}

impl MyVarTrait for VarA {}
impl MyVarTrait for VarB {}

trait Executable {
    fn execute<T: MyVarTrait>(self);
}

impl MyEnum {
    fn do_a_plus_b_twice(self, a: i32, b: i32) {
        struct Op(i32, i32);

        impl Executable for Op {
            fn execute<T: MyVarTrait>(self) {
                let Op(a, b) = self;

                T::do_trait();
                T::do_trait();

                println!("{} + {}", a, b);
            }
        }

        self.visit(Op(a, b))
    }

    fn do_a_minus_b_twice(self, a: i32, b: i32) {
        struct Op(i32, i32);

        impl Executable for Op {
            fn execute<T: MyVarTrait>(self) {
                let Op(a, b) = self;

                T::do_trait();
                T::do_trait();

                println!("{} - {}", a, b);
            }
        }

        self.visit(Op(a, b))
    }

    fn visit<E: Executable>(self, executable: E) {
        match self {
            Self::VarA => executable.execute::<VarA>(),
            Self::VarB => executable.execute::<VarB>(),
        }
    }
}

The idea is that MyEnum is a fieldless enum used as a selector, while each variant corresponds to a concrete type (VarA, VarB).

I want the enum-to-type mapping to live in one place:

match self {
    Self::VarA => executable.execute::<VarA>(),
    Self::VarB => executable.execute::<VarB>(),
}

Then individual operations can define a local Op type which carries whatever context that operation needs.

This gives me static dispatch and avoids repeating the same match in every operation.

If the enum variants carried the actual implementation values, e.g.

enum MyEnum {
    VarA(VarA),
    VarB(VarB),
}

then something like enum_dispatch would fit naturally.

But in my real use case I specifically want a fieldless enum, because it is also used as a list of available variants, for example with strum for enumeration/UI selection.

So I effectively want:

fieldless enum variant
    -> concrete Rust type
    -> generic operation over that type

The pattern above works, but the local struct Op + impl Executable is somewhat verbose. What I would ideally like is something analogous to a generic closure, conceptually:

self.visit(|T| {
    T::do_trait();
    // captured local context...
});

but Rust closures cannot have a generic call<T>(), so I ended up using the local Op type instead.

Is there a more idiomatic way to model this?

In particular, I’d be interested in approaches that:

  • keep the enum fieldless,
  • preserve static dispatch / monomorphization,
  • keep the enum-to-concrete-type mapping in one place,
  • allow operations to carry local context without repeating a large match,
  • and ideally avoid excessive boilerplate.

Would you keep this visitor-like pattern, use a macro, restructure the types, or approach it differently?

Is there a reason this is not sufficient?

trait MyVarTrait {
    fn do_trait(self);
}

impl MyVarTrait for MyEnum {
    fn do_trait(self) {
        match self {
            Self::VarA => VarA.do_trait(),
            Self::VarB => VarB.do_trait(),
        }
    }
}

impl MyEnum {
    fn do_a_plus_b_twice(self, a: i32, b: i32) {
        self.do_trait();
        self.do_trait();
        println!("{} + {}", a, b);
    }

The strum crate lets you define the full enum with fields/payloads, and it creates the corresponding fieldless enum for you. You specify the name of the fieldless enum, and you can convert a full enum value to its corresponding fieldless enum value.

That way, you could also use the enum_dispatch crate on the full enum. And there is no duplicated mapping.

See

It is sufficient, however everytime when I am adding new enum variant, I need to duplicate new variation in every match, and over time can be a lot, so looking if I can write some more-less generic code to avoid unnecessary growing boilerplate code.

Yesterday, I've tried to play with abstraction, I've tried enum_dispatch with PhantomData to emulate unit variants, but abstraction rapidly grown in complexity, once I've started troubleshooting compiler issue, so I am sticking to plain match like you saying, but still looking if there is any elegant way to manage complexity.

In language like Java or C++ - such things can be easily solved with inheritance, which is similar to dyn Trait in rust, but I am concern that rust advocates to use enum instead, since enum runs much faster that dyn Trait and dynamic dispatch, enum matching can do static dispatch and static inline in many cases.


static VAR_A : VarA = VarA;
static VAR_B : VarB = VarB;

fn to_my_var_trait(my_enum: MyEnum) -> &dyn MyVarTrait {
   match my_enum {
      MyEnum::VarA => &VAR_A,
      MyEnum::VarA => &VAR_B,
   }
}

Looks like rust has optimization for dyn Trait as well, however still not sure if it is idiomatic way and can cause performance lags in hot loop if code will grow in complexity for optimizer comparing to pattern matching.

EnumDiscriminants - something worth learning, even if it may not solve my problem, it looks like it can be useful in many other cases.


UPD:

It looks like it is very close to what I am doing.

I have enum with data and enum full of unit variants like:

enum FunctionKind {
    Sin,
    Exp,
    Linear,
}

and

enum FunctionArgument {
    Sin(SinArgument),
    Exp(ExpArgument),
    Linear(LinearArgument),
}

Looks like EnumDiscriminants can generate FunctionKind for me automatically.

Any reason why match may not feel sufficient is consistency.

Let's say I have series of types, with traits I can bring some order into that. match feels flexible, but sometime that flexibility is devastating, because I can put similar with different interface, and when it comes to manage into something more complex, it just wouldn't work, and all the code becomes throw-away.

Is a bit of an oxymoron: a fieldless enum, once instantiated, is no longer a type - it's a value: a plain u8 for a fieldless enum with <= 256 entries for #[repr(Rust)] in particular.

To keep things bound to the type itself, known and enforced at compile-time:

fn main() {
    let a = Var::A;
    a.do_a_plus_b_twice(0xA, 0xA); // 10 + 10
    
    let b = Var::B;
    b.do_a_minus_b_twice(0xB, 0xB); // 11 - 11
}

enum Var {}
impl Var {
    const A: VarA = VarA;
    const B: VarB = VarB;
}

// ...

trait MyVarTraitAuto: MyVarTrait + Sized {
    fn do_a_plus_b_twice(self, a: i32, b: i32) { /* ... */ }
    fn do_a_minus_b_twice(self, a: i32, b: i32) { /* ... */ }
}


impl<T: MyVarTrait> MyVarTraitAuto for T {}

Full example here.

I ended up making a tiny proc-macro crate to automate exactly this kind of repetitive enum dispatch.

It keeps the concrete type in each generated match arm, so you can apply the same expression to different variant payload types without introducing a common trait just for the dispatch:

match_variants!(MyEnum, value, (x), {
    x.do_trait()
})

It expands to an ordinary match, with each arm type-checked independently.

Crates.io: crates.io: Rust Package Registry
GitHub: GitHub - amidukr/match-variants: Rust procedural macros for applying the same expression to heterogeneous enum variants without a common trait. · GitHub