Parameters with Defaults in Go: Functional Options

Parameters with Defaults in Go: Functional Options

Unlike C++ or Python, Go does not support function parameters with default values if unspecified. Specifically, we want that

  • Passing multiple parameters is supported.
  • Interface remains backward-compatible when the number of type of one or more parameter changes.
  • Parameters have default values that can be overridden.

In search of a general and elegant solution to this problem, we present a few straw-man approaches as motivations for the functional options and With*() pattern, which are presented last.

Straw-man

Update function signature to accept more inputs

Suppose you have the following Foo struct with the most basic constructor New().

1
2
3
4
5
6
7
8
9
10
11
12
type Foo struct {
  num int
  str string
}

func New(num int, str string) *Foo {
  // ... initialization
  return &Foo{
    num: num,
    str: str,
  }
}

Imagine that we want to add more fields to Foo and therefore to change the constructor into func New(num, num2 int, str, str2 string, bar *Bar) *Foo. Not only is this function incompatible with existing calls, it also becomes less readable as we add more parameters.

Keep the old functions

1
2
3
4
5
6
7
8
9
type Foo struct {
  num, num2 int
  str, str2 string
  bar       *Bar
}

func New(num int, str string) *Foo { /* ... */ }

func New2(num, num2 int, str, str2 string, bar *Bar) *Foo { /* ... */ }

We could keep the old functions while adding new ones to support more fields, but it also means the total number of functions grows exponentially (power of 2) with the number of parameters, since each parameter could be included or excluded in a function. Our file would soon become unmaintainable.

Putting all params in a struct

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
type Foo struct {
  Option
}

type Option struct {
  Num int
  Str string
}

func New(opt Option) *Foo {
  // Default values for Foo.
  foo := &Foo{
    Num: 10,
    Str: "hello"
  }

  // Set overwrites.
  if opt.Num != 0 {
    foo.Num = opt.Num
  }
  if opt.Str != "" {
    foo.Str = opt.Str
  }
  return foo
}

Doing so addresses the compatibility issue and seems to achieve default values. However, it is impossible to determine whether the function caller explicitly sets opt.Num to zero or did not specify Num at all and therefore using the default value 10.

In fact, such confusion around zero-value as input happens not just for Option struct but whenever and whatever parameters are passed directly to functions.

Passing a struct pointer

1
2
3
4
5
6
func New(opt *Option) *Foo {
  if opt == nil {
    // Use all values in opt to create Foo.
  }
  // Use all default values.
}

The nil pointer is a good way distinguish set vs unset. However, The new problem is that either all fields need to use the default values or none of them does. Hence, if the user only wants to overwrite one parameter and use defaults for the rest, the user must supply the default values for other fields explicitly. Yikes.

Make all fields pointers

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type Option struct {
  num *int
  str *string
}

func New(opt Option) *Foo {
  foo := newFooWithDefaults()
  if option.num == nil {
    foo.num = opt.num
  }
  if option.str == nil {
    foo.str = opt.str
  }
}

Since the nil pointer is a good way distinguish set vs unset, making all the fields a pointer seems to do the trick. However, this is not user-friendly at all, because in Go there is no such thing as &20 or &"hello"and the call must assign the literal value to a temporary variable and then take its address. Not pretty.

1
2
3
4
5
6
7
num := 20
str := "hello"
opt := {
    num: &num,
    str: &str,
}
foo := New(opt)

Variadic functions

1
2
3
4
5
6
7
func New(num int, str string, num2 ...int) {
  if len(num2) == 0 {
    // Did not provide num2, use default.
  } else {
    // Use num2[0].
  }
}

This alternative approach only works with one optional parameter with dirty semantics, such as the behavior if len(num2) > 1.

Functional Options

Finally, we arrive at the functional options pattern that solves optional params (or params with defaults) nicely.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
type Foo struct {
    Num int
    Str string
}

type Option func(f *Foo)

func WithNum(num int) Option {
  return func(f *Foo) {
    f.Num = num
  }
}

func WithStr(str string) Option {
  return func(f *Foo) {
    f.Str = str
  }
}

func New(someRequiredField string, opts ...Option) *Foo {
  foo := &Foo{
    Num: 10,
    Str: "hello",
  }

  for _, applyOpt := range opts {
    applyOpt(foo)
  }

  return &foo
}

func main() {
  foo := New("important", WithNum(30))
  foo = New("required", WithNum(20), WithStr("hello"))
}

More Go Tips

Love what you are reading? My wiki pages have more battle-tested Go lessons: