Functional Options
2020-08-04
I was looking at ways to do method chaining in Go and found this article from Jon Calhoun about functional options. He made some really good points. I read through it probably three times, and being the dull knife that I am had trouble wrapping my head around it. So I sat down and tried to figure it out. I figured I’d put this post together for anyone who is in the same boat as me and wants a nice simple example of how functional options work.
First let’s define a struct to hold some data. I’ve been using BoltDB recently so let’s create a struct for a BoltDB record and a function to return a new record.
package maintype Record struct { Bucket []byte Key []byte Value []byte}func NewRecord() *Record { return &Record {}}
package maintype Record struct { Bucket []byte Key []byte Value []byte}func NewRecord() *Record { return &Record {}}
Now that we have a pointer to an empty record we can fill it with some values.
package maintype Record struct { Bucket []byte Key []byte Value []byte}func NewRecord() *Record { return &Record {}}func main() { r := NewRecord() r.Bucket = []byte("myBucket") r.Key = []byte("myKey") r.Value = []byte("somevalue")}
package maintype Record struct { Bucket []byte Key []byte Value []byte}func NewRecord() *Record { return &Record {}}func main() { r := NewRecord() r.Bucket = []byte("myBucket") r.Key = []byte("myKey") r.Value = []byte("somevalue")}
I’ve always kind of thought this looked kind of bad but didn’t really know how to make it prettier, other than passing values into the NewRecordNewRecord function or just initializing the struct without the NewRecordNewRecord function. However, now I have an answer.
Functional options are what they sound like, they are options passed to a function that are also functions. So to create a new variable of type Record we can pass functions that set our data for us, but are optional unlike if they were just parameters.
We need to create a helper type and then the functions to modify our record struct:
package maintype Record struct { Bucket []byte Key []byte Value []byte}type RecordOption func(*Record)func NewRecord(opts ...RecordOption) *Record { r := &Record{} for _, opt := range opts { opt(r) } return r}
package maintype Record struct { Bucket []byte Key []byte Value []byte}type RecordOption func(*Record)func NewRecord(opts ...RecordOption) *Record { r := &Record{} for _, opt := range opts { opt(r) } return r}
We changed NewRecordNewRecord to a variadic function that accepts our functional options. We can then range over the options to set our struct fields. Next let’s create our functions to modify our Record.
package maintype Record struct { Bucket []byte Key []byte Value []byte}type RecordOption func(*Record)func NewRecord(opts ...RecordOption) *Record { r := &Record{} for _, opt := range opts { opt(r) } return r}func BucketName(name string) RecordOption { return func(r *Record) { r.Bucket = []byte(name) }}func Key(name string) RecordOption { return func(r *Record) { r.Key = []byte(name) }}func Value(value string) RecordOption { return func(r *Record) { r.Value = []byte(value) }}
package maintype Record struct { Bucket []byte Key []byte Value []byte}type RecordOption func(*Record)func NewRecord(opts ...RecordOption) *Record { r := &Record{} for _, opt := range opts { opt(r) } return r}func BucketName(name string) RecordOption { return func(r *Record) { r.Bucket = []byte(name) }}func Key(name string) RecordOption { return func(r *Record) { r.Key = []byte(name) }}func Value(value string) RecordOption { return func(r *Record) { r.Value = []byte(value) }}
We now have functions to modify the fields in our struct and then return our RecordOption type. The way we call these can look similar to method chaining, but is done as parameters in our function.
record2 := NewRecord( BucketName("myBucket"), Key("myKey"), Value("somevalue"),)
record2 := NewRecord( BucketName("myBucket"), Key("myKey"), Value("somevalue"),)
This looks much cleaner than assigning fields in each line after we call NewRecord()NewRecord(). The following is an example of everything put together. The example includes a function to base64 our data for the value and then a non-existent method to write the record to the database (because I’m too lazy to type all of that out).
package mainimport ( "encoding/base64" "fmt")type Record struct { Bucket []byte Key []byte Value []byte}type RecordOption func(*Record)func NewRecord(opts ...RecordOption) *Record { r := &Record{} for _, opt := range opts { opt(r) } return r}func BucketName(name string) RecordOption { return func(r *Record) { r.Bucket = []byte(name) }}func Key(name string) RecordOption { return func(r *Record) { r.Key = []byte(name) }}func Value(value string) RecordOption { return func(r *Record) { r.Value = []byte(value) }}func toBase64(key string) string { encoded := base64.RawStdEncoding.EncodeToString([]byte(key)) return encoded}func main() { data := ` { color: "red", value: "#f00" }` encoded := toBase64(data) record := NewRecord( BucketName("myBucket"), Key("myKey"), Value(encoded), ) record.WriteRecord()}
package mainimport ( "encoding/base64" "fmt")type Record struct { Bucket []byte Key []byte Value []byte}type RecordOption func(*Record)func NewRecord(opts ...RecordOption) *Record { r := &Record{} for _, opt := range opts { opt(r) } return r}func BucketName(name string) RecordOption { return func(r *Record) { r.Bucket = []byte(name) }}func Key(name string) RecordOption { return func(r *Record) { r.Key = []byte(name) }}func Value(value string) RecordOption { return func(r *Record) { r.Value = []byte(value) }}func toBase64(key string) string { encoded := base64.RawStdEncoding.EncodeToString([]byte(key)) return encoded}func main() { data := ` { color: "red", value: "#f00" }` encoded := toBase64(data) record := NewRecord( BucketName("myBucket"), Key("myKey"), Value(encoded), ) record.WriteRecord()}
Hopefully this at least gives you a little better understanding of how functional options can be built and used in your development.