Coding with Go Structs

Overview #

Structs allow us to group data together in Go. This is similar to other programming languages’ classes, data objects, or records.

A struct can do two main things, group data and be used as a type to implement interfaces.

Structs can also contain metadata in the form of “tags” that frameworks can use for validation and serialization.

The syntax for a struct is

1
2
3
4
type <name> struct {
<attribute name> <attribute type>
...
}

Below is an example of a simple struct:

1
2
3
4
type Person struct {
Name string
age int
}

And another with generics:

1
2
3
4
type Node[T any] struct {
Value T
Next *Node[T]
}

We can instantiate a struct using the struct literal syntax.

1
p := &Person{Name: "Test", Age: 32}

Note: Struct and attribute names should start with a capital letter to make them visible outside the current package.
Note: When initializing a struct, you don’t need to provide values for all the struct’s attributes.

Zero Value Instantiation #

A Struct behaves the same as any other variable in Go; It has a zero value and can be referenced by a pointer. A Struct’s zero value is an empty struct with attributes/fields set to zero values corresponding to each type.

This means if we declare our Person struct as:

1
var p Person

The Person struct is instantiated in memory, and its Name and Age string fields are set to "".

Structs have a literal syntax using curly brackets, and because go does not enforce specifying all struct attributes, we can instantiate an empty struct using Person{}.

There is also the new function, which instantiates a type to its zero value and returns a pointer:

1
p := new(Person)

Which one you use is up to you, but I prefer the literal syntax and only use the zero value instantiation when I want to build and set the struct values in a function.

Adding Methods #

Object-oriented programming wants to add methods to everything, attaching functionality with data. Data-oriented and functional programming separates data from functionality. I personally prefer to only add methods in particular cases like To String, Comparators and when required for polymorphism.

Methods are added differently than most mainstream languages. Instead of defining it inside the struct declaration, we define a method independently.

The syntax is:

1
2
3
func (<optional name> <receiver type>) <method name> ( <method arguments >) <return arguments > {

}

The receiver type can be a pointer or a value type. I would suggest to stick with pointer types and only use value types when you have the need.
For a good example, see A Tour of Go: Methods.

We can access the receiver’s attributes by giving it a name and then using the dot notation <receiver>.<attribute>.

Below is an example of writing a String() method for the Person struct:

1
2
3
4
5
6
7
8
func (p *Person) String () string {
    return fmt.Sprintf("name: %s, age: %d", p.Name, p.Age)
}

func main() {
    p := &Person{Name: "Test", Age: 32}
    fmt.Println(p.String())
}

Methods in different files from the type #

Methods and types can live in different files as long as they are in the same package.
We need to use a type definition to define a method for a type in a different package.

1
2
3
type S string
    func (s S) DoSomething() {
}

Trying to do so without the type definition will result in a Invalid receiver type '<...>' ('<...>' is a non-local type) compilation error.

Struct Constructor Methods #

Structs have no constructor methods as found in mainstream OO languages, nor are they needed. A simple function is all we need without adding complex constructor semantics to our cognitive burden.

In Go, it is common to see constructor functions prefixed with “New”. The naming is purely convention and does not affect how the Go compiler sees the function.

Here is an example of a constructor creating a new node. Note: the example makes use of generics.

1
2
3
func NewNode[T any](v T) *Node[T]{
    return &Node[T]{Value: v}
}

Implementing interfaces #

Polymorphism is a way that we can separate behaviour from the actual implementation. It introduces a layer of indirection where we need more flexibility as to what implementation is used, especially when writing libraries or utility code.

In Go, interfaces are defined almost the same as with structs, with the exception that the body of an interface only contains functions.

For example, see the Stringer interface, which the fmt package uses to abstract how types are printed.

1
2
3
type Stringer interface {
    String() string
}

In Go, we do not mark a struct as implementing any interface but instead, just add the interface’s method to the type.

If we wanted our Node struct to implement the Stringer interface, we only need to implement the method for it like below:

1
2
3
func (n *Node[T]) String() string {
    return fmt.Sprintf("Node: %s", n.Value)
}

Now, the Node struct can be passed to any function that requires the Stringer interface.

Combining Interfaces (Embedding) #

It is idiomatic in Go to define very finely-grained interfaces. Interfaces can be built up by defining new interfaces as the combination of several simpler interfaces.

Let’s say we wanted to define an interface for a HashTable key. We want the key to be hashable and also comparable.
We can define a Hashable interface and a Comparable interface, and finally combine both into a HashTableKey interface.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
type Hashable interface {
hash() []byte
}

type Comparable[T any] interface {
Compare(t T) int
}

type HashTableKey[T any] interface {
Hashable
Comparable[T]
}

And we can use it like:

1
2
3
4
5
func AddHashKey[T any](k HashTableKey[T]) {
hash := k.hash()
compareRes := k.Compare(anotherKey)

}

For more information, see the official documentation Go embedding.

Telling if a struct is empty #

Golang has many ways to test if a struct was initialized (not empty). Which one do you use? It depends on how much do we want our code to know about the type we are testing?

Using reflection #

This is generic and works for any type, i.e. the testing code does not need to know about the struct’s type.

Using reflect.ValueOf(p).IsZero() returns true if p is empty and false otherwise. To demonstrate, the code below will print Is Empty and Not Empty.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func main() {
    var p Person
    PrintIsEmpty(p)

    p.Name = "hi"
    PrintIsEmpty(p)
}

func PrintIsEmpty(v interface{}) {
    if reflect.ValueOf(v).IsZero() {
        fmt.Println("Is empty")
    } else {
        fmt.Println("Not empty")
    }
}

Using reflect lets us assume nothing of the struct we are testing, and we can utilize the interface{} type. This is perfect for library or utility code.

Using a struct field #

Testing if a field is set or not. This is the easiest way but does require knowledge of the struct’s type.

1
2
if p.Name == "" {
}

Using a zero value #

This method requires knowledge of the struct’s type. It also places an extra restriction that the struct must contain only comparable values. This means if Person contained a slice field this method would result in a compile error.

if p == (Person{}) {
    fmt.Println("Is Empty")
}
1
2
3
4
5
6
7
8
type Person struct{
    Names []string
}

// compile error "operator == is not defined for Person"
if p == (Person{}) {

}

On using deep equal #

This method requires knowledge of the struct’s type and compares each field.

1
2
3
4
5
var p Person

if reflect.DeepEqual(p, Person{}) {
    fmt.Println("Is Empty")
}

On using nil #

In many programming languages, null, None or nil is used to test for empty objects. In Golang, we can use nil only on pointers, but we cannot use them on any other types.

To demonstrate, if you try to compile the following code, you will get a cannot convert nil type error.

1
2
3
4
5
var p Person
// compile error nil type != type of person struct
if p == nil {

}

The variable p of type Person is always instantiated when first declared but has not been set with values. The following uses a pointer, and the code does compile.

1
2
3
4
5
var p *Person
// does compile
if p == nil {

}

Compiling code does not equal working as intended code. We should only use this if we want to check that a pointer has a value because we can also, cheat in do:

1
2
3
4
var p *Person = &Person{}
// compiles but is the same as var p Person
if p == nil {
}

The statements var p Person and p := &Person{} are equal, with the only difference being that the second assigns a pointer to p, but both create (instantiate) an uninitialized version of the Person struct.

Struct Tags (JSON and YAML support) #

Struct tags are used mostly for serialization to and from JSON, YAML or XML formats. It can be used and interpreted by any framework and is a way to put metadata on each attribute.

The syntax is:

<name> <type> `<tag key>:"<tag value>"[ space ]<tag2 key>:"<tag2 value>"`

Go’s attribute visibility works by defining the first letter of an attribute as an upper-case or lowercase letter. But this creates an issue when reading and writing JSON data because JSON attributes are almost always lowercase.

For example:

Then JSON string

1
2
3
4
{
  "name": "test",
  "age": 32
}

It would not marshall correctly into our Person struct. For this to work, we need to add JSON tags to our struct so that it knows which lowercase attribute matches which attribute in the struct.

1
2
3
4
type Person struct {
Name string `json:"name"`
Age int `json:"age"`
}

We can add more tags to each attribute by simply separating them with a space:

1
2
3
4
type Person struct {
Name string `json:"name" yaml:"name"`
Age int `json:"age" yaml:"age"`
}

Important: tags are separated with a space, not a comma.

Anonymous Structs #

Anonymous Structs have no name, and cannot be reused outside of the context in which they are declared. We use them to provide typing to a bunch of data where we do not want or need to reuse the type again.

This is especially useful when specifying a list of records for testing.

In the example below, we define a list of records with attributes, id, name and age. Any code that accesses the records slice can use the dot notation to read the properties.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
records := []struct {
    id   int
    name string
    age  int
}{
    {id: 1, name: "Test1", age: 32},
    {id: 2, name: "Test2", age: 33},
}

for _, record := range records {
    fmt.Println(record.id)
}

Note: An anonymous struct is exactly the same as a named struct for all purposes. It just doesn’t have a name.

Anonymous struct definitions are valid type declarations. This means you can create some uncallable code that will compile but the type system will not allow you to do much because the type is unknown outside the declaration’s context.

1
2
3
func CannotCallFn(p struct{Name string}) {
    fmt.println("Hi ", p.Name)
}
1
2
3
4
5
6
func WierdFunc(v []int) *struct {
    Avg int
    Max int
} {
    return nil
}

Combining Structs (Embedding) #

In Golang, composition is used instead of inheritance, and embedding is a shortcut method for composing different types.

For example, let’s say we wanted to add the Identity type to our Person struct:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
type Identity struct {
    Id string
}

type Person struct {
    Identity
    Name string
}


func (i Identity) IdLen()  {
    return len(i.Id)
}

We can use the IdLen method on Person as if we defined it for that struct. We can also refer to the Id field in the Identity struct.

For example:

1
2
3
p.IdLen()

len(p.Id)

It is also possible to refer to the embedded type using the type name like so:

1
2
3
len(p.Identity.Id)
// or
p.Identity.IdLen()

For more information, see the official documentation Go embedding.

Summary #

My intention for this post was to serve as a one-stop reference for all things struct. It is not an exhaustive reference, but I hope that it reflects 99% of what a Go programmer needs to know about structs.

My advice to any programmer would be to practice the fancier methods, but when writing code, always keep things the simplest and most readable as possible.

You may also enjoy