如果 Go 支持默认参数,下面这组 API 看起来完全可以合成一个函数:
go
net.Dial("tcp", "db.internal:5432")
net.DialTimeout("tcp", "db.internal:5432", 3*time.Second)
例如假想一种并不存在的 Go 语法,把默认值写进声明:
go
func Dial(network, address string, timeout time.Duration = 3*time.Second)
Dial("tcp", "db.internal:5432")
少一个函数名,也少写一个参数,确实方便。可一旦参数允许省略,往同一个函数里继续塞选项就变得很顺手。
今天只有 timeout,过一阵又加上重试次数、代理、保活时间:
text
Dial(network, address,
timeout = 3s,
retries = 2,
keepAlive = 30s,
proxy = nil)
调用处仍然只有短短一行,函数承担的事情却越来越多。默认参数本身不难实现,Go 更警惕的是:一个函数借着默认值不断长出新行为,最后只能靠参数组合区分这些行为。
这正是 Go 当初不喜欢默认参数的地方。Rob Pike 在介绍 Go 设计时给过一句很直接的评价:默认参数太容易被拿来修补 API 设计问题。参数继续往函数后面加,原本应该拆开的行为就被藏进了一张越来越长的默认值表里。Go at Google
Go 的函数签名没有"可以省略的这一段"。规范里的普通参数必须逐一提供;只有最后一个 ...T 参数可以接收零个或多个同类型实参。Go 语言规范
于是 Go 标准库经常把语义差别写进 API 本身:
go
net.Dial(network, address)
net.DialTimeout(network, address, timeout)
看到 DialTimeout,不用跳进源码,也知道这个调用主动规定了超时。这里还要分清:net.Dial 并没有暗藏一个固定的"三秒默认值",而是没有通过这个 API 显式设置超时;需要限定连接时间时,调用方改用 DialTimeout。两种语义直接写在了函数名上。
当选项确实很多时,Go 库通常把扩展点放到一个独立对象里。http.Client 就是这种写法:
go
client := &http.Client{
Timeout: 3 * time.Second,
}
resp, err := client.Get(url)
配置和执行分开以后,新增字段不需要改变 Get 的函数签名。另一种常见写法是函数式 Option。Go 官方关于模块兼容性的文章也专门讨论过这两种方案,同时提醒 Option 会增加名称和重复选项处理等复杂度;它不是默认参数换了件衣服就自动变好。Keeping Your Modules Compatible
变长参数也不能充当通用默认参数:
go
func Dial(addr string, timeout ...time.Duration)
它只能表达"零个或多个同类型值"。函数内部还得决定传两个超时时怎么办,调用处也看不出省略参数代表永不超时还是使用某个默认值。参数一多,最后很容易滑到 ...any,类型检查也跟着变弱。
所以我看一个 Go API 要不要再塞一个可选参数,通常先看它是不是一种新的行为。若是,就给它一个能说清差别的名字,或者把它收进配置对象。Dial 和 DialTimeout 多占了一个名字,但调用代码自己带着说明书。