Skip to content
System nominalUTC --:--:--Articles 041
MODULE 02 Lesson 04 / 09 30 MIN

Types and structure

Structs, Methods and Embedding

Struct declarations, the zero value as a design decision, value and pointer receivers, embedding, field tags and the comparability rules that decide what breaks.

A Go service is mostly structs passing through methods: the requests you decode, the configuration you load, the rows you scan. How you declare those types decides how much validation code you write later and how many nil checks you cannot avoid.

Three decisions come up every time. What do the fields mean when nobody has set them? Does a method change the value it is called on? And when two types need to share behaviour, do you copy the code, embed one type in the other, or reach for an interface?

Work through them in that order and Go stops feeling like a small language with awkward corners. Go in the other order, starting with the syntax, and you write Java with semicolons.

A struct is a value, and its zero value is a design decision

Declaring a struct names a set of fields with types. Nothing more is generated: no constructor, no default values, no equality method. A variable of that type is immediately usable and every field holds the zero value for its type.

type Config struct {
	Host     string
	Port     int
	Timeout  time.Duration
	LogLevel string
	Tags     []string
}

Declare var c Config, with no constructor and no initialisation, and print it with %+v:

zero value: {Host: Port:0 Timeout:0s LogLevel: Tags:[]}
Timeout == 0: true
Tags == nil: true, len(Tags) == 0: true

Timeout is a time.Duration, an integer count of nanoseconds with a String method, so it prints as 0s and compares equal to 0. Tags prints as [] even though it is nil, because a nil slice is a valid slice: len returns 0 and appending works. Nil maps are the opposite, and the constructor section below shows what that costs.

Because every field has a defined zero value, you can give the type a usable zero value by choosing field meanings where zero is the right answer. Then say what unset means in one place:

var ErrNoPort = errors.New("config: port is not set")

// Validate is a method on a value receiver: it only reads.
func (c Config) Validate() error {
	if c.Host == "" {
		return errors.New("config: host is empty")
	}
	if c.Port == 0 {
		return ErrNoPort
	}
	return nil
}
validation: config: host is empty
after defaults, Validate: <nil>

That is the shape to aim for: a zero value that is inert rather than dangerous, one method that says what is missing, and a caller who can check before use. A constructor is worth writing when it can enforce an invariant, but the type does not require one.

Copying is the other consequence of structs being values. Assignment and function arguments copy the whole struct, and reference-typed fields such as slices, maps and pointers are copied shallowly:

a := Config{Tags: []string{"eu"}}
b := a
b.Tags = append(b.Tags, "internal")
fmt.Println(a.Tags, len(b.Tags))

If the capacity is already there, that append writes into the shared array and a sees the change. That is not a reason to avoid slice fields. It is the reason to be deliberate about which values you keep and which you hand out.

A constructor is for invariants, not for initialisation

Because the zero value is usable, a constructor function earns its place by establishing something the zero value cannot. A map field is the clearest example: a nil map reads safely and panics on the first write, so the type needs a constructor that allocates it.

type Storage struct {
	values map[string]string
}

func NewStorage() *Storage {
	return &Storage{values: make(map[string]string)}
}

func (s *Storage) Set(key, value string) {
	s.values[key] = value
}

The pattern is worth naming: return a pointer, name the function New plus the type, and keep the field unexported so there is no way to build a Storage missing its map. Writing NewStorage() without the & compiles and copies the struct on the way out, which stops working as soon as the type holds anything others need to share.

A constructor that can fail returns a second error value, and then the question is whether it should panic instead. For configuration read at startup, both answers are defensible; returning the error keeps the decision with main, which is where it belongs. The panic-versus-error rules have a lesson of their own in this module.

Pointer receivers exist because methods must be able to change the value

A method is a function with a receiver in front of the name. The receiver can be the type itself or a pointer to it, and the choice has nothing to do with performance in practice.

type Metrics struct {
	requests  int
	totalTime int64
}

func (m *Metrics) Observe(milliseconds int64) {
	m.requests++
	m.totalTime += milliseconds
}

func (m *Metrics) Average() float64 {
	if m.requests == 0 {
		return 0
	}
	return float64(m.totalTime) / float64(m.requests)
}

Observe must take a pointer, because m.requests++ is shorthand for reading a field and writing it back, and a value receiver would increment a copy. Average only reads, so a value receiver would work. It takes a pointer anyway, and that is the consistency rule: when one method on a type needs a pointer receiver, every method on that type gets one.

A type's method set decides which interfaces it satisfies

The consistency rule is not a matter of taste. It decides the method set, and the method set decides what satisfies an interface. Go's rule is mechanical:

Method declared withIn the method set of TIn the method set of *T
value receiver func (t T) M()yesyes
pointer receiver func (t *T) M()noyes

So a type with any pointer-receiver method can only be used through a pointer when an interface is involved. This compiles and prints the numbers you expect:

func report(r Reporter) {
	r.Observe(120)
	r.Observe(80)
	fmt.Printf("requests averaged: %.1fms\n", r.Average())
}

func main() {
	var m Metrics
	report(&m)
	fmt.Printf("collected: %+v, average %.1fms\n", m, m.Average())
}
requests averaged: 100.0ms
collected: {requests:2 totalTime:200}, average 100.0ms

Pass m instead of &m and the compiler refuses, with a message that names the exact method responsible:

./main.go:22:9: cannot use m (variable of struct type Metrics) as Reporter value in argument to report: Metrics does not implement Reporter (method Average has pointer receiver)

People meet that error when they put a struct value into something that expects an interface. The fix is a & at the call site, or a decision that the type is a value type and none of its methods mutate. Giving Observe a value receiver to silence the compiler is the wrong fix: the mutation disappears and the bug goes quiet.

Methods on a pointer receiver are still callable on an addressable value, which is why m.Observe(100) works on a local variable with no visible &. The same call on a value returned from a function does not compile. That is one more reason to work with pointers to the types whose methods mutate.

Embedding promotes behaviour instead of inheriting it

Go has no inheritance and no extends. It has one mechanism that people mistake for it: a struct field can be declared with a type and no field name, which is embedding.

type Runtime struct {
	Region string
	Since  time.Time
}

func (r Runtime) Uptime() time.Duration { return time.Since(r.Since) }

func (r *Runtime) Reset(t time.Time) { r.Since = t }

type Metrics struct {
	Runtime          // embedded: the field name is Runtime
	requests  int
	totalTime int64
}

An embedded field is a real field with the type's name, and the outer struct gains the inner type's fields and methods without naming the field. That is promotion, and it is all embedding does.

m := Metrics{Runtime: Runtime{Region: "eu-west-1", Since: start}}

fmt.Println("region:", m.Region)          // promoted field
fmt.Println("uptime:", m.Uptime())        // promoted value method
m.Reset(time.Now())                       // promoted pointer method
fmt.Println("explicit path:", m.Runtime.Region)
region: eu-west-1
uptime over a minute: true
after reset: true, average 150.0ms
explicit path: eu-west-1

Note that m.Reset(...) works and changes the embedded value, because m is addressable and Reset takes a pointer receiver. Promotion follows the method-set rules above: *Metrics gets Reset, and Metrics does not.

What embedding does not give you is virtual dispatch. There is no way to override a promoted method and have the inner type's own methods call your version. Embed a type because its behaviour is genuinely part of this value, not because you want to substitute one implementation for another. Substitution is what interfaces are for.

There is one genuine promotion hazard. If the outer struct declares a field or method with the same name as a promoted one, the outer declaration wins at the shallowest depth and nothing warns you. Declare the same name on two embedded types at the same depth and the compiler refuses the ambiguous selector.

Field tags are metadata that only some packages read

A tag is a string literal after a field's type. The compiler stores it and does nothing else with it, so a typo in a tag is not a compile error. Packages that care read it through reflection, and encoding/json is the one you will meet first.

type Endpoint struct {
	Host     string   `json:"host"`
	Port     int      `json:"port"`
	Scheme   string   `json:"scheme,omitempty"`
	Tags     []string `json:"tags,omitempty"`
	internal string
	Secret   string `json:"-"` // never leaves the process
}
{
  "name": "billing",
  "endpoints": [
    {
      "host": "api.internal",
      "port": 443,
      "scheme": "https"
    },
    {
      "host": "legacy.internal",
      "port": 80
    }
  ]
}

Three rules produced that output. The tag renames the key, so Host becomes host. omitempty drops the field when it holds the zero value, which is why the second endpoint has no scheme or tags. And - excludes a field for good, so secret is absent even though it was set.

The unexported field internal also produced nothing, which is worth knowing before you debug a missing JSON key: unexported fields are invisible to reflection-based encoders, so a tag on one changes nothing. Keys are matched case-insensitively on decode and written exactly as tagged on encode, and an unknown option is ignored rather than reported.

Comparability is decided by the fields, field by field

Structs are compared with ==, which compares every field. That works when every field type is comparable, and the compiler enforces it:

endpoint := Endpoint{Host: "api.internal", Port: 443}
seen := map[Endpoint]int{}
seen[endpoint] = 1
equal endpoints: true
maps keyed by struct: 1
equal routes: true
routes as map keys: true

Add one slice field and the type stops being comparable. Both == and use as a map key stop compiling, and the messages name the offending field type rather than the struct:

./buildfail.go:16:6: invalid operation: a == b (struct containing []string cannot be compared)
./buildfail.go:20:6: invalid operation: c == d (struct containing func() error cannot be compared)
./buildfail.go:22:10: invalid map key type Broken

Slices, maps and functions are not comparable, so any struct containing one is not comparable. Everything else is, including pointers, interfaces, channels and arrays of comparable types. Interfaces are the case that bites later: the interface comparison compiles, and fails at run time when the dynamic type is not comparable.

comparing structs whose channel fields are equal: false
panic: runtime error: comparing uncomparable type map[string]int

goroutine 1 [running]:
main.main()
	/tmp/golab/chanpanic/main.go:14 +0x190
exit status 2

The struct compared fine because channels are comparable. The map, stored inside any, panicked, because the dynamic type was not comparable. Nothing in the source shows that, and recover does not reliably catch it. When a struct holds a slice or a map and someone calls ==, prefer an explicit method that says what the comparison means.

Lab 30 minutes

Model a small in-process connection pool. Start with a Pool struct holding a name, a maximum size, an open count and a slice of live endpoints.

Then write:

  • A method that opens a connection and updates the count, with the receiver you think is correct.
  • A method that reports how full the pool is, and one that closes a connection. Keep one consistent receiver for all three.
  • A Runtime-style embedded struct holding the pool's region and the time it started, with an Uptime method. Reach at least one of its fields through the outer struct.
  • Tags so that marshalling the pool with encoding/json emits name, region, open and max, omits the live endpoints entirely, and never emits an internal token field.

Then answer, in a comment above the struct: is Pool comparable as you declared it, and which field decided that? Change one field type so the answer flips, and record the compiler error you got when you compared the first version.

Decide where your limit is enforced, then test the boundary by acquiring Max connections and one more.

Show the solution and the reasoning

The interesting decisions are the receiver and the endpoint slice:

type Runtime struct {
	Region string    `json:"region"`
	Since  time.Time `json:"-"`
}

func (r Runtime) Uptime() time.Duration { return time.Since(r.Since) }

type Pool struct {
	Runtime
	Name  string     `json:"name"`
	Max   int        `json:"max"`
	Open  int        `json:"open"`
	Live  []Endpoint `json:"-"`
	Token string     `json:"-"`
}

func (p *Pool) Acquire(e Endpoint) error {
	if p.Open+1 > p.Max {
		return ErrPoolFull
	}
	p.Live = append(p.Live, e)
	p.Open++
	return nil
}

func (p *Pool) Fill() float64 {
	if p.Max == 0 {
		return 0
	}
	return float64(p.Open) / float64(p.Max)
}

func (p *Pool) Release() {
	if p.Open == 0 {
		return
	}
	p.Open--
}

The tags go on Runtime's fields rather than on the embedded field in Pool, because there is no syntax for tagging a field from outside the type that declares it. If you cannot change Runtime, copy the two fields into Pool instead of embedding, which is a reasonable trade when the JSON shape matters more than the composition.

Every method takes a pointer receiver because Acquire and Release mutate. One receiver kind keeps the method set of *Pool complete, the right trade for a mutable object. And p.Open+1 > p.Max is the check that enforces the limit, because the counter is incremented after it.

Pool is not comparable. Live is a slice, and that one field decides it for the whole struct. The - tag on Token is irrelevant to comparability: JSON options have no effect on language rules, so a struct is not made comparable by convincing the encoder to skip a field. The compile error for the first version is the same one from earlier in the lesson:

invalid operation: p1 == p2 (struct containing []Endpoint cannot be compared)

Swapping Live []Endpoint for Live map[string]Endpoint keeps the answer the same, because maps are not comparable either, and swapping it for a single Endpoint flips the answer since that field holds only a string and an int. That is the point of the lab: comparability is a property of the field list.

If you wanted an equality check on the pool, the honest answer is a method that compares the fields you care about, such as name, region and open count, and leaves Live out. The compiler error saved you from a comparison that would have been wrong anyway.

Progress

Mark this lesson done and it stays marked in this browser.