Go 里该用 switch、map 还是接口做分发?

写 Go 代码时,经常会遇到一类很普通的问题:根据一个值选择不同的处理逻辑。

图片转换器要根据文件扩展名选择转换函数,HTTP handler 要根据 action 调不同的业务逻辑,CLI 程序要根据 subcommand 执行不同命令。写到这里,很多人会开始纠结:继续写 switch 会不会太土?要不要换成 map[string]func?是不是应该直接抽一个接口,再配一个注册表?

我更倾向于先问另一个问题:这段分发逻辑现在的变化成本在哪里?

如果只是三五个分支,switch 往往就是最合适的写法。等分支多到每次新增类型都要修改同一个大函数,再考虑把选择逻辑移到 map 里。接口注册表应该更晚出现,通常只有当每个分支背后已经不只是一个函数,而是一组有状态、有配置、需要独立组织的行为时,它才值得引入。

这篇文章借用一个图片格式转换的例子,聊聊这三种写法各自适合什么阶段。

先从 switch 开始

假设我们在写一个图片转换器,根据源文件扩展名把不同格式转换成 JPG。最直接的写法是 switch

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package jpgconv

import (
"fmt"
"path/filepath"
"strings"
)

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

switch ext {
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", ext)
}
}

这段代码没有什么问题。它的优点很直接,所有分支都在一个地方,错误路径也清楚。读代码的人不需要跳到别的文件里找注册逻辑,也不用猜某个接口实现是不是被初始化了。

很多时候,switch 看起来“不高级”,只是因为它太朴素了。三五种格式、变化频率不高、每个分支只是调用一个函数,这种情况下应该先看维护成本,不必急着换成更复杂的结构。

真正需要注意的是,switch 会把所有变化集中到同一个函数里。如果格式越来越多,团队里不同人都要改这里,或者每次新增格式都要在一个很长的 case 列表里找位置,这个函数就开始变成维护热点。

这时可以考虑换一种结构。

map[string]func 把分发变成数据

当每个分支只是“扩展名到转换函数”的对应关系时,map[string]func 通常比接口更自然。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package jpgconv

import (
"fmt"
"path/filepath"
"strings"
)

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)
}

这个版本没有让转换逻辑变得更复杂,只是把“选择哪个函数”从条件分支移到了数据表里。新增一种格式时,通常只需要写一个转换函数,再加一条 map 记录。

1
2
3
4
5
6
var converters = map[string]ConverterFunc{
".png": convertPNG,
".gif": convertGIF,
".webp": convertWEBP,
".bmp": convertBMP,
}

它适合解决一个很具体的问题:分支数量变多后,中心 switch 不再好维护。

如果转换逻辑继续变复杂,下一步可以考虑接口注册表。不过在进入那一步之前,先看清楚复杂度是不是已经超过了函数表能表达的范围。

map[string]func 也有自己的代价。最明显的是映射关系变成了运行期数据,编译器不会像检查 switch 语句那样帮你看分支是否写重。比如你在 map 里重复写了同一个 key,后面的值会覆盖前面的值,代码仍然可以编译。

1
2
3
4
var converters = map[string]ConverterFunc{
".jpg": convertJPEG,
".jpg": convertLegacyJPEG,
}

这段代码会直接编译失败,因为 Go 不允许 map literal 里出现重复常量 key。但如果 key 是变量或从配置加载,问题就会推迟到运行期。实际项目里,可以在测试里覆盖注册表,确认关键格式都能被识别。

另一个需要判断的是全局变量。如果 converters 是固定的内置表,全局 map 很简单。如果测试里需要替换转换函数,或者不同业务需要不同转换集合,可以把 map 包到一个结构体里。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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)
}

这层结构体没有改变分发模型,只是把“有哪些转换函数”从包级变量移到了一个对象里。测试、配置和多实例会更方便。

接口注册表适合更重的分支

接口注册表当然也能写。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
type Converter interface {
Extensions() []string
Convert(srcPath, dstPath string) error
}

type Registry struct {
converters map[string]Converter
}

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

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

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

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

return c.Convert(srcPath, dstPath)
}

然后每种格式提供自己的实现。

1
2
3
4
5
6
7
8
9
10
11
type PNGConverter struct {
quality int
}

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

func (c PNGConverter) Convert(srcPath, dstPath string) error {
return convertPNGWithQuality(srcPath, dstPath, c.quality)
}

这套写法适合什么情况?

如果每种转换器都需要自己的配置,接口开始有意义。比如 PNG 转换要处理透明通道,GIF 转换要决定取第一帧还是生成多张图,RAW 格式转换要依赖外部命令路径或初始化后的 decoder。这时“转换器”已经不只是一个函数名,而是一个带行为和状态的对象。

如果转换器需要暴露多种行为,接口也更合适。比如除了 Convert,还要支持 ProbeValidateMetadata,用一个函数类型就不够自然了。

还有一种情况是插件式组织。不同格式的实现放在不同包里,各自注册到中心 registry。这样新增格式时,中心代码几乎不用改。

不过这类写法也有代价。读代码的人需要先找到接口,再找注册点,再找具体实现。初始化顺序、重复注册、默认 registry 是否线程安全,都会变成需要解释的细节。

如果现在只是三五个扩展名对应三五个函数,接口注册表大概率太早了。

不要只按分支数量做判断

判断标准不是分支数量。二十个分支如果几年不变,写在一个 switch 里也未必难维护。六个分支如果每周都在变,而且不同人经常同时改同一块代码,map[string]func 可能更合适。

更值得看的有几个问题。

新增一个类型要改几个地方。如果每次都要改一个中心函数,而且这个函数已经很长,说明变化点可以往外挪。

每个分支背后是不是只有一个函数。如果是,map[string]func 足够。如果每个分支都有配置、状态、初始化和多个方法,再考虑接口。

错误路径要保持清楚。无论用哪种写法,找不到处理器时都应该返回明确错误,不要让 nil function 调用或空实现把问题藏起来。

最容易被忽略的是测试成本。switch 很容易直接测输入输出,map 和 registry 更方便注入 fake converter,但也多了注册逻辑要测。接口注册表如果引入 init 自动注册,测试时还要留意全局状态和用例顺序。很多抽象在主流程里看起来很整齐,到了测试里才暴露出真实价格。

小结

少量、稳定的分支,用 switch 就够了。它最直接,读起来没有额外成本。

分支数量增加,但每个分支仍然只是调用一个函数,可以换成 map[string]func。它把扩展名和处理函数的对应关系集中成一张表,新增格式时少改中心分发函数。

当每个分支背后已经是一组行为,或者需要不同包独立注册,再引入接口和 registry。工作中比较常见的过度抽象,是一个函数调用本来能解决的问题,被拆成接口、实现、注册器和初始化流程。等排查问题时,读者要沿着好几个文件追一圈,最后发现真正执行的仍然只是一个普通函数。

分发机制的目标是让变化发生在最小的位置,同时让下一位维护者能看懂这层结构保护的是什么。


Go 里该用 switch、map 还是接口做分发?
https://blog.zhangliangliang.cc/post/go-dispatch-switch-map-interface.html
作者
Bobby Zhang
发布于
2026年5月18日
许可协议