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)”。比如 append
、fmt.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 的官方 SDK、Kubernetes Client )。
相比“普通传参”和“参数结构体”,“函数式选项”的确有着许多优势,不过我们也不能对其缺点视而不见:
- 需要写更多不算简单的代码来实现
- 相比直白的“参数结构体”,在使用基于“函数式选项”模式的 API 时,用户更难找出所有的可选项,需要花费更多功夫
总的来说,最简单的“普通传参”、“参数结构体”以及“函数式选项”的实现难度和灵活度递增,这几种模式各有其适用的场景。在设计 API 时,我们需要从具体需求出发,优先采用更简单的做法,如无必要,不引入更复杂的传参模式。
参考
😊 如果你喜欢这篇文章,也欢迎了解我的书: 《Python 工匠:案例、技巧与工程实践》 。它专注于编程基础素养与 Python 高级技巧的结合,是一本广受好评、适合许多人的 Python 进阶书。