普通传参模式

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

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

在调用方调用函数时:

1
ListApplications(5, 0)

当后续添加需求需要给函数添加新的入参时,可以直接修改函数签名,但相对应的调用代码也需要跟着改变:

1
2
3
4
5
6
7
8
9
10
11
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类型。

结构体传参

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

1
2
3
4
5
6
// ListAppsOptions 是查询应用列表时的可选项
type ListAppsOptions struct {
limit int
offset int
owner string
}

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

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

调用方代码如下:

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

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

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

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

关于可选参数默认值的一个陷阱

现在有一个新需求,需要在 ListApplications 函数增加一个新选项:hasDeployed,根据应用是否已部署来过滤结果。

参数结构体调整如下:

1
2
3
4
5
6
7
// ListAppsOptions 是查询应用列表时的可选项
type ListAppsOptions struct {
limit int
offset int
owner string
hasDeployed bool
}

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

1
2
3
4
5
6
7
8
9
10
// ListApplications 查询应用列表,增加对 HasDeployed 过滤
func ListApplications(opts ListAppsOptions) []Application {
// ...
if opts.hasDeployed {
// ...
} else {
// ...
}
return allApps[opts.offset : opts.offset+opts.limit]
}

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

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

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

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

此时问题出现了,我们知道布尔类型的默认值是false,这也意味着当我们不为其提供任何值的时候,程序总是会将其的值置为false

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

引入指针类型支持可选

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

1
2
3
4
5
6
7
8
// ListAppsOptions 是查询应用列表时的可选项
type ListAppsOptions struct {
limit int
offset int
owner string
// 启用指针类型
hasDeployed *bool
}

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

1
2
3
4
5
6
7
8
9
10
// 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 分支,不做任何过滤:

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

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

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

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

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

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

函数式选项模式

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

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

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

1
2
3
4
5
6
7
8
9
10
11
// 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]
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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

调用时的代码如下:

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

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

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

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

为了解决这个问题,可以在函数式选项模式的基础做一些小优化:用接口类型去替代工厂函数。

使用接口实现函数式选项

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

1
2
3
type Option interface {
applyTo(*ListAppsOptions)
}

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

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

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

1
2
3
4
5
6
7
8
9
10
// 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]
}

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

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

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

总结

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

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

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

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