Functional options on steroids

Hello, Habr! I present to you the translation of the article Functional options on steroids by Márk Sági-Kazár .

image

Functional options are Go's paradigm for clean and extensible APIs. She is popularized by Dave Cheney and Rob Pike . This post is about practices that have been around the template since it was first introduced.

Functional options have emerged as a way to create good and clean APIs with a configuration that includes optional parameters. There are many obvious ways to do this (constructor, configuration structure, setters, etc.), but when you need to pass dozens of options, they are poorly read and do not give such good APIs as functional options.

Introduction - what are functional options?


Usually, when you create an “object”, you call the constructor with the necessary arguments:

obj := New(arg1, arg2)

(Let's ignore the fact that there are no traditional constructors in Go.)

Functional options allow you to extend the API with additional parameters, turning the above line into the following:

// I can still do this...
obj := New(arg1, arg2)

// ...but this works too
obj := New(arg1, arg2, myOption1, myOption2)

Functional options are basically arguments of a variable function that takes as parameters a composite or intermediate configuration type. Due to its variable nature, it is perfectly acceptable to call the constructor without any options, keeping it clean, even if you want to use the default values.

To better demonstrate the pattern, let's take a look at a realistic example (without functional options):

type Server struct {
    addr string
}

// NewServer initializes a new Server listening on addr.
func NewServer(addr string) *Server {
    return &Server {
        addr: addr,
    }
}

After adding the timeout option, the code looks like this:

type Server struct {
    addr string

    // default: no timeout
    timeout time.Duration
}

// Timeout configures a maximum length of idle connection in Server.
func Timeout(timeout time.Duration) func(*Server) {
    return func(s *Server) {
        s.timeout = timeout
    }
}

// NewServer initializes a new Server listening on addr with optional configuration.
func NewServer(addr string, opts ...func(*Server)) *Server {
    server := &Server {
        addr: addr,
    }

    // apply the list of options to Server
    for _, opt := range opts {
        opt(server)
    }

    return server
}

The resulting API is easy to use and easy to read:

// no optional paramters, use defaults
server := NewServer(":8080")

// configure a timeout in addition to the address
server := NewServer(":8080", Timeout(10 * time.Second))

// configure a timeout and TLS in addition to the address
server := NewServer(":8080", Timeout(10 * time.Second), TLS(&TLSConfig{}))


For comparison, here is what initialization looks like using the constructor and using the config configuration structure:

// constructor variants
server := NewServer(":8080")
server := NewServerWithTimeout(":8080", 10 * time.Second)
server := NewServerWithTimeoutAndTLS(":8080", 10 * time.Second, &TLSConfig{})


// config struct
server := NewServer(":8080", Config{})
server := NewServer(":8080", Config{ Timeout: 10 * time.Second })
server := NewServer(":8080", Config{ Timeout: 10 * time.Second, TLS: &TLSConfig{} })

The advantage of using functional options over the constructor is probably obvious: they are easier to maintain and read / write. Functional parameters also exceed the configuration structure when parameters are not passed to the constructor. In the following sections I will show even more examples where the configuration structure may not live up to expectations.

Read the full history of functional options by clicking on the links in the introduction. (translator's note - a blog with an original article by Dave Cheney is not always available in Russia and you may need to connect via VPN to view it. Also, information from the article is available in the video of his report on dotGo 2014 )

Functional Options Practices


Functional options themselves are nothing more than functions passed to the constructor. Ease of use of simple functions gives flexibility and has great potential. Therefore, it is not surprising that over the years a lot of practices have appeared around the template. Below I will provide a list of what I consider the most popular and useful practices.

Email me if you think something is missing.

Type of options


The first thing you can do when applying the functional options template is to determine the type for the optional function:

// Option configures a Server.
type Option func(s *Server)

Although this may not seem like a big improvement, the code becomes more readable by using a type name instead of defining a function:

func Timeout(timeout time.Duration) func(*Server) { /*...*/ }

// reads: a new server accepts an address
//      and a set of functions that accepts the server itself
func NewServer(addr string, opts ...func(s *Server)) *Server

// VS

func Timeout(timeout time.Duration) Option { /*...*/ }

// reads: a new server accepts an address and a set of options
func NewServer(addr string, opts ...Option) *Server

Another advantage of having an option type is that Godoc places option functions under the type:

image

Option Type List


Typically, functional parameters are used to create a single instance of something, but this is not always the case. Reusing the default parameter list when creating multiple instances is also not uncommon:

defaultOptions := []Option{Timeout(5 * time.Second)}

server1 := NewServer(":8080", append(defaultOptions, MaxConnections(10))...)

server2 := NewServer(":8080", append(defaultOptions, RateLimit(10, time.Minute))...)

server3 := NewServer(":8080", append(defaultOptions, Timeout(10 * time.Second))...)

This is not very readable code, especially considering that the point of using functional options is to create friendly APIs. Fortunately, there is a way to simplify this. We just need to slice the [] Option directly with the option:

// Options turns a list of Option instances into an Option.
func Options(opts ...Option) Option {
    return func(s *Server) {
        for _, opt := range opts {
            opt(s)
        }
    }
}

After replacing the slice with the Options function, the above code becomes:

defaultOptions := Options(Timeout(5 * time.Second))

server1 := NewServer(":8080", defaultOptions, MaxConnections(10))

server2 := NewServer(":8080", defaultOptions, RateLimit(10, time.Minute))

server3 := NewServer(":8080", defaultOptions, Timeout(10 * time.Second))

Prefixes of the With / Set Options


Options are often complex types, unlike a timeout or maximum number of connections. For example, in the server package, you can define the Logger interface as an option (and use it if there is no default logger):

type Logger interface {
    Info(msg string)
    Error(msg string)
}

Obviously, the Logger name cannot be used as the option name, as it is already used by the interface. You can call the option LoggerOption, but it's not a very friendly name. If you look at the constructor as a sentence, in our case the word with: WithLogger comes to mind.

func WithLogger(logger Logger) Option {
    return func(s *Server) {
        s.logger = logger
    }
}

// reads: create a new server that listens on :8080 with a logger
NewServer(":8080", WithLogger(logger))

Another common example of a complex type option is a slice of values:

type Server struct {
    // ...

    whitelistIPs []string
}

func WithWhitelistedIP(ip string) Option {
    return func(s *Server) {
        s.whitelistIPs = append(s.whitelistIPs, ip)
    }
}

NewServer(":8080", WithWhitelistedIP("10.0.0.0/8"), WithWhitelistedIP("172.16.0.0/12"))

In this case, the option usually appends the values ​​to existing ones, rather than overwriting them. If you need to overwrite an existing set of values, you can use the word set in the option name:

func SetWhitelistedIP(ip string) Option {
    return func(s *Server) {
        s.whitelistIPs = []string{ip}
    }
}

NewServer(
    ":8080",
    WithWhitelistedIP("10.0.0.0/8"),
    WithWhitelistedIP("172.16.0.0/12"),
    SetWhitelistedIP("192.168.0.0/16"), // overwrites any previous values
)

Preset Template


Special use cases are usually universal enough to support them in the library. In the case of a configuration, this may mean a set of parameters grouped together and used as a preset for use. In our example, the Server can have open and internal usage scenarios that set timeouts, speed limits, number of connections, etc. in different ways.

// PublicPreset configures a Server for public usage.
func PublicPreset() Option {
    return Options(
        WithTimeout(10 * time.Second),
        MaxConnections(10),
    )
}

// InternalPreset configures a Server for internal usage.
func InternalPreset() Option {
    return Options(
        WithTimeout(20 * time.Second),
        WithWhitelistedIP("10.0.0.0/8"),
    )
}

Although presets may be useful in some cases, they are probably of great value in internal libraries, and in public libraries their value is less.

Default Type Values ​​vs Default Preset Values


Go always has default values. For numbers, this is usually zero, for Boolean types, it is false, and so on. In an optional configuration, it is considered good practice to rely on the default values. For example, a null value should mean an unlimited timeout instead of its absence (which is usually meaningless).

In some cases, the default type value is not a good default value. For example, the default value for Logger is nil, which can lead to panic (if you do not protect the logger calls with conditional checks).

In these cases, setting the value in the constructor (before applying the parameters) is a good way to determine the fallback:

func NewServer(addr string, opts ...func(*Server)) *Server {
    server := &Server {
        addr:   addr,
        logger: noopLogger{},
    }

    // apply the list of options to Server
    for _, opt := range opts {
        opt(server)
    }

    return server
}

I saw examples with default presets (using the template described in the previous section). However, I do not consider this a good practice. This is much less expressive than just setting default values ​​in the constructor:

func NewServer(addr string, opts ...func(*Server)) *Server {
    server := &Server {
        addr:   addr,
    }

    // what are the defaults?
    opts = append([]Option{DefaultPreset()}, opts...)

    // apply the list of options to Server
    for _, opt := range opts {
        opt(server)
    }

    return server
}

Configuration structure option


Having a Config structure as a functional option is probably not as common, but this is sometimes found. The idea is that the functional options refer to the configuration structure instead of referring to the actual object being created:

type Config struct {
    Timeout time.Duration
}

type Option func(c *Config)

type Server struct {
    // ...

    config Config
}

This template is useful when you have many options, and creating a configuration structure seems cleaner than listing all the options in a function call:

config := Config{
    Timeout: 10 * time.Second
    // ...
    // lots of other options
}

NewServer(":8080", WithConfig(config))

Another use case for this template is to set default values:

config := Config{
    Timeout: 10 * time.Second
    // ...
    // lots of other options
}

NewServer(":8080", WithConfig(config), WithTimeout(20 * time.Second))

Advanced Templates


After writing dozens of functional options, you may wonder if there is a better way to do this. Not in terms of use, but in terms of long-term support.

For example, what if we could define types and use them as parameters:

type Timeout time.Duration

NewServer(":8080", Timeout(time.Minute))

(Note that the use of the API remains the same)

It turns out that by changing the Option type, we can easily do this:

// Option configures a Server.
type Option interface {
    // apply is unexported,
    // so only the current package can implement this interface.
    apply(s *Server)
}

Overriding an optional function as an interface opens the door for a number of new ways to implement functional options:

Various built-in types can be used as parameters without a wrapper function:

// Timeout configures a maximum length of idle connection in Server.
type Timeout time.Duration

func (t Timeout) apply(s *Server) {
    s.timeout = time.Duration(t)
}

Lists of options and configuration structures (see previous sections) can also be redefined as follows:

// Options turns a list of Option instances into an Option.
type Options []Option

func (o Options) apply(s *Server) {
    for _, opt := range o {
        o.apply(s)
    }
}

type Config struct {
    Timeout time.Duration
}

func (c Config) apply(s *Server) {
    s.config = c
}

My personal favorite is the ability to reuse the option in multiple constructors:

// ServerOption configures a Server.
type ServerOption interface {
    applyServer(s *Server)
}

// ClientOption configures a Client.
type ClientOption interface {
    applyClient(c *Client)
}

// Option configures a Server or a Client.
type Option interface {
    ServerOption
    ClientOption
}

func WithLogger(logger Logger) Option {
    return withLogger{logger}
}

type withLogger struct {
    logger Logger
}

func (o withLogger) applyServer(s *Server) {
    s.logger = o.logger
}

func (o withLogger) applyClient(c *Client) {
    c.logger = o.logger
}

NewServer(":8080", WithLogger(logger))
NewClient("http://localhost:8080", WithLogger(logger))

Summary


Functional parameters is a powerful template for creating clean and extensible APIs with dozens of parameters. Although this does a little more work than supporting a simple configuration structure, it provides much more flexibility and provides much cleaner APIs than alternatives.

All Articles