普通传参模式
Go语言支持按顺序传入参数来调用函数,下面是一个示例函数:
1 | // ListApplications 查询应用列表 |
在调用方调用函数时:
1 | ListApplications(5, 0) |
当后续添加需求需要给函数添加新的入参时,可以直接修改函数签名,但相对应的调用代码也需要跟着改变:
1 | func ListApplications(limit, offset int, owner string) []Application { |
显而易见的,这种普通传参模式存在以下几个明显的问题:
- 可读性不佳:只支持用位置,不支持用关键字来区分参数,当参数变多后,各参数含义很难一目了然;
- 破坏程序的兼容性:当增加新参数后,原有调用代码必须进行对应的修改,比如像上方的
ListApplications(5,0,"")
一样,在owner
参数的位置传递空字符串。
为了解决以上这些问题,常见的做法是引入一个参数结构体struct
类型。
结构体传参
新建一个结构体类型,里面包含函数需要支持的所有参数:
1 | // ListAppsOptions 是查询应用列表时的可选项 |
修改原函数,直接接收该结构体类型作为唯一参数:
1 | // ListApplications 查询应用列表,使用基于结构体的查询选项 |
调用方代码如下:
1 | ListApplications(ListAppsOptions{limit: 5, offset: 0, owner: "piglei"}) |
相比普通模式,使用参数结构体有以下优势:
- 构建参数结构体时,可显式的指定各个参数的字段名,可读性佳
- 对于非必选参数,构建时可以不用传值,比如上面这个例子就省略了
owner
参数
不过,无论是使用普通模式还是参数结构体,都无法支持一个常见的使用场景:真正的可选参数
关于可选参数默认值的一个陷阱
现在有一个新需求,需要在 ListApplications
函数增加一个新选项:hasDeployed
,根据应用是否已部署来过滤结果。
参数结构体调整如下:
1 | // ListAppsOptions 是查询应用列表时的可选项 |
查询函数也做出对应的调整:
1 | // ListApplications 查询应用列表,增加对 HasDeployed 过滤 |
想过滤已部署的应用时,我们可以这么调用:
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 | // ListAppsOptions 是查询应用列表时的可选项 |
查询函数也需要做一些调整:
1 | // ListApplications 查询应用列表,增加对 HasDeployed 过滤 |
在调用函数时,调用方如不指定hasDeployed
字段的值,代码就会进入 if opts.hasDeployed == nil
分支,不做任何过滤:
1 | ListApplications(ListAppsOptions{limit: 5, offset: 0}) |
当调用方想按 hasDeployed
过滤时,可以采用以下的方式:
1 | wantHasDeployed := true |
因为 hasDeployed
如今是指针类型 *bool
,所以我们必须得先创建一个临时变量,然后取它的指针去调用函数。
不得不说,这挺麻烦的对不?有没有一种方式,既能解决前面这些函数传参时的痛点,又能让调用过程不要像“手动造指针”这么麻烦呢?
接下来便该函数式选项(functional options)模式出场了。
函数式选项模式
除了普通传参模式外,Go 语言其实还支持可变数量的参数,使用该特性的函数统称为“可变参数函数(varadic functions)”。比如 append
、fmt.Println
均属此类。
1 | nums := []int{} |
为了实现“函数式选项”模式,我们首先修改 ListApplications
函数的签名,使其接收类型为 func(*ListAppsOptions)
的可变数量参数。
1 | // ListApplications 查询应用列表,使用可变参数 |
然后再定义一系列用于调节选项的工厂函数:
1 | func WithPager(limit, offset int) func(*ListAppsOptions) { |
这些以 With*
命名的工厂函数,通过返回闭包函数,来修改函数选项对象 ListAppsOptions
。
调用时的代码如下:
1 | // 不使用任何参数 |
和使用参数结构体比起来,函数式选项模式有以下特点:
- 更友好的可选参数:比如不再需要手动为
hasDeployed
做取指针操作 - 灵活性更强:可以方便地在每个
With*
函数里追加额外逻辑 - 向前兼容性好,任意增加心的选项都不会影响已有代码
- 更漂亮的API:当参数结构体很复杂时,该模式所提供的API更漂亮,也更好用
不过,直接使用工厂函数实现的函数式选项模式,对于使用方其实算不上太友好。因为每个With*
都是独立的工厂函数,可能分布在各个地方,调用方使用时,很难一站式的找出函数所支持的所有选项。
为了解决这个问题,可以在函数式选项模式的基础做一些小优化:用接口类型去替代工厂函数。
使用接口实现函数式选项
首先,定义一个名为 Option
的接口类型,其中仅包含一个方法 applyTo
:
1 | type Option interface { |
然后,把这批 With*
工厂函数改为各自的自定义类型,并实现 Option
接口:
1 | type WithPager struct { |
做完这些准备工作后,查询函数也要做出相对应的调整:
1 | // ListApplications 查询应用列表,使用可变参数,Option 接口类型 |
调用代码和之前类似,如下所示:
1 | ListApplications(WithPager{limit: 2, offset: 5}, WithOwner("piglei")) |
各个可选项从工厂函数变成 Option
接口后,找出所有可选项变得更方便了,使用 IDE 的“查找接口的实现”就可以轻松完成任务。
总结
看完这些传参模式后,我们会发现“函数式选项”似乎在各方面都是优胜者,它可读性好、兼容性强,好像理应成为所有开发者的首选。而它在 Go 社区中确实也非常流行,活跃在许多流行的开源项目里(比如 AWS 的官方 SDK、Kubernetes Client )。
相比“普通传参”和“参数结构体”,“函数式选项”的确有着许多优势,不过我们也不能对其缺点视而不见:
- 需要写更多不算简单的代码来实现
- 相比直白的“参数结构体”,在使用基于“函数式选项”模式的 API 时,用户更难找出所有的可选项,需要花费更多功夫
总的来说,最简单的“普通传参”、“参数结构体”以及“函数式选项”的实现难度和灵活度递增,这几种模式各有其适用的场景。在设计 API 时,我们需要从具体需求出发,优先采用更简单的做法,如无必要,不引入更复杂的传参模式。