Go 里该用 switch、map 还是接口做分发?
写 Go 代码时,经常会遇到一类很普通的问题:根据一个值选择不同的处理逻辑。
图片转换器要根据文件扩展名选择转换函数,HTTP handler 要根据 action 调不同的业务逻辑,CLI 程序要根据 subcommand 执行不同命令。写到这里,很多人会开始纠结:继续写 switch 会不会太土?要不要换成 map[string]func?是不是应该直接抽一个接口,再配一个注册表?
我更倾向于先问另一个问题:这段分发逻辑现在的变化成本在哪里?
如果只是三五个分支,switch 往往就是最合适的写法。等分支多到每次新增类型都要修改同一个大函数,再考虑把选择逻辑移到 map 里。接口注册表应该更晚出现,通常只有当每个分支背后已经不只是一个函数,而是一组有状态、有配置、需要独立组织的行为时,它才值得引入。
这篇文章借用一个图片格式转换的例子,聊聊这三种写法各自适合什么阶段。
先从 switch 开始
假设我们在写一个图片转换器,根据源文件扩展名把不同格式转换成 JPG。最直接的写法是 switch。
1 | |
这段代码没有什么问题。它的优点很直接,所有分支都在一个地方,错误路径也清楚。读代码的人不需要跳到别的文件里找注册逻辑,也不用猜某个接口实现是不是被初始化了。
很多时候,switch 看起来“不高级”,只是因为它太朴素了。三五种格式、变化频率不高、每个分支只是调用一个函数,这种情况下应该先看维护成本,不必急着换成更复杂的结构。
真正需要注意的是,switch 会把所有变化集中到同一个函数里。如果格式越来越多,团队里不同人都要改这里,或者每次新增格式都要在一个很长的 case 列表里找位置,这个函数就开始变成维护热点。
这时可以考虑换一种结构。
map[string]func 把分发变成数据
当每个分支只是“扩展名到转换函数”的对应关系时,map[string]func 通常比接口更自然。
1 | |
这个版本没有让转换逻辑变得更复杂,只是把“选择哪个函数”从条件分支移到了数据表里。新增一种格式时,通常只需要写一个转换函数,再加一条 map 记录。
1 | |
它适合解决一个很具体的问题:分支数量变多后,中心 switch 不再好维护。
如果转换逻辑继续变复杂,下一步可以考虑接口注册表。不过在进入那一步之前,先看清楚复杂度是不是已经超过了函数表能表达的范围。
map[string]func 也有自己的代价。最明显的是映射关系变成了运行期数据,编译器不会像检查 switch 语句那样帮你看分支是否写重。比如你在 map 里重复写了同一个 key,后面的值会覆盖前面的值,代码仍然可以编译。
1 | |
这段代码会直接编译失败,因为 Go 不允许 map literal 里出现重复常量 key。但如果 key 是变量或从配置加载,问题就会推迟到运行期。实际项目里,可以在测试里覆盖注册表,确认关键格式都能被识别。
另一个需要判断的是全局变量。如果 converters 是固定的内置表,全局 map 很简单。如果测试里需要替换转换函数,或者不同业务需要不同转换集合,可以把 map 包到一个结构体里。
1 | |
这层结构体没有改变分发模型,只是把“有哪些转换函数”从包级变量移到了一个对象里。测试、配置和多实例会更方便。
接口注册表适合更重的分支
接口注册表当然也能写。
1 | |
然后每种格式提供自己的实现。
1 | |
这套写法适合什么情况?
如果每种转换器都需要自己的配置,接口开始有意义。比如 PNG 转换要处理透明通道,GIF 转换要决定取第一帧还是生成多张图,RAW 格式转换要依赖外部命令路径或初始化后的 decoder。这时“转换器”已经不只是一个函数名,而是一个带行为和状态的对象。
如果转换器需要暴露多种行为,接口也更合适。比如除了 Convert,还要支持 Probe、Validate、Metadata,用一个函数类型就不够自然了。
还有一种情况是插件式组织。不同格式的实现放在不同包里,各自注册到中心 registry。这样新增格式时,中心代码几乎不用改。
不过这类写法也有代价。读代码的人需要先找到接口,再找注册点,再找具体实现。初始化顺序、重复注册、默认 registry 是否线程安全,都会变成需要解释的细节。
如果现在只是三五个扩展名对应三五个函数,接口注册表大概率太早了。
不要只按分支数量做判断
判断标准不是分支数量。二十个分支如果几年不变,写在一个 switch 里也未必难维护。六个分支如果每周都在变,而且不同人经常同时改同一块代码,map[string]func 可能更合适。
更值得看的有几个问题。
新增一个类型要改几个地方。如果每次都要改一个中心函数,而且这个函数已经很长,说明变化点可以往外挪。
每个分支背后是不是只有一个函数。如果是,map[string]func 足够。如果每个分支都有配置、状态、初始化和多个方法,再考虑接口。
错误路径要保持清楚。无论用哪种写法,找不到处理器时都应该返回明确错误,不要让 nil function 调用或空实现把问题藏起来。
最容易被忽略的是测试成本。switch 很容易直接测输入输出,map 和 registry 更方便注入 fake converter,但也多了注册逻辑要测。接口注册表如果引入 init 自动注册,测试时还要留意全局状态和用例顺序。很多抽象在主流程里看起来很整齐,到了测试里才暴露出真实价格。
小结
少量、稳定的分支,用 switch 就够了。它最直接,读起来没有额外成本。
分支数量增加,但每个分支仍然只是调用一个函数,可以换成 map[string]func。它把扩展名和处理函数的对应关系集中成一张表,新增格式时少改中心分发函数。
当每个分支背后已经是一组行为,或者需要不同包独立注册,再引入接口和 registry。工作中比较常见的过度抽象,是一个函数调用本来能解决的问题,被拆成接口、实现、注册器和初始化流程。等排查问题时,读者要沿着好几个文件追一圈,最后发现真正执行的仍然只是一个普通函数。
分发机制的目标是让变化发生在最小的位置,同时让下一位维护者能看懂这层结构保护的是什么。