Go 的几种函数传参模式

1. 普通传参

Go 语言支持按顺序传入参数来调用函数,下面是一个示例函数:

// ListApplications 查询应用列表
func ListApplications(limit, offset int) []Application {
    return allApps[offset : offset+limit]
}

调用代码:

ListApplications(5, 0)

当你想增加新参数时,可以直接修改函数签名。比如,下面的代码给 ListApplications 增加了新的过滤参数 owner

func ListApplications(limit, offset int, owner string) []Application {
    if owner != "" {
        // ...
    }
    return allApps[offset : offset+limit]
}

调用代码也需要随之改变:

ListApplications(5, 0, "piglei")
// 不使用 owner 过滤
ListApplications(5, 0, "")

显而易见,这种普通传参模式存在以下几个明显的问题:

  • 可读性不佳:只支持用位置,不支持用关键字来区分参数,参数变多后,各参数含义很难一目了然
  • 破坏兼容性:增加新参数后,原有调用代码必须进行对应修改,比如像上方的 ListApplications(5, 0, "") 一样,在 owner 参数的位置传递空字符串

为了解决这些问题,常见的做法是引入一个参数结构体(struct)类型。

2. 使用参数结构体

新建一个结构体类型,里面包含函数需要支持的所有参数:

// ListAppsOptions 是查询应用列表时的可选项
type ListAppsOptions struct {
    limit  int
    offset int
    owner  string
}

修改原函数,直接接收该结构体类型作为唯一参数:

// ListApplications 查询应用列表,使用基于结构体的查询选项
func ListApplications(opts ListAppsOptions) []Application {
    if opts.owner != "" {
        // ...
    }
    return allApps[opts.offset : opts.offset+opts.limit]
}

调用代码如下所示:

ListApplications(ListAppsOptions{limit: 5, offset: 0, owner: "piglei"})
ListApplications(ListAppsOptions{limit: 5, offset: 0})

相比普通模式,使用参数结构体有以下几个优势:

  • 构建参数结构体时,可显式指定各参数的字段名,可读性佳
  • 对于非必选参数,构建时可不传值,比如上面省略了 owner

不过,无论是使用普通模式还是参数结构体,都无法支持一个常见的使用场景:真正的可选参数。

3. 藏在可选参数里的陷阱

为了演示“可选参数”的问题,我们给 ListApplications 函数增加一个新选项:hasDeployed——根据应用是否已部署来过滤结果。

参数结构体调整如下:

// ListAppsOptions 是查询应用列表时的可选项
type ListAppsOptions struct {
    limit       int
    offset      int
    owner       string
    hasDeployed bool
}

查询函数也做出对应调整:

// ListApplications 查询应用列表,增加对 HasDeployed 过滤
func ListApplications(opts ListAppsOptions) []Application {
    // ...
    if opts.hasDeployed {
        // ...
    } else {
        // ...
    }
    return allApps[opts.offset : opts.offset+opts.limit]
}

想过滤已部署的应用时,我们可以这么调用:

ListApplications(ListAppsOptions{limit: 5, offset: 0, hasDeployed: true})

而当我们不需要按“部署状态”过滤时,可以删除 hasDeployed 字段,用以下代码调用 ListApplications 函数:

ListApplications(ListAppsOptions{limit: 5, offset: 0})

等等……好像哪里不太对劲。hasDeployed 是布尔类型,这意味着当我们不为其提供任何值时,程序总是会使用布尔类型的零值(zero value):false

所以,现在的代码其实根本拿不到“未按已部署状态过滤”的结果,hasDeployed 要么为 true,要么为 false,不存在其他状态。

4. 引入指针类型支持可选

为了解决上面的问题,最直接的做法是引入指针类型(pointer type)。和普通的值类型不同,Go 里的指针类型拥有一个特殊的零值:nil。因此,只要把 hasDeployed 从布尔类型(bool)改成指针类型(*bool),就能更好地支持可选参数:

// ListAppsOptions 是查询应用列表时的可选项
type ListAppsOptions struct {
    limit  int
    offset int
    owner  string
    // 启用指针类型
    hasDeployed *bool
}

查询函数也需要做一些调整:

// ListApplications 查询应用列表,增加对 HasDeployed 过滤
func ListApplications(opts ListAppsOptions) []Application {
    // ...
    if opts.hasDeployed == nil {
        // 默认不过滤分支
    } else {
        // 按 hasDeployed 为 true 或 false 来过滤
    }
    return allApps[opts.offset : opts.offset+opts.limit]
}

在调用函数时,调用方如不指定 hasDeployed 字段的值,代码就会进入 if opts.hasDeployed == nil 分支,不做任何过滤:

ListApplications(ListAppsOptions{limit: 5, offset: 0})

当调用方想按 hasDeployed 过滤时,可以采用下面的方式:

wantHasDeployed := true
ListApplications(ListAppsOptions{limit: 5, offset: 0, hasDeployed: &wantHasDeployed})

如你所见,因为 hasDeployed 如今是指针类型 *bool ,所以我们必须得先创建一个临时变量,然后取它的指针去调用函数。

不得不说,这挺麻烦的对不?有没有一种方式,既能解决前面这些函数传参时的痛点,又能让调用过程不要像“手动造指针”这么麻烦呢?

接下来便该函数式选项(functional options)模式出场了。

5. “函数式选项”模式

除了普通传参模式外,Go 语言其实还支持可变数量的参数,使用该特性的函数统称为“可变参数函数(varadic functions)”。比如 appendfmt.Println 均属此类。

nums := []int{}
// 调用 append 时,传多少个参数都行 
nums = append(nums, 1, 2, 3, 4)

为了实现“函数式选项”模式,我们首先修改 ListApplications 函数的签名,使其接收类型为 func(*ListAppsOptions) 的可变数量参数。

// ListApplications 查询应用列表,使用可变参数
func ListApplications(opts ...func(*ListAppsOptions)) []Application {
    // 设置好每个参数的默认值
    config := ListAppsOptions{limit: 10, offset: 0, owner: "", hasDeployed: nil}
    // 轮询 opts 里的每个函数,调用它们来修改 config 对象
    for _, opt := range opts {
        opt(&config)
    }
    // ...
    return allApps[config.offset : config.offset+config.limit]
}

然后,再定义一系列用于调节选项的工厂函数:

func WithPager(limit, offset int) func(*ListAppsOptions) {
    return func(opts *ListAppsOptions) {
        opts.limit = limit
        opts.offset = offset
    }
}

func WithOwner(owner string) func(*ListAppsOptions) {
    return func(opts *ListAppsOptions) {
        opts.owner = owner
    }
}

func WithHasDeployed(val bool) func(*ListAppsOptions) {
    return func(opts *ListAppsOptions) {
        opts.hasDeployed = &val
}

这些以 With* 命名的工厂函数,通过返回闭包函数,来修改函数选项对象 ListAppsOptions

调用时的代码如下:

// 不使用任何参数
ListApplications()
// 选择性启用某些选项
ListApplications(WithPager(2, 5), WithOwner("piglei"))
ListApplications(WithPager(2, 5), WithOwner("piglei"), WithHasDeployed(false))

和使用“参数结构体”比起来,“函数式选项”模式有以下几个特点:

  • 更友好的可选参数:比如不再需要手动为 hasDeployed 取指针
  • 灵活性更强:可以方便地在每个 With* 函数里追加额外逻辑
  • 向前兼容性好:任意增加新的选项都不会影响已有代码
  • 更漂亮的 API:当参数结构体很复杂时,该模式所提供的 API 更漂亮,也更好用

不过,直接用工厂函数实现的“函数式选项”模式,对使用方其实算不上太友好。因为每个 With* 都是独立的工厂函数,可能分布在各个地方,调用方在使用时,很难一站式的找出函数所支持的所有选项。

为了解决这个问题,人们在“函数式选项”模式的基础做了一些小优化:用接口(Interface)类型替代工厂函数。

6. 使用接口实现“函数式选项”

首先,定义一个名为 Option 的接口类型,其中仅包含一个方法 applyTo

type Option interface {
    applyTo(*ListAppsOptions)
}

然后,把这批 With* 工厂函数改为各自的自定义类型,并实现 Option 接口:

type WithPager struct {
    limit  int
    offset int
}

func (r WithPager) applyTo(opts *ListAppsOptions) {
    opts.limit = r.limit
    opts.offset = r.offset
}

type WithOwner string

func (r WithOwner) applyTo(opts *ListAppsOptions) {
    opts.owner = string(r)
}

type WithHasDeployed bool

func (r WithHasDeployed) applyTo(opts *ListAppsOptions) {
    val := bool(r)
    opts.hasDeployed = &val
}

做完这些准备工作后,查询函数也要做出相应的调整:

// ListApplications 查询应用列表,使用可变参数,Option 接口类型
func ListApplications(opts ...Option) []Application {
    config := ListAppsOptions{limit: 10, offset: 0, owner: "", hasDeployed: nil}
    for _, opt := range opts {
        // 调整调用方式
        opt.applyTo(&config)
    }
    // ...
    return allApps[config.offset : config.offset+config.limit]
}

调用代码和之前类似,如下所示:

ListApplications(WithPager{limit: 2, offset: 5}, WithOwner("piglei"))
ListApplications(WithOwner("piglei"), WithHasDeployed(false))

各个可选项从工厂函数变成 Option 接口后,找出所有可选项变得更方便了,使用 IDE 的“查找接口的实现”就可以轻松完成任务。

问:应该优先使用“函数式选项”吗?

看完这些传参模式后,我们会发现“函数式选项”似乎在各方面都是优胜者,它可读性好、兼容性强,好像理应成为所有开发者的首选。而它在 Go 社区中确实也非常流行,活跃在许多流行的开源项目里(比如 AWS 的官方 SDKKubernetes Client )。

相比“普通传参”和“参数结构体”,“函数式选项”的确有着许多优势,不过我们也不能对其缺点视而不见:

  • 需要写更多不算简单的代码来实现
  • 相比直白的“参数结构体”,在使用基于“函数式选项”模式的 API 时,用户更难找出所有的可选项,需要花费更多功夫

总的来说,最简单的“普通传参”、“参数结构体”以及“函数式选项”的实现难度和灵活度递增,这几种模式各有其适用的场景。在设计 API 时,我们需要从具体需求出发,优先采用更简单的做法,如无必要,不引入更复杂的传参模式。

参考

  1. Functional options for friendly APIs | Dave Cheney
  2. Parameters with Defaults in Go: Functional Options | Charles Xu