Structs are configuration
The “functional options” pattern in Go is very silly and you should not use it.
If you haven’t seen it before, it typically looks like this, with each WithX
function modifying some private field of the server.Server
type returned by server.New()
.
1
2
3
4
5
6
svr := server.New(
server.WithHost("localhost"),
server.WithPort(8080),
server.WithTimeout(time.Minute),
server.WithMaxConn(120),
)
Or, if you’re AWS, for some reason, it looks like this.
1
2
3
client := s3.NewFromConfig(aws.Config{}, func(o *s3.Options) {
o.Region = "us-east-1"
})
Which is even stranger since the configuration type being changed is public, but there are many dubious design decisions in the AWS SDK for Go.
Developers typically discover functional options the following way:
- They have defined a type;
- That type has an associated
New()
method; - There is a variable amount of configuration associated with that type;
- That configuration needs to be passed into
New()
; - Go doesn’t have keyword arguments;
- Google/Bing/DDG the problem;
- Functional options.
Step 4 is where things have gone wrong, because the type already has associated configuration. It came free with your type definition.
Don’t pass additional options into a constructor. Instead, make the user-configurable fields of your type public.
You may have to do a little more work to make this happen, but it will make your API cleaner and your users happier.
Handling defaults
One of the reasons developers may want to hide the true value of a configurable field is because they want to set default values in the type’s New()
method.
But one of the important principles of Go is to make the zero value useful. Plenty of types in Go, such as sync.Mutex
, bytes.Buffer
, strings.Builder
, and the like, are immediately usable without explicit initialization. Perhaps your type can be one of them: it would simplify your API and allow your type to be included in other types, like how sync.Mutex
is used across the stdlib, by simply defining a field.
Consider whether it’s possible to accept the zero value of each field as a reasonable default.
Using the same server example as earlier, it might be reasonable to:
- Interpret an empty
Host
string aslocalhost
. Parts of the Go standard library already do this, so you may not have to do extra work here. - Interpret a
0
Port
as a random available port. This is actually how port 0 is interpreted bybind(2)
, so you may not have to do extra work here. - Interpret a
0
Timeout
as indefinite. This is how Go does it elsewhere. Sometimes it is better not to provide a default, because clients and servers may have different tolerances depending on the work they are doing, and it can lead to apparently-inconsistent behavior if the user is unaware of it. - Interpret a
0
MaxConn
as equal to no limit, for the same reason asTimeout
above.
In cases where the zero value can never be valid, you can write your own method to interpret it as a default on-the-fly, as it’s needed. cmp.Or
makes this especially easy to do.
Handling initialization
If you have some work to do when your type is first constructed, first, it’s worth asking whether a constructor is really the right place to do that work. A new http.Server
does not immediately begin serving requests, nor does an exec.Cmd
immediately run a system command. The declaration of the type is separate from the type performing some behavior.
Don’t underestimate how useful this separation of concerns can be, because once you have a constructor that performs an action, it is very difficult to decouple later. Being able to treat an exec.Cmd
as just data, for instance, is very useful to me as a user of the os/exec
package. Not only does it afford me a lot of flexibility on how the command is run - CombinedOutput()
works differently from Run()
which works differently from Start()
- it also lets me pass the command around before running it. Being able to treat it as data is especially useful for tests, where I might not want the command to execute at all, but I do want to inspect and compare its configuration against what is expected.
If you absolutely must perform some action at initialization time, you still may not need a constructor to do it. For example, you can use a private sync.Once
field to execute a private init()
function exactly one time, in a goroutine-safe way.
If your initialization process can return an error, you can use sync.OnceValue(s)
to consistently return the same error value whenever type methods are called.
Here is an example of an io.WriteCloser
that turns writes into channel sends and has no constructor nor an explicit initialization function.
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
37
38
39
40
41
42
43
44
45
46
47
48
package main
import (
"fmt"
"sync"
"time"
)
type chanWriter struct {
c chan byte
once sync.Once
}
func (w *chanWriter) init() {
w.c = make(chan byte)
}
func (w *chanWriter) Channel() chan byte {
w.once.Do(w.init)
return w.c
}
func (w *chanWriter) Write(p []byte) (int, error) {
w.once.Do(w.init)
for _, b := range p {
w.c <- b
}
return len(p), nil
}
func (w *chanWriter) Close() error {
w.once.Do(w.init)
close(w.c)
return nil
}
var w chanWriter
func main() {
go func() {
fmt.Fprintln(&w, "Hello, world!")
w.Close()
}()
for b := range w.Channel() {
fmt.Print(string(b))
time.Sleep(100 * time.Millisecond) // For illustrative purposes only.
}
}
By spec, it’s not defined whether Write()
or Channel()
will be called first in this program. They may even be called simultaneously. But the sync.Once
employed here lets the initialization be done safely and without expanding the public API of the chanWriter
type.