普通传参模式
Go语言支持按顺序传入参数来调用函数,下面是一个示例函数:
1 2 3 4
| func ListApplications(limit, offset int) []Application { return allApps[offset : offset+limit] }
|
在调用方调用函数时:
当后续添加需求需要给函数添加新的入参时,可以直接修改函数签名,但相对应的调用代码也需要跟着改变:
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")
ListApplications(5, 0, "")
|
显而易见的,这种普通传参模式存在以下几个明显的问题:
- 可读性不佳:只支持用位置,不支持用关键字来区分参数,当参数变多后,各参数含义很难一目了然;
- 破坏程序的兼容性:当增加新参数后,原有调用代码必须进行对应的修改,比如像上方的
ListApplications(5,0,"")一样,在 owner参数的位置传递空字符串。
为了解决以上这些问题,常见的做法是引入一个参数结构体struct类型。
结构体传参
新建一个结构体类型,里面包含函数需要支持的所有参数:
1 2 3 4 5 6
| type ListAppsOptions struct { limit int offset int owner string }
|
修改原函数,直接接收该结构体类型作为唯一参数:
1 2 3 4 5 6 7
| 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
| type ListAppsOptions struct { limit int offset int owner string hasDeployed bool }
|
查询函数也做出对应的调整:
1 2 3 4 5 6 7 8 9 10
| 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
| type ListAppsOptions struct { limit int offset int owner string hasDeployed *bool }
|
查询函数也需要做一些调整:
1 2 3 4 5 6 7 8 9 10
| func ListApplications(opts ListAppsOptions) []Application { if opts.hasDeployed == nil { } else { } 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)”。比如 append、fmt.Println 均属此类。
1 2 3
| nums := []int{}
nums = append(nums, 1, 2, 3, 4)
|
为了实现“函数式选项”模式,我们首先修改 ListApplications 函数的签名,使其接收类型为 func(*ListAppsOptions) 的可变数量参数。
1 2 3 4 5 6 7 8 9 10 11
| func ListApplications(opts ...func(*ListAppsOptions)) []Application { config := ListAppsOptions{limit: 10, offset: 0, owner: "", hasDeployed: nil} 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
| 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 的官方 SDK、Kubernetes Client )。
相比“普通传参”和“参数结构体”,“函数式选项”的确有着许多优势,不过我们也不能对其缺点视而不见:
- 需要写更多不算简单的代码来实现
- 相比直白的“参数结构体”,在使用基于“函数式选项”模式的 API 时,用户更难找出所有的可选项,需要花费更多功夫
总的来说,最简单的“普通传参”、“参数结构体”以及“函数式选项”的实现难度和灵活度递增,这几种模式各有其适用的场景。在设计 API 时,我们需要从具体需求出发,优先采用更简单的做法,如无必要,不引入更复杂的传参模式。