你一定遇到过这种场景:一个结构体有十几个字段,大部分是可选的。构造函数参数越写越长,调用时根本分不清第几个参数是什么意思,还要传一堆零值占位。

函数选项模式(Functional Options)就是来解决这个问题的。它用高阶函数替代冗长的参数列表,让代码既灵活又可读。

问题从哪来

假设我们要创建一个 HTTP 服务器,有很多可配置项:

type Server struct {
    Host     string
    Port     int
    Timeout  time.Duration
    TLS      bool
    CertFile string
    KeyFile  string
    MaxConn  int
}

最直觉的做法是写全参数构造函数,但调用时即使只想改超时时间,也得把所有参数填上:

srv := NewServer("localhost", 8080,
    30*time.Second, false, "", "")

后面三个参数完全没用,但必须传。参数一多,可读性急剧下降。

常见的改进方案有两种,但各有局限:配置结构体无法区分"未设置"和"零值"(Timeout 为 0 是没设置还是想设为 0?);Builder 模式引入额外类型、代码量翻倍,且忘了调 Build() 编译器不会报错。

函数选项模式:优雅的解法

核心思想很简单:用函数作为参数,每个函数负责设置一个配置项

先定义选项类型:

type Option func(*Server)

然后为每个配置项定义一个返回 Option 的函数:

func WithTimeout(d time.Duration) Option {
    return func(s *Server) {
        s.Timeout = d
    }
}

func WithTLS(certFile, keyFile string) Option {
    return func(s *Server) {
        s.TLS = true
        s.CertFile = certFile
        s.KeyFile = keyFile
    }
}

构造函数接收可变数量的 Option,先设默认值再逐个应用:

func NewServer(host string, port int,
    opts ...Option) *Server {
    s := &Server{
        Host:    host,
        Port:    port,
        Timeout: 10 * time.Second,
    }
    for _, opt := range opts {
        opt(s)
    }
    return s
}

调用时只传需要的选项:

srv := NewServer("localhost", 8080,
    WithTimeout(30*time.Second),
    WithTLS("cert.pem", "key.pem"),
)

每个选项含义一目了然,不需要的直接不传。

进阶:校验与组合

如果选项之间有约束关系,可以在应用选项后做校验:

func NewServer(host string, port int,
    opts ...Option) (*Server, error) {
    s := &Server{
        Host: host, Port: port,
        Timeout: 10 * time.Second,
    }
    for _, opt := range opts {
        opt(s)
    }
    if s.TLS && s.CertFile == "" {
        return nil, errors.New(
            "TLS requires CertFile")
    }
    return s, nil
}

对于经常一起使用的选项,可以定义复合选项:

func WithProduction() Option {
    return func(s *Server) {
        s.Timeout = 60 * time.Second
        s.MaxConn = 10000
    }
}

一行代码搞定生产环境配置,简洁又不容易遗漏。

最佳实践

命名规范: 选项函数统一用 With 前缀,这是 Go 社区的惯例,gRPCuber-go/zap 等项目都遵循。

顺序无关性: 选项之间应独立,应用顺序不影响结果。有依赖关系时在构造函数中统一处理,而非在选项函数中隐式耦合。

必填 vs 可选: 必填参数放在函数签名中,可选参数用 Option 传递。如果所有参数都通过 Option 传递,调用者可能忘记设置关键字段。

接口型选项: google.golang.org/grpc 使用了一种变体——选项接口:

type Option interface {
    apply(*Server)
}

type funcOption struct {
    f func(*Server)
}

func (o *funcOption) apply(s *Server) {
    o.f(s)
}

注意这里的 apply 是小写(未导出)的方法。这种设计被称为封闭接口(Sealed Interface),它的目的恰恰是阻止第三方包实现自己的 Option。对于公共 API 库来说,这种写法可以严格控制配置项的来源,防止外部传入不可控的选项类型,并且允许库内部使用不同类型(不仅仅是函数,比如用空结构体节省内存)来实现该接口。

什么时候该用,什么时候不该用

适合: 多个可选配置项、有合理默认值、需要对外暴露稳定 API。

不适合: 只有 1-2 个可选参数、配置项全必填、内部使用的结构体。

简单场景下强行使用,反而增加代码复杂度。

写在最后

函数选项模式的核心就三步:

  • 定义 type Option func(*T) 类型
  • 为每个配置项写一个 WithXxx 函数
  • 构造函数中先设默认值,再应用选项

它解决了三个实际问题:参数可读性、零值歧义、API 向后兼容。新增配置项只需添加一个 WithXxx 函数,不会破坏已有调用代码。