Different Ways to Initialize Go structs
Categories:
8 minute read
Many languages like Java, C#, etc. have constructors
that help you instantiate an object and its properties.
Go does not have ‘constructors’ in the pure sense of the word. Let’s see what Go has instead.
Build-in options
Out-of-the-box Go gives us 2 ways to initialize structs - struct literals and the new
build-in function.
Let’s see what these look like for a simple struct named Person
:
package people
type Person struct {
age int
name string
}
// struct literal
person := &Person{
age: 25,
name: "Anton",
}
// new build-in
person := new(Person) // person is of type *Person
person.age = 25
person.name = "Anton"
However, in this scenario the Person
fields are un-exported, thus they cannot be used outside of the people
package. We can easily mitigate this by exporting them:
// people/person.go
package people
type Person struct {
Name string
Age int
}
// main.go
package main
import "asankov.dev/people"
// struct literal
person := &people.Person{
Name: "Anton",
Age: 25,
}
// new build-in
person := new(people.Person) // p is of type *people.Person
person.Name = "Anton"
person.Age = 25
But both of these options contain a big trade-off - now all the fields of the person can be viewed and changed by anyone.
Also, we have no validation.
For example, in this scenario, we can easily set the Age
property to a negative value.
So what can we do if we don’t want to export the fields or enforce additional validation?
Constructor function
As I said in the beginning, Go does not have constructors. However, that does not mean we cannot define one ourselves. Let’s see how a user-defined constructor can look like:
With positional arguments
We can define a constructor function where the struct fields are positional arguments of the function:
package people
func NewPerson(name string, age int) *Person {
if age < 0 {
panic("NewPerson: age cannot be a negative number")
}
return &Person{name: name, age: age}
}
This provides us 2 benefits:
- We have validation for the
age
field. If someone tried to create aPerson
with a negative age, they will get a panic with a descriptive error of what they did wrong (we could have also returned an error, but this does not matter for the topic at hand). In the same manner, we could add validation for thename
field (for example, that it’s not empty). - The
Person
internals are decoupled from the initialization logic. For example, we can add anisUnderage
field to the struct and compute that based onage
inside of theNewPerson
function. The consumer of the function won’t have to be bothered with the logic behind this field, because we are taking care of it.
But it also has some downsides. If our Person
had many string
fields (for example - multiple names or addresses) this function would have looked like that:
func NewPerson(firstName, lastName, addressLine1, addressLine2 string, age int) *Person
and having a function with multiple parameters of the same type is a recipe for disaster. In this case, the danger is that the consumer of this function can easily misplace any of the parameters (for example, replace the first and last names) and they would have no compile-time check for that.
// is it?
p := NewPerson("Anton", "Sankov", "Bulgaria", "Sofia", 25)
// or?
p := NewPerson("Anton", "Sankov", "Sofia", "Bulgaria", 25)
// or?
p := NewPerson("Anton", "Bulgaria", "Sofia", "Sofia str, number 25", 25)
Another, even bigger issue than that is backward compatibility.
Let’s say that we need to add additional fields to our Person
struct. For example, salary
(money rules the world, don’t they).
We can easily add it:
package people
type Person struct {
age int
name string
salary float64
}
// change
// func NewPerson(name string, age int) *Person {
// to:
func NewPerson(name string, age int, salary float64) *Person {
if age < 0 || salary < 0 {
panic("NewPerson: age and salary cannot be negative numbers")
}
return &Person{name: name, age: age, salary: salary}
}
However, this is a breaking change to the NewPerson
method. If this were a library, all of the consumers would get compilation errors when they upgrade to the new version. This is definitely not ideal and could have a side-effect of your consumers screaming at you on Twitter.
Is there a way to make this without breaking change?
Yes, of course.
To do this we need to restructure our NewPerson
function.
Let’s see what are our options.
With Options struct
We can define PersonOptions
struct that mirrors the Person
but with exported field and pass this to NewPerson
.
Let’s see how this looks like:
package people
type Person struct {
age int
name string
salary float64
}
type PersonOptions struct {
Age int
Name string
Salary float64
}
func NewPerson(opts *PersonOptions) *Person {
if opts == nil || opts.Age < 0 || opts.Salary < 0 {
panic("NewPerson: age and salary cannot be negative numbers")
}
return &Person{name: opts.Name, age: opts.Age, salary: opts.Salary}
}
This allows us to introduce new fields to Person
as much as we like without breaking the contract of NewPerson
.
However, there is another trade-off here: If we add a new field to Person
(and respectively PersonOptions
) we cannot easily enforce that all consumers of NewPerson
will set the new properties.
Of course, we can fail NewPerson
if any of the new fields are missing, but this is actually a breaking change, not much better than the one in the first example (and even worse, because it will only be caught at runtime).
Another downside here is that it’s not obvious which fields from the PersonOptions
structs are required and which are optional.
The clearest way to communicate this to the consumers is via docstrings, which is not as obvious as just looking at the method signature (because who reads the docs, right).
Possible mitigation to this problem is if we define the required field in PersonOptions
as value types and the optional ones as refs.
For example:
type PersonOptions struct {
// Age and Name are required
Age int
Name string
// Salary is optional
Salary *float64
}
This way the consumer can set the optional fields to nil
and not bother with default values.
It is also a bit more clear what is required and what is not.
Variadic function constructor
Alternative to that is to make our constructor a variadic function that accepts an arbitrary number of mutating functions. Then, the implementation of the method will run all the functions one by one on an instance of the struct which will be returned by the constructor. This means, we also need to provide a set of functions that will mutate the fields.
If you did not understand anything from this explanation, don’t worry. Here’s how the code looks like:
package people
type PersonOptionFunc func(*Person)
func WithName(name string) PersonOptionFunc {
return func(p *Person) {
p.name = name
}
}
func WithAge(age int) PersonOptionFunc {
return func(p *Person) {
p.age = age
}
}
func NewPerson(opts ...PersonOptionFunc) *Person {
p := &Person{}
for _, opt := range opts {
opt(p)
}
return p
}
// usage:
p := people.NewPerson(people.WithName("Anton"), people.WithAge(25))
fmt.Println(p) // {name: "Anton", age: 25}
The downside here is that the amount of WithXXX
functions are not obvious, and the consumers of the package would either have to search them in the package documentation or depend on their IDE autocomplete to show them the possible options.
In my opinion, it does not give you any benefits over the Options
struct, but bring the downside that the options are not obvious.
Middle ground
A middle ground between constructor with required positional arguments and optional arguments would be to have the required fields for a given struct as positional arguments so that the consumer MUST pass them, and have everything else as optional parameters, which may be skipped.
func NewPerson(name string, options *PersonOptions) *Person {
p := &Person{name: name}
if options == nil {
return p
}
if options.Age != 0 /* OR options.Age != nil */ {
p.age = options.Age /* OR p.age = *options.Age */
}
if options.Salary != 0 /* OR options.Salary != nil */ {
p.salary = options.Salary /* OR p.salary = *options.Salary */
}
return p
}
// usage:
p := NewPerson("Anton", &people.PersonOptions{Age: 25})
fmt.Println(p) // {name: "Anton", age: 25}
Summary
There are 2 build-in ways to initialize a Go struct. Both are quite limited and more often than not they are not enough. That is why people came up with more sophisticated solutions (based on the built-in ones).
Takeaways
So what is the best option? Well, there isn’t one. All described approaches have their pros and cons. They depend on your use case and the way your code is meant to be used. As with everything in computer science, there is not a single correct solution or a silver bullet. It’s all a matter of the tradeoffs you are willing to make.
If you are writing a library that will be used by hundreds of other projects, backwards-incompatible changes will not be appreciated by your consumers, so you need to choose the more flexible options. If you’re working locally in a single codebase, signature changes can be beneficial, because they will enforce that all usages of the methods are adapted.
If you got to the end of the article, I just want you to know that I recently started this blog, and this is the first article in it. If it was useful for you, you learned something new, or you think it can be improved feel free to ping me on Twitter, LinkedIn or email. I would appreciate the feedback.