Functional Options in Go

Functional Options in Go. Comic required? Optional!

Functional Options in Go. Comic required? Optional!

Hello! My name is Dima, I lead the team developing the core of digital medicine in the Republic of Uzbekistan. Today I want to share my knowledge about a pattern that can significantly simplify your work if you write in Go. We will talk about functional options.

It may seem a bit complicated at first, but the idea and implementation are actually very simple. Trust me, once you figure it out, your code will become a bit more flexible and simpler.

Unfortunately, Go does not have rich functionality for managing “mandatory” and “optional” function arguments, nor does it have a nice way to assign default values ​​to these arguments, but there is a small life hack, and that’s what we’ll talk about today.

Imagine you have a function that has a lot of parameters for configuration. How do you usually write such functions? You create a structure, stuff all the available parameters into it, then write a constructor for it, into which you also stuff all these parameters as arguments. Now, every time you call the function, you have to write the same arguments for configuration each time. This can quickly turn into a nightmare, especially if there are 10, 15 or even 20 of these parameters. It is very easy to get confused or make a mistake in this case, since the function turns into a dump of arguments.

Functional Options in Go. Comic Many Arguments

Functional Options in Go. Comic Many Arguments

Functional options are a pattern that will help you bring order to the chaos of arguments. It allows you to pass parameters as needed. The constructor body sets default values, and then uses the passed functions to modify them.

This approach not only makes the code cleaner, but also significantly simplifies its support. Add a new option? Easy! Remove an unnecessary one? Also not a problem. In general, it is a useful thing, especially when working with large and complex systems.

Enough advertising? Let's now figure out how it works. At an interview, you might be asked: “Is it possible to make a function argument optional in go?”

The obvious answer would be “no”, but it is not. The last argument of the function can be a numbered sequence of arguments (variadic functions). The parameter that accepts such arguments must be placed last in the list, and its type must be preceded by an ellipsis. If we do not specify variadic functions when calling the function, everything will work correctly, here is an example:

package main

func someFn(arg1 int, arg2 string, moreArgs ...bool) {
   // Какая-то важная логика...
}

func main() {
   someFn(1, "2")
   someFn(1, "2", true)
   someFn(1, "2", true, false)
}

What does this give you, besides the fact that you can show off your attentiveness at the interview? It gives you the most important thing! Now the last argument has become optional. Moreover, there may be more than one.

Let's try to imagine that you have to write an HTTP server (pseudo-implementation), here's what will happen if you pass the entire configuration in arguments:

package http

import "time"

type server struct {
   Port       int
   Timeout    time.Duration
   EnableLogs bool
}

func NewServer(port int, timeout time.Duration, enableLogs bool) *server {
   return &server{
       Port:       port,
       Timeout:    timeout,
       EnableLogs: enableLogs,
   }
}
package main

import (
   "time"

   http "habr.com/server"
)

func main() {
   http.NewServer(3000, 3*time.Second, true)
}

It seems good, but let's rewrite the implementation into functional options:

package http

import "time"

type server struct {
   Port       int
   Timeout    time.Duration
   EnableLogs bool
}

type serverOption func(*server)

func WithPort(port int) serverOption {
   return func(s *server) {
       s.Port = port
   }
}

func WithTimeout(timeout time.Duration) serverOption {
   return func(s *server) {
       s.Timeout = timeout
   }
}

func WithLogs(enabled bool) serverOption {
   return func(s *server) {
       s.EnableLogs = enabled
   }
}

func NewServer(opts ...serverOption) *server {
   server := &server{
       Port:       8080,
       Timeout:    60,
       EnableLogs: false,
   }

   for _, opt := range opts {
       opt(server)
   }

   return server
}

Did you notice?

A?

Isn't that cool?

Yes, there is more code in the http package, but how has the call to this function changed? Now there is no need to pass all the arguments each time to start the server. Want to change the port? Please. Need to enable logging? Easy. Moreover, if you run the NewServer function without any parameters at all, it will start perfectly with default values. This makes the code more flexible and easier to maintain.

Few people think about the fact that they might need to add a new configuration parameter. If you don't use functional options, you'll have to change the constructor and refactor all the call sites. However, you're an engineer and you took care of this in advance! Having laid down the functional options, you need to add a parameter to the structure, a default value, a new method, and that's it… Done! In all the places where you've already called this method, everything will work correctly!

From the example above you probably already understood how it works, but let's still go through the implementation. What needs to be done?

1. Define the basic structure. Let's say you have some object that you want to configure with functional options. Start with a simple structure. Let's take Server from the previous example:

type server struct {
   Port       int
   Timeout    time.Duration
   EnableLogs bool
}

2. Now define a type for the options. Typically, this is a function that takes a pointer to an object and modifies its state:

type serverOption func(*Server)

3. Then create functions that return serverOption. Each of them modifies a specific field of the structure. For example:

func WithPort(port int) serverOption {
   return func(s *server) {
       s.Port = port
   }
}

func WithTimeout(timeout time.Duration) serverOption {
   return func(s *server) {
       s.Timeout = timeout
   }
}

func WithLogs(enabled bool) serverOption {
   return func(s *server) {
       s.EnableLogs = enabled
   }
}

Notice that each function returns another function that takes a pointer to a server structure and modifies its fields. This is our main customization mechanism.

4. Implement a constructor. To do this, write a function that will accept functional options. This constructor will first create an object with default values, and then loop through the options and modify it.

func NewServer(opts ...serverOption) *server {
   server := &server{
       Port:       8080,
       Timeout:    60,
       EnableLogs: false,
   }

   for _, opt := range opts {
       opt(server)
   }

   return server
}

The key point here is that we accept a variable number of arguments (functional options) and apply them to the server object. If you don't pass anything, default values ​​will be used.

5. Now let's see how this will work in practice. Let's say you need to create a server with a custom port and enabled logging:

server := NewServer(WithPort(9090), WithLogs(true))

You can pass only those options that are really important. You can leave the default timeout and change only the port and enable logging.

I wrote about flexibility above. Let's try adding SSL support to our server, step by step:

1. Let's add a new field to the structure:

type server struct {
   Port       int
   Timeout    time.Duration
   EnableLogs bool
   WithSSL    bool
}

2. Add a default value (in the case of bool it is not necessary, but for clarity we will add it)

func NewServer(opts ...serverOption) *server {
   server := &server{
       Port:       8080,
       Timeout:    60,
       EnableLogs: false,
       WithSSL:    false,
   }
   // …
   return server
}

3. Let's add a new functional option

func WithSSL(enabled bool) serverOption {
   return func(s *server) {
       s.WithSSL = enabled
   }
}

Voila! Now your server has SSL support, and you didn't have to change any existing code to do it.

Let's sum it up:

+ Flexibility and extensibility;
+ Clean code;
+ Ease of working with default parameters;
+ Ease of testing.

But there are also disadvantages:
– The complexity of code readability increases, especially in the case of a large number of options;
– Possible hidden dependencies of options on each other;
– The need for additional documentation of each option;
– The desire to overcomplicate simple tasks.

Functional options are a really great pattern, but it's not a silver bullet. You'll need to justify why you want to use it first.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *