What's the ideal dispatch mechanism?

Someone asked in r/golang:

I’m creating a multi-format converter that converts all graphic formats used on a user’s system to JPG. I can use a switch based on the file extension to choose how to convert each file. Is there a more idiomatic way to structure the code, or is a switch preferable for this kind of problem? What construct would be more optimal to maintain, extend, and use long term, based on your experience, in place of a switch (number of formats up to 20)?

I took a stab at it there. Here’s the longer version.


A switch is fine as a starting point, and I’d start there. Once you hit 10-20 formats, it becomes a long, central piece of code that you keep touching every time a new format shows up. But I still wouldn’t change anything if maintaining a bunch of case arms isn’t actually causing problems.

package jpgconv

func Convert(srcPath, dstPath string) error {
    switch strings.ToLower(filepath.Ext(srcPath)) {
    case ".png":
        return convertPNG(srcPath, dstPath)
    case ".gif":
        return convertGIF(srcPath, dstPath)
    case ".webp":
        return convertWEBP(srcPath, dstPath)
    default:
        return fmt.Errorf(
            "unsupported source format: %s", filepath.Ext(srcPath))
    }
}

But sometimes I don’t start with a switch and instead go straight to a map of functions. This removes the growing conditional. Adding a new format becomes a one-line map entry instead of editing a big block:

package jpgconv

type ConverterFunc func(srcPath, dstPath string) error

var converters = map[string]ConverterFunc{
    ".png":  convertPNG,
    ".gif":  convertGIF,
    ".webp": convertWEBP,
}

func Convert(srcPath, dstPath string) error {
    ext := strings.ToLower(filepath.Ext(srcPath))

    fn, ok := converters[ext]
    if !ok {
        return fmt.Errorf("unsupported source format: %s", ext)
    }
    return fn(srcPath, dstPath)
}

func convertPNG(srcPath, dstPath string) error {
    // read PNG from srcPath and write JPG to dstPath
    return nil
}

This is usually where I stop. But you can keep going and replace the map of functions with a map of interfaces. Instead of a flat function map, you define a converter interface and keep a registry of types that satisfy it. The dispatch logic stays the same:

package jpgconv

type Converter interface {
    Extensions() []string
    Convert(srcPath, dstPath string) error
}

Now instead of a map of functions, you keep a registry of converters:

package jpgconv

var registry = map[string]Converter{}

func Register(c Converter) {
    for _, ext := range c.Extensions() {
        registry[strings.ToLower(ext)] = c
    }
}

func Convert(srcPath, dstPath string) error {
    ext := strings.ToLower(filepath.Ext(srcPath))

    c, ok := registry[ext]
    if !ok {
        return fmt.Errorf("unsupported source format: %s", ext)
    }
    return c.Convert(srcPath, dstPath)
}

Each format can live in its own file and register itself. This avoids touching central code when adding new formats, which is the main long-term win:

package jpgconv

type PNGConverter struct{}

func (PNGConverter) Extensions() []string {
    return []string{".png"}
}

func (PNGConverter) Convert(srcPath, dstPath string) error {
    return convertPNG(srcPath, dstPath)
}

func init() {
    Register(PNGConverter{})
}

I rarely go for the interface approach unless I know for sure that the map of functions is a bottleneck, which almost never happens. It feels a bit heavy, and I’m not a big fan of the extra abstraction unless it actually solves a real problem.


If you want to avoid global state in the map of func approach, wrap the map in a struct and hang Convert off of it:

package jpgconv

type Registry struct {
    converters map[string]ConverterFunc
}

func NewRegistry() *Registry {
    return &Registry{converters: make(map[string]ConverterFunc)}
}

func (r *Registry) Register(ext string, fn ConverterFunc) {
    r.converters[strings.ToLower(ext)] = fn
}

func (r *Registry) Convert(srcPath, dstPath string) error {
    ext := strings.ToLower(filepath.Ext(srcPath))

    fn, ok := r.converters[ext]
    if !ok {
        return fmt.Errorf("unsupported source format: %s", ext)
    }
    return fn(srcPath, dstPath)
}
§