BK
Back to blog

Functional options may not always form closures

#golang

This post builds on our previous discussion, where we took a deep dive into closures using the Functional Options Pattern. Please refer to it before going through this.

Here, we'd be taking it a little bit further.

We saw how something like WithHost("localhost") returned a function that still had access to host when that function was called later.

At this point, it would be tempting for you to think that:

Functional Options = closures.

But...that's not quite right. Why?

Closures are one way to use the functional options pattern, but they're not a requirement.

I discovered this myself when I was poking around the AWS SDK for Go. It has a WithPresignExpires option that looks very similar to the closure-based example we discussed in my previous post, but there's something different going on underneath.

So, let's take a look.

func generatePresignedURL(s3Client *s3.Client, bucket, key string, expireTime time.Duration) (string, error) {
    presignClient := s3.NewPresignClient(s3Client)

    objectInput := &s3.GetObjectInput{
        Bucket: &bucket,
        Key:    &key,
    }

    presignedHTTPRequest, err := presignClient.PresignGetObject(context.Background(), objectInput, s3.WithPresignExpires(expireTime))
    if err != nil {
        return "", err
    }

    return presignedHTTPRequest.URL, nil
}

Here, I was working on a function that returns presigned URL for an object in an s3 bucket, so I needed to use PresignGetObject from the SDK. I want you to focus on the last argument that PresignGetObject takes. Notice the With... pattern.

You might expect that it returns a closure just like we discussed in the previous post. But notice what I found when I poked around:

func WithPresignExpires(dur time.Duration) func(*PresignOptions) {
    return withPresignExpires(dur).options
}

type withPresignExpires time.Duration

func (w withPresignExpires) options(o *PresignOptions) {
    o.Expires = time.Duration(w)
}

WithPresignExpires doesn't return a closure!

So, what does it return? We learnt that closures help functions remember stuff in their lexical scope right, so how does dur survive after WithPresignExpires is eventually called? There's no closure here, so what's up?

Enter Methods as Values

We'd come back to functional options. Let's take a little detour if you don't mind.

In Go, methods are simply functions that are associated with a type.

For example:

// a type
type Server struct {
    host string
}

// a method
func (s *Server) setHost(host string) {
    s.host = host
}

Notice it looks like a function but with some special kind of backpack (called it's receiver).

Normally, we expect to call the method directly like so:

// type initialized
server := &Server{}

// you have called me now
server.setHost("localhost")

But, hey! There's something else we can do.

We can take the method and bind it to it's receiver, and then assign it to a variable rather than call it now:

// you can invoke me later
setHost := server.setHost

Now setHost is a variable that holds a method value that we can call later:

// invoking the method value we stored
setHost("localhost")

So what's going on here?

When we write:

setHost := server.setHost

the server struct value is already attached/binded/glued to the setHost method we defined earlier.

That's what a method value is: a method with its receiver already bound to it, which you can call later.

Note that the name of the variable that keeps the method value can be anything. But I think it just makes sense to name it according to the method name, so when you call it later, you easily know what you're calling. That's why I named the variable setHost to match the method setHost. But you could have done:

anotherName := server.setHost

anotherName("localhost")

Back to the AWS example, shall we?!

Now let's look at this line again:

...
return withPresignExpires(dur).options
}

type withPresignExpires time.Duration

func (w withPresignExpires) options(o *PresignOptions) {
    o.Expires = time.Duration(w)
}

We can break it down.

First, withPresignExpires(dur) converts the dur value of type time.Duration to a value of type withPresignExpires (a custom type).

And when we write withPresignExpires(dur).options we're simply binding the options method to the value we just converted (a.k.a it's receiver), which we can call later.

So conceptually:

option := withPresignExpires(dur).options

means:

"Keep this method value inside an option variable, so I can call it later."

And when it runs, the receiver w is still the withPresignExpires value created from dur.

Notice the difference between calling the method immediatley and storing, then calling later:

// called immediately
withPresignExpires(dur).options(presignOptions)

// saved in a variable and called later
option := withPresignExpires(dur).options
option(presignOptions)

Let's call it a wrap

Unlike our previous closure example, where reference to a value is captured inside an enclosing function. Here, dur isn't being captured from an enclosing function scope. The dur value became the receiver of the option method. And anything we were ready to call the method value, the dur value (method receiver) we created is still alive and would be referenced by the method.

So the key lesson here is that: closures remember variables from an enclosing scope, while method values carry around their receivers with them.

Both of them allow you produce a function that you can pass around, remember values, and call later. And that's exactly what the functional optional pattern needs.

ClosureMethod value
Function remembers variables from an enclosing scopeFunction has a receiver attached to it
func(s *Server) { s.host = host }withPresignExpires(dur).options

The functional options pattern doesn't require closures specifically. It simply requires functions that can be used to configure something. Closures and method values are just two different ways to produce those functions.

See you in the next one. :)