uber-go / guide

The Uber Go Style Guide.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Guideline: For functional options, don't recommend closures

abhinav opened this issue · comments

Our recommendation right now includes this pattern,

type Option interface{ apply(*options) }

type optionFunc func(*options)

And then all options are implemented using optionFunc and closures.

func WithTimeout(d time.Duration) Option {
  return optionFunc(func(o *options) {
    o.Timeout = d
  })
}

This is not the pattern we should be encouraging because it makes Options
too opaque. We cannot print them or compare them, or introspect values inside
them.

We should instead recommend declaring a new type for each option.

type timeoutOption time.Duration

func WithTimeout(t time.Duration) Option {
  return timeoutOption(t)
}

func (o timeoutOption) apply(opts *options) {
  opts.Timeout = time.Duration(o)
}

// And

type loggerOption struct{ L *zap.Logger }

func WithLogger(l *zap.Logger) Option {
  return loggerOption{L: l}
}

func (o *loggerOption) apply(opts *options) {
  o.Logger = o.L
}

To justify the boilerplate for interface versus type Option func(*options),
that version has the following issues:

  • godoc looks bad because it shows func(*options) in the signature
  • The signature is now permanently tied to being a closure (which has the
    disadvantages of closures above)
  • Without interfaces, we can never rely on upcasting to support additional
    functionality in the future

I think the struct approach is definitely the better approach when we need introspection of the options, but it's also pretty heavy, and the above justifications no longer apply with the following approach:

type Option interface {
  apply(*opts)
}

type optionFunc func(*opts)

func (f optionFunc) apply(o *opts) {
  f(o)
}

This doesn't give us introspection, but it does avoid the issues raised above, while still reducing a lot of boilerplate for each option.

I think the struct approach is definitely the better approach when we need introspection of the options, but it's also pretty heavy, and the above justifications no longer apply with the following approach:

type Option interface {
  apply(*opts)
}

type optionFunc func(*opts)

func (f optionFunc) apply(o *opts) {
  f(o)
}

This doesn't give us introspection, but it does avoid the issues raised above, while still reducing a lot of boilerplate for each option.

One of the issues in the above is that the optionFunc type cannot be compared with reflect.DeepEqual. Having this property to the options allows to map a config into a slice of options for passing into the constructor and having such test allows better tests for library users.

Whilst I agree that the amount of boilerplate when writing options without closures is not ideal, the extra 5 lines (which are in general simple to understand) gives us better (or more flexible) tests.

Correct me if I am missing something here. :)

Yes, comparison is called out in the original issue and I considered it part of "need introspection of the options". That said, it doesn't seem worth it to call out when it's OK to use the function vs structs, so I'm ok with keeping the guidance simple and always recommending a struct.

It's a little heavier, but it does make things more consistent to have a single way of doing functional options.