1. General passing of arguments
The Go language supports calling functions by passing arguments sequentially, as shown in the following example function.
Calling code.
|
|
When you want to add a new argument, you can just change the function signature. For example, the following code adds a new filter argument owner
to ListApplications
.
The calling code needs to be changed accordingly.
Obviously, there are several obvious problems with this common pass-argument model.
- Poor readability: only position is supported, not keywords to distinguish arguments, and the meaning of each argument is difficult to understand at a glance after more arguments are added.
- Disruptive compatibility: after adding new arguments, the original calling code must be modified, such as in the case of
ListApplications(5, 0, "")
above, where the empty string is passed in the position of theowner
argument.
To solve these problems, it is common practice to introduce a argument structure (struct) type.
2. Using argument structs
Create a new structure type containing all the arguments that the function needs to support.
Modify the original function to accept this structure type directly as the only argument.
The calling code is shown below.
There are several advantages of using argument structures compared to the normal model.
- When constructing a argument structure, you can explicitly specify the field name of each argument, which is more readable.
- For non-essential arguments, you can build them without passing values, such as omitting
owner
above.
However, there is a common usage scenario that is not supported by either the normal schema or argument structs: truly optional arguments.
3. The trap hidden in optional arguments
To demonstrate the problem of “optional arguments”, we add a new option to the ListApplications
function: hasDeployed
- which filters the results based on whether the application has been deployed or not.
The argument structure is adjusted as follows.
The query function has also been adjusted accordingly.
When we want to filter the deployed applications, we can call it like this.
|
|
And when we don’t need to filter by “deployment status”, we can remove the hasDeployed
field and call the ListApplications
function with the following code.
|
|
Wait …… something doesn’t seem right. hasDeployed
is a boolean type, which means that when we don’t provide any value for it, the program will always use the zero value of the boolean type: false
.
So, the code now doesn’t actually get the “not filtered by deployed status” result at all, hasDeployed
is either true
or false
and no other status exists.
4. Introduce pointer type support optionally
To solve the above problem, the most straightforward approach is to introduce a pointer type. Unlike normal value types, pointer types in Go have a special zero value: nil
. So, simply changing hasDeployed
from a boolean type (bool
) to a pointer type (*bool
) will allow better support for optional arguments.
The query function also requires some adjustments.
|
|
When calling a function, if the caller does not specify the value of the hasDeployed
field, the code goes to the if opts.hasDeployed == nil
branch without any filtering.
|
|
When the caller wants to filter by hasDeployed
, the following can be used.
In golang, you can actually create a non-nil pointer variable quickly in the following way.
1
ListAppsOptions{limit: 5, offset: 0, hasDeployed: &[]bool{true}[0]}
As you can see, since hasDeployed
is now a pointer type *bool
, we have to create a temporary variable first and then take its pointer to call the function.
Needless to say, this is quite a hassle, isn’t it? Is there a way to solve the above pain points when passing function arguments, but not make the calling process as cumbersome as “manually building pointers”?
Then it’s time for the functional options pattern to come into play.
5. The “function option” mode
In addition to the normal pass-argument mode, Go actually supports a variable number of arguments, and functions that use this feature are collectively called “variadic functions”. For example, append
and fmt.Println
are in this category.
To implement the “functional options” pattern, we first modify the signature of the ListApplications
function to take a variable number of arguments of type func(*ListAppsOptions)
.
|
|
Then, a series of factory functions are defined for the adjustment options.
|
|
These factory functions, named With*
, modify the function options object ListAppsOptions
by returning the closure function.
The code when called is as follows.
Compared to the use of “argument structures”, the “functional options” model has the following features.
- More friendly optional arguments: for example, no more manual fetching of pointers for
hasDeployed
. - More flexibility: additional logic can be easily appended to each
With*
function - Good forward compatibility: add any new option without affecting existing code
- prettier API: when the argument structure is complex, the API provided by this pattern is prettier and more usable
However, the “functional options” pattern, implemented directly with factory functions, is not really very user-friendly. Because each With*
is a separate 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 place.
To solve this problem, some minor optimizations have been made to the “functional options” pattern: replacing factory functions with Interface types.
6. Implementing “functional options” using interfaces
First, define an interface type called Option
, which contains only one method applyTo
.
Then, change this batch of With*
factory functions to their respective custom types and implement the Option
interface.
|
|
After these preparations have been made, the query function should be adjusted accordingly.
|
|
The calling code is similar to the previous one, as follows.
Once the options have been changed from factory functions to Option
interfaces, it becomes easier to find all the options and use the IDE’s Find Interface Implementation
to do the job easily.
Q: Should I give preference to “functional options”?
After looking at these argument passing patterns, we see that “functional options” seems to be the winner in every way; it’s readable, it’s compatible, and it seems like 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 (e.g., AWS’ official SDK, Kubernetes Client).
The “function option” does have many advantages over “normal passing” and “argument structs”, but we can’t ignore the disadvantages.
- requires more not-so-simple code to implement
- It is more difficult for the user to find all the available options when using an API based on the “functional options” pattern than with a straightforward “argument structure”, and requires more effort
In general, the simplest “ordinary argument passing”, “argument structs” and “functional options” are increasingly difficult and flexible to implement, and each of these modes has its own applicable scenarios. When designing APIs, we need to prioritize the simpler approach based on specific requirements and not introduce more complex passing patterns if not necessary.