1. General passing of arguments

The Go language supports calling functions by passing arguments sequentially, as shown in the following example function.

1
2
3
4
// ListApplications Query Application List
func ListApplications(limit, offset int) []Application {
    return allApps[offset : offset+limit]
}

Calling code.

1
ListApplications(5, 0)

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.

1
2
3
4
5
6
func ListApplications(limit, offset int, owner string) []Application {
    if owner != "" {
        // ...
    }
    return allApps[offset : offset+limit]
}

The calling code needs to be changed accordingly.

1
2
3
ListApplications(5, 0, "piglei")
// Do not use "owner" filtering
ListApplications(5, 0, "")

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 the owner 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.

1
2
3
4
5
6
// ListAppsOptions is optional when querying the application list
type ListAppsOptions struct {
    limit  int
    offset int
    owner  string
}

Modify the original function to accept this structure type directly as the only argument.

1
2
3
4
5
6
7
// ListApplications Query the application list, using the structure-based query option.
func ListApplications(opts ListAppsOptions) []Application {
    if opts.owner != "" {
        // ...
    }
    return allApps[opts.offset : opts.offset+opts.limit]
}

The calling code is shown below.

1
2
ListApplications(ListAppsOptions{limit: 5, offset: 0, owner: "piglei"})
ListApplications(ListAppsOptions{limit: 5, offset: 0})

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.

1
2
3
4
5
6
7
// ListAppsOptions is optional when querying the application list
type ListAppsOptions struct {
    limit       int
    offset      int
    owner       string
    hasDeployed bool
}

The query function has also been adjusted accordingly.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// ListApplications Query application list, add filtering for HasDeployed
func ListApplications(opts ListAppsOptions) []Application {
    // ...
    if opts.hasDeployed {
        // ...
    } else {
        // ...
    }
    return allApps[opts.offset : opts.offset+opts.limit]
}

When we want to filter the deployed applications, we can call it like this.

1
ListApplications(ListAppsOptions{limit: 5, offset: 0, hasDeployed: true})

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.

1
ListApplications(ListAppsOptions{limit: 5, offset: 0})

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.

1
2
3
4
5
6
7
type ListAppsOptions struct {
    limit  int
    offset int
    owner  string
    // Enable pointer types
    hasDeployed *bool
}

The query function also requires some adjustments.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// ListApplications Query application list, add filtering for HasDeployed
func ListApplications(opts ListAppsOptions) []Application {
    // ...
    if opts.hasDeployed == nil {
        // No filtering by default
    } else {
        // Filter by whether hasDeployed is true or false
    }
    return allApps[opts.offset : opts.offset+opts.limit]
}

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.

1
ListApplications(ListAppsOptions{limit: 5, offset: 0})

When the caller wants to filter by hasDeployed, the following can be used.

1
2
wantHasDeployed := true
ListApplications(ListAppsOptions{limit: 5, offset: 0, hasDeployed: &wantHasDeployed})

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.

1
2
3
nums := []int{}
// When calling append, multiple arguments can be passed
nums = append(nums, 1, 2, 3, 4)

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).

1
2
3
4
5
6
7
8
9
// ListApplications Query the list of applications, using variable arguments
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]
}

Then, a series of factory functions are defined for the adjustment options.

 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
}

These factory functions, named With*, modify the function options object ListAppsOptions by returning the closure function.

The code when called is as follows.

1
2
3
4
5
// No arguments are used
ListApplications()
// Selectively enable certain options
ListApplications(WithPager(2, 5), WithOwner("piglei"))
ListApplications(WithPager(2, 5), WithOwner("piglei"), WithHasDeployed(false))

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.

1
2
3
type Option interface {
    applyTo(*ListAppsOptions)
}

Then, change this batch of With* factory functions to their respective custom types and implement the Option interface.

 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
}

After these preparations have been made, the query function should be adjusted accordingly.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// ListApplications Query application list, using variable arguments, Option interface type
func ListApplications(opts ...Option) []Application {
    config := ListAppsOptions{limit: 10, offset: 0, owner: "", hasDeployed: nil}
    for _, opt := range opts {
        // Adjusting the call method
        opt.applyTo(&config)
    }
    // ...
    return allApps[config.offset : config.offset+config.limit]
}

The calling code is similar to the previous one, as follows.

1
2
ListApplications(WithPager{limit: 2, offset: 5}, WithOwner("piglei"))
ListApplications(WithOwner("piglei"), WithHasDeployed(false))

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.

Ref

  1. Functional options for friendly APIs | Dave Cheney
  2. arguments with Defaults in Go: Functional Options | Charles Xu