Original link:https://www.zlovezl.cn/articles/go-func-argument-patterns/
1. Common parameters
The Go language supports calling functions by passing parameters in order. Here is an example function:
// ListApplications 查询应用列表func ListApplications(limit, offset int) []Application { return allApps[offset : offset+limit] }
calling code:
ListApplications(5, 0)
When you want to add new parameters, you can directly modify the function signature. For example, the following code adds a new filter parameter owner
to ListApplications
:
func ListApplications(limit, offset int, owner string) []Application { if owner != "" { // ... } return allApps[offset : offset+limit] }
The calling code also needs to be changed accordingly:
ListApplications(5, 0, "piglei") // 不使用owner 过滤ListApplications(5, 0, "")
Obviously, this common parameter transfer mode has the following obvious problems:
- Poor readability: only the position is supported, and the keyword is not supported to distinguish parameters. After the parameters increase, it is difficult to understand the meaning of each parameter at a glance
- Destruction of compatibility: After adding new parameters, the original calling code must be modified accordingly, for example, like the above
ListApplications(5, 0, "")
, passing an empty string in the position of theowner
parameter
To solve these problems, it is common practice to introduce a parameter struct type.
2. Using the parameter structure
Create a new structure type that contains all the parameters that the function needs to support:
// ListAppsOptions 是查询应用列表时的可选项type ListAppsOptions struct { limit int offset int owner string }
Modify the original function to directly receive the structure type as the only parameter:
// ListApplications 查询应用列表,使用基于结构体的查询选项func ListApplications(opts ListAppsOptions) []Application { if opts.owner != "" { // ... } return allApps[opts.offset : opts.offset+opts.limit] }
The calling code looks like this:
ListApplications(ListAppsOptions{limit: 5, offset: 0, owner: "piglei"}) ListApplications(ListAppsOptions{limit: 5, offset: 0})
Compared with the normal mode, using the parameter structure has the following advantages:
- When building the parameter structure, you can explicitly specify the field name of each parameter, which is more readable
- For non-required parameters, no value can be passed during construction, for example, the
owner
is omitted above
However, neither the normal pattern nor the parameter struct can support a common use case: true optional parameters.
3. Pitfalls hidden in optional parameters
To demonstrate the “optional parameter” problem, we add a new option to the ListApplications
function: hasDeployed
– filter results based on whether the application has been deployed.
The parameter structure is adjusted as follows:
// ListAppsOptions 是查询应用列表时的可选项type ListAppsOptions struct { limit int offset int owner string hasDeployed bool }
The query function is also adjusted accordingly:
// ListApplications 查询应用列表,增加对HasDeployed 过滤func ListApplications(opts ListAppsOptions) []Application { // ... if opts.hasDeployed { // ... } else { // ... } return allApps[opts.offset : opts.offset+opts.limit] }
When we want to filter deployed applications, we can call it like this:
ListApplications(ListAppsOptions{limit: 5, offset: 0, hasDeployed: true})
And when we don’t need to filter by “deployment status”, we can delete hasDeployed
field and call ListApplications
function with the following code:
ListApplications(ListAppsOptions{limit: 5, offset: 0})
Wait… something doesn’t seem right. hasDeployed
is boolean, which means that when we don’t provide it with any value, the program will always use the boolean zero value: false
.
Therefore, the current code can’t actually get the result of “not filtered by deployed state” at all, hasDeployed
is either true
or false
, and there are no other states.
4. Introduce pointer type support optional
In order to solve the above problem, the most direct approach is to introduce a pointer type (pointer type). Unlike normal value types, pointer types in Go have a special zero value: nil
. So just change hasDeployed
from a boolean type ( bool
) to a pointer type ( *bool
) to better support optional parameters:
// ListAppsOptions 是查询应用列表时的可选项type ListAppsOptions struct { limit int offset int owner string // 启用指针类型hasDeployed *bool }
The query function also needs some tweaking:
// ListApplications 查询应用列表,增加对HasDeployed 过滤func ListApplications(opts ListAppsOptions) []Application { // ... if opts.hasDeployed == nil { // 默认不过滤分支} else { // 按hasDeployed 为true 或false 来过滤} return allApps[opts.offset : opts.offset+opts.limit] }
When calling the function, if the caller does not specify the value of the hasDeployed
field, the code will enter the if opts.hasDeployed == nil
branch without any filtering:
ListApplications(ListAppsOptions{limit: 5, offset: 0})
When the caller wants to filter by hasDeployed
, it can use the following methods:
wantHasDeployed := true ListApplications(ListAppsOptions{limit: 5, offset: 0, hasDeployed: &wantHasDeployed})
As you can see, since hasDeployed
is now a pointer type *bool
, we must first create a temporary variable and then take its pointer to call the function.
I have to say, it’s kind of troublesome, isn’t it? Is there a way that can not only solve the pain points of the above functions when passing parameters, but also make the calling process not as troublesome as “manually creating pointers”?
Next comes the functional options pattern.
5. “Functional Options” Mode
In addition to the normal parameter passing mode, the Go language actually supports a variable number of parameters. Functions using this feature are collectively referred to as “variadic functions”. For example, append
and fmt.Println
belong to this category.
nums := []int{} // 调用append 时,传多少个参数都行nums = append(nums, 1, 2, 3, 4)
To implement the “functional options” pattern, we first modify the signature of the ListApplications
function to accept a variable number of arguments of type 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] }
Then, define a series of factory functions for adjusting options:
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 }
These factory functions named With*
modify the function options object ListAppsOptions
by returning a closure function.
The code when called is as follows:
// 不使用任何参数ListApplications() // 选择性启用某些选项ListApplications(WithPager(2, 5), WithOwner("piglei")) ListApplications(WithPager(2, 5), WithOwner("piglei"), WithHasDeployed(false))
Compared with using the “parameter structure”, the “functional option” mode has the following characteristics:
- More friendly optional parameters: for example, it is no longer necessary to manually fetch the pointer for
hasDeployed
- More flexibility: additional logic can be easily added to each
With*
function - Good forward compatibility: any new options added will not affect existing code
- Prettier API: When the parameter structure is complex, the API provided by this pattern is prettier and easier to use
However, the “functional option” mode implemented directly by factory functions is not very friendly to users. Because each With*
is an independent factory function, which may be distributed in various places, it is difficult for the caller to find out all the options supported by the function in one stop when using it.
To solve this problem, people made some small optimizations based on the “functional options” pattern: replacing factory functions with interface types.
6. Implement “functional options” using interfaces
First, define an interface type called Option
that contains only one method, applyTo
:
type Option interface { applyTo(*ListAppsOptions) }
Then, change the batch of With*
factory functions to their own custom types and implement the Option
interface:
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 }
After completing these preparations, the query function should also be adjusted accordingly:
// 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] }
The calling code is similar to before, as follows:
ListApplications(WithPager{limit: 2, offset: 5}, WithOwner("piglei")) ListApplications(WithOwner("piglei"), WithHasDeployed(false))
After each option is changed from a factory function to an Option
interface, it becomes more convenient to find all the options, and the task can be easily accomplished by using the IDE’s “Find the Implementation of the Interface”.
Q: Should “Functional Options” be preferred?
After reading these parameter passing modes, we will find that the “functional option” seems to be the winner in all aspects. It is good in readability and compatibility, and it seems that it should be the first choice of all developers. And it is indeed very popular in the Go community, active in many popular open source projects (such as AWS’s official SDK , Kubernetes Client ).
Compared with “normal parameter passing” and “parameter structure”, “functional option” does have many advantages, but we can’t turn a blind eye to its shortcomings:
- Need to write more code that is not simple to achieve
- Compared with the straightforward “parameter structure”, when using the API based on the “functional options” pattern, it is more difficult for the user to find all the options, and it takes more effort
In general, the implementation difficulty and flexibility of the simplest “common parameter transfer”, “parameter structure” and “functional option” are increasing, and these modes have their own applicable scenarios. When designing the API, we need to start from the specific needs, and give priority to adopting simpler methods, and if unnecessary, do not introduce more complex parameter transfer modes.
refer to
This article is reprinted from:https://www.zlovezl.cn/articles/go-func-argument-patterns/
This site is for inclusion only, and the copyright belongs to the original author.