Built-in enum dispatch

My suggestion is to add built-in enum dispatch, like in Go.

Explanation: Go still haven’t got generics. Instead, they have syntactic sugar for enum dispatch mechanism called dictionaries. enum_dispatch crate is really cool, but it has several restrictions (inability to use traits from std, for example).

Drawbacks:

  • Unability to fully control behavior (current syntax).

Example:

Go version:

func foo[T any](value T) {
    fmt.Println(value)
}

Probable syntax:

fn foo(value: dict Display) {
    println!(“{value}”);
}

How is this different from trait objects (dyn Display)?

2 Likes

Seems to be more performant and can be stored on the stack due to fixed size.

I don't use Go myself. What sort of design patterns does this enable?

2 Likes

I don’t either.

Just some optimization capabilities. Wanted to know folks’ opinions on this topic and it’s probable implementation.

Sounds like optimization via indirection.

Could you be more specific?

1 Like

Do you have a reference for what that Go code is actually doing here? https://duckduckgo.com/?q=go+enum+dispatch+dictionaries doesn't give me any relevant results explaining the details behind it. (A "dictionary" sounds like a vtable to me at first glance, which would make this very similar to Gc<dyn Display>, but maybe with an optimization pass that converts that into a enum when there's a known small set of users; but that code looks more like what I recall Go generics looking like so I'm not sure).

In terms of the work done to use it, are we better off fixing the polymorphization and devirtualization optimizations so that we don't need it?

Enum dispatch lies somewhere between monomorphized generics and dynamic dispatch.

If I have code like:

pub fn foo<D: Display>(item: D) { … }

then the compiler will generate one version of foo for each type D that it's called with - so there will be code for foo<String>, foo<u32> etc in the final binary. There's a trick to optimizing this, called "polymorphization", where the compiler knows that most of foo's code will be identical between versions of foo, and doesn't output as many copies - for lots of detail, look at what the Polymorphization Working Group is working on.

There's also a sometimes-applicable manual trick for optimizing this, where you have an inner function that's not generic, and a generic wrapper:

pub fn foo<P: AsRef<Path>>(path: P) {
    fn foo_inner(path: &Path) { … }
    foo_inner(path.as_ref())
}

This results in foo_inner existing once, while foo is still monomorphized many times, but is tiny before optimizations. The polymorphization WG would like to optimize functions like this automatically into this form, where possible.

At the other extreme, you have indirect dispatch via trait objects:

pub fn foo(item: &dyn Display) { … }

This is fully dynamic - item has a vtable, and the compiler generates one body for foo, plus an indirect jump through the vtable every time you use item. Sometimes, the compiler can optimize this via something called "devirtualization", where it works out what possible values the vtable can take on, and removes the indirection.

Enum dispatch sits in the middle. You auto-generate (or hand-write) an enum that has one variant for each implementation of the trait that you want to use, and you implement the trait for that enum. So:

// All types in this enum implement `Display`, and its only API surface is
// the `impl Display` below
enum Displayable {
    U32(u32),
    String(String),
    F64(f64),
    IpAddr(IpAddr),
}

impl Display for Displayable {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::U32(d) => d.fmt(f),
            Self::String(d) => d.fmt(f),
            Self::F64(d) => d.fmt(f),
            Self::IpAddr(d) => d.fmt(f),
        }
    }
}

(you can see how this sort of code is easy to auto-generate).

Then, as long as the only implementations of Display that you care about have a variant in Displayable, you can take the enum instead of being generic (and monomorphized), or taking a trait object:

pub fn foo(item: Displayable) { … }

Personally, I'm not convinced that this should have special syntax - it feels like something the Polymorphization Working Group "should" implement as an optimized implementation of the generic form, where it makes sense - i.e. you always write:

pub fn foo<D: Display>(item: D) { … }

and the compiler works out for itself whether that's better implemented by monomorphization, or by generating "enum dispatch" type of code.

3 Likes

I don't think the Go version does what you think it does. fmt.Println gets .. any and passes them, eventually, to this function that uses a big switch statement for the builtin types and reflection for anything else.

4 Likes

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.