Optionals
Question
What is the problem with optionals? In python we can just leave fields out and cherry pick ones we want?
Answer
Well lucky you. In go it’s not that simple, we’re left with trade offs when designing APIs that require optionals.
The purpose of this post is the break down the core options to approach this issue.
Self-referential functions
Interface
Go
// User interface looks like this. The user can optional functions that perform some
// modifcation to the interal state of the object, without having to expose the internal structure.
// This could be used to modify a lot of internal state and make complex changes through a simple interface.
pocketlog.New(pocketlog.LevelInfo, pocketlog.WithOutput(os.Stdout))Implementation
Go
type Option func(*Logger)
// WithOutput returns a configuration function that sets the output of logs.
func WithOutput(output io.Writer) Option {
return func(lgr *Logger) {
lgr.output = output // closure: reaches up and grabs output? - idk
}
}
func New(threshold Level, opts ...Option) *Logger {
lgr := &Logger{threshold: threshold, output: os.Stdout}
for _, configFunc := range opts {
configFunc(lgr)
}
return lgr
}Convetion says use With* naming functions mutate the struct
Question
When would I use it?
Answer
Typically when you have several optional constructor-time parameters and want
Newto stay stable as the list grows.
Pros
- signature stays stable as options grow
- minimal surface exposed to the caller
- one option can set several internal fields coherently
Cons
- more code: one exported func per knob
- less discoverable than a struct (options scattered as funcs vs one named type)
- as written (
func(*Logger), no error) it can’t reject bad input; duplicate options silently last-win
Config struct
Interface
Go
// The caller fills one struct and hands it over. Anything left out falls back
// to a default. Every knob is visible in a single value.
pocketlog.New(pocketlog.LevelInfo, pocketlog.Config{
Output: os.Stdout,
MaxMessageRunes: 500,
})Implementation
Go
type Config struct {
Output io.Writer
MaxMessageRunes int
}
func New(threshold Level, cfg Config) *Logger {
lgr := &Logger{threshold: threshold, output: os.Stdout, maxMessageRunes: 1000}
// zero/nil means "leave the default", we have to check each field by hand
if cfg.Output != nil {
lgr.output = cfg.Output
}
if cfg.MaxMessageRunes != 0 {
lgr.maxMessageRunes = cfg.MaxMessageRunes
}
return lgr
}one struct param, handle nil/zero for defaults
Pros
- all knobs visible in one type
- no per-option closures/allocs (just a struct copy)
- reads clearly at the call site
- trivial to pass around / unmarshal from a file or env
Cons
- manual zero/nil check per field to mean “use default”
- zero value is ambiguous: can’t tell “unset” from “explicitly zero”, so the caller can’t set a field to its own zero value (e.g.
MaxMessageRunes: 0)
Usable zero value
Interface
Go
// No constructor at all. The caller builds the struct directly and the zero
// value already does something sensible - like http.Server{Addr: ...}.
lgr := pocketlog.Logger{Threshold: pocketlog.LevelInfo}
lgr.Infof("ready")Implementation
Go
type Logger struct {
Threshold Level
Output io.Writer
MaxMessageRunes int
}
// methods tolerate the zero value and fill defaults lazily
func (l *Logger) out() io.Writer {
if l.Output == nil {
return os.Stdout
}
return l.Output
}exported fields, no constructor, make the user build it
Builder / method chaining
Interface
Go
// New returns a logger, each With* returns the same logger so calls chain.
lgr := pocketlog.New(pocketlog.LevelInfo).
WithOutput(os.Stdout).
WithMaxMessageRunes(500)Implementation
Go
func (l *Logger) WithOutput(w io.Writer) *Logger {
l.output = w
return l // hand the same pointer back so the next call can chain
}
func (l *Logger) WithMaxMessageRunes(n int) *Logger {
l.maxMessageRunes = n
return l
}