Learn Go Series (#8) - Structs, Methods, and Receivers
What will I learn
- How to define a struct, Go's way of bundling related data, and how to build and print one;
- How to attach methods to your types with a receiver;
- The single most important early choice: value receivers versus pointer receivers, and what each means for mutation and copying;
- Embedding: Go's composition-over-inheritance mechanism for reusing fields and methods;
- Struct comparison and using structs as map keys;
- The constructor idiom (
NewX) and one-off anonymous structs.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Go distribution (1.27 or newer, from go.dev/dl) -- tested against Go 1.27;
- Episodes 1-7, especially functions (episode 3) and pointers as previewed there;
- The ambition to learn Go programming.
Difficulty
- Beginner
Curriculum (of the Learn Go Series):
- Learn Go Series (#1) - Introduction to Go
- Learn Go Series (#2) - Variables, Types, Constants, and iota
- Learn Go Series (#3) - Functions and First-Class Functions
- Learn Go Series (#4) - Control Flow: if, switch, and the Only Loop
- Learn Go Series (#5) - Arrays, Slices, and Slice Internals
- Learn Go Series (#6) - Maps and Sets
- Learn Go Series (#7) - Strings, Runes, and Unicode
- Learn Go Series (#8) - Structs, Methods, and Receivers (this post)
Learn Go Series (#8) - Structs, Methods, and Receivers
Go has no classes, and it does not miss them. Your own types are structs -- plain bundles of named fields -- and behaviour comes from methods you attach to those types. That is the whole toolkit for modelling data, and it is genuinely enough. The one decision that matters from day one is whether a method takes its receiver by value or by pointer, because that decides whether the method can change the thing it is called on. Let us clear last episode's exercises and then build our own types.
Here is the thread to hold on to through the whole episode, because it is what makes Go's approach feel different if you are coming from Python or Java: there is no inheritance tree, no class, no extends, no constructor keyword, no private/public decorators. Instead there are four small, orthogonal ideas -- the struct (data), the method (behaviour), the receiver choice (copy or mutate), and embedding (reuse by composition) -- and you build everything out of those. It is a deliberately small language surface, and the payoff is that once you have these four ideas straight, there is almost nothing left to learn about "objects" in Go. That is the whole point of the design: fewer concepts, less magic, and code that does exactly what it says. Having said that, let us get concrete.
Solutions to Episode 7 Exercises
Exercise 1 -- reverse by runes. Convert to []rune so multi-byte characters stay intact:
package main
import "fmt"
func reverse(s string) string {
r := []rune(s)
for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
return string(r)
}
func main() {
fmt.Println(reverse("h茅llo")) // oll茅h -- the 茅 survives
}
Exercise 2 -- count vowels, Unicode-safe. for range walks runes, so multi-byte input never splits mid-character:
package main
import (
"fmt"
"strings"
)
func countVowels(s string) int {
count := 0
for _, r := range s { // range yields runes, not bytes
if strings.ContainsRune("aeiouAEIOU", r) {
count++
}
}
return count
}
func main() {
fmt.Println(countVowels("Hello, World")) // e, o, o = 3
}
Exercise 3 -- a CSV field splitter. Split keeps empty fields, so the gap between two commas is an empty string:
package main
import (
"fmt"
"strings"
)
func main() {
line := "go,rust,,zig"
for i, f := range strings.Split(line, ",") {
fmt.Printf("field %d: %q\n", i, f)
}
// field 2 is "" -- the empty field between the two commas
}
Now, our own types.
Defining a struct and attaching a method
A struct groups named fields. A method is a function with a receiver written before the name -- it says "this function belongs to this type". Here is a rectangle that knows its own area:
package main
import "fmt"
type Rectangle struct {
Width, Height float64
}
// Area has a VALUE receiver (r Rectangle): it reads the rectangle.
func (r Rectangle) Area() float64 {
return r.Width * r.Height
}
func main() {
r := Rectangle{Width: 3, Height: 4}
fmt.Printf("%+v has area %.1f\n", r, r.Area())
}
(r Rectangle) is the receiver; inside the method, r is the rectangle you called .Area() on. You build a struct with a composite literal, and using field names (Width: 3) is the readable, order-independent form you should prefer. There is also a positional form -- Rectangle{3, 4} -- but it silently breaks the day someone adds a field or reorders them, so save it for tiny two-field types like a coordinate and name your fields everywhere else. You are also allowed to leave fields out: Rectangle{Width: 3} gives Height its zero value (0), which is a property we lean on hard in the next section.
The %+v verb prints the struct with its field names -- keep it in your debugging toolbox, because %v alone gives you just the values ({3 4}) while %+v gives you {Width:3 Height:4}, and the second is what you actually want when you are staring at a bug at midnight. Methods, by the way, are not stored "inside" the struct the way you might picture a class layout -- they are ordinary functions that the compiler associates with the type, and a Rectangle value carries no hidden pointer to its methods. That is worth internalising early: a struct is just its fields, nothing more.
Zero values: a struct is ready to use
One of Go's quietest but most useful design choices is that every type has a zero value, and a struct's zero value is simply each field set to its own zero -- 0 for numbers, "" for strings, nil for pointers, slices and maps, false for bools. That means var b Buffer gives you a fully-formed, usable value without a constructor, if you design the type so that its zero value already means something sensible:
package main
import (
"fmt"
"strings"
)
type Logger struct {
prefix string
lines []string // nil slice is fine: append works on it
}
func (l *Logger) Log(msg string) {
l.lines = append(l.lines, l.prefix+msg)
}
func (l *Logger) Dump() string {
return strings.Join(l.lines, "\n")
}
func main() {
var log Logger // zero value: prefix "", lines nil -- ready immediately
log.Log("started")
log.Log("working")
fmt.Println(log.Dump())
}
No NewLogger needed: the zero value works because append to a nil slice is safe (episode 5) and an empty prefix is harmless. This "make the zero value useful" principle is everywhere in the standard library -- sync.Mutex, bytes.Buffer and strings.Builder are all usable straight from var x T with no initialisation call. It is worth designing your own types the same way when you can: the less ceremony a caller needs before your type does something reasonable, the better. When the zero value cannot be sensible -- because some field genuinely must be validated or supplied -- that is exactly when you reach for a constructor, which we get to shortly.
The key decision: value vs pointer receiver
A value receiver gets a copy of the struct, so any changes it makes are lost when the method returns. A pointer receiver (*T) gets a pointer to the original, so it can mutate it. This is the choice you make for every method, and it is easy once you see it side by side:
package main
import "fmt"
type Counter struct {
count int
}
// value receiver: mutates a copy, so the original is unchanged
func (c Counter) TryInc() {
c.count++
}
// pointer receiver: mutates the original
func (c *Counter) Inc() {
c.count++
}
func main() {
c := Counter{}
c.TryInc()
fmt.Println("after TryInc:", c.count) // 0 -- the copy was thrown away
c.Inc()
c.Inc()
fmt.Println("after two Inc:", c.count) // 2 -- the original changed
}
TryInc increments a copy that vanishes; Inc increments the real thing. The rule of thumb: use a pointer receiver if the method needs to modify the receiver, or if the struct is large (to avoid copying it on every call). And a consistency rule that matters more than people expect: if any method on a type uses a pointer receiver, use pointer receivers for all of them, so the type's method set is uniform. Mixing the two is legal but confusing, and it will bite you later when the type is used through an interface -- a *Counter and a Counter do not have quite the same method set, and the difference is a classic source of "why doesn't my type satisfy that interface" head-scratching (a puzzle we solve properly next episode).
Notice Go let you call c.Inc() on a value even though Inc wants a *Counter -- it automatically takes the address for you ((&c).Inc()), because c is a variable and therefore addressable. The convenience has one sharp edge worth knowing now: a value that is not addressable -- a map element, for instance -- cannot have a pointer-receiver method called on it, because Go has no address to take. m["key"].Inc() is a compile error for exactly that reason. The fix is to pull the value into a variable first, mutate it, and store it back, or to make the map hold pointers (map[string]*Counter). It is a small thing, but it is the kind of small thing that makes you say "aha" the first time the compiler stops you, and it all flows from the one rule: pointer receivers need something addressable to point at.
There is also a cost dimension people forget. A value receiver copies the whole struct on every single call. For a two-field Point that copy is free and a value receiver is cleaner (no nil to worry about). For a struct with a dozen fields, or one that embeds a big array, that copy on every method call is real work you are doing for no benefit. So the honest full rule is: pointer receiver if you mutate OR the struct is big; value receiver for small, immutable-by-nature values; and never mix the two on one type.
Constructors: the NewX idiom
Go has no constructors built into the language. The convention is a plain function named NewT that builds and returns a *T, doing any validation and returning an error if the inputs are bad. This is where you enforce that a value is always created in a valid state:
package main
import (
"errors"
"fmt"
)
type Account struct {
owner string
balance int
}
func NewAccount(owner string, initial int) (*Account, error) {
if initial < 0 {
return nil, errors.New("initial balance cannot be negative")
}
return &Account{owner: owner, balance: initial}, nil
}
func main() {
a, err := NewAccount("Ada", 100)
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Printf("%s has a balance of %d\n", a.owner, a.balance)
}
&Account{...} builds an Account and takes its address in one step, returning a pointer -- and this is safe even though the Account looks like a local variable, because Go's escape analysis notices the address leaves the function and quietly allocates it on the heap for you (no dangling pointer, no manual malloc; more on that in a later episode). Lowercase field names (owner, balance) make them private to the package, so callers outside the package cannot build an Account{} directly and must go through NewAccount -- which is exactly how you guarantee no account ever exists with a negative balance. That combination -- unexported fields plus a NewT gatekeeper -- is the Go way of enforcing an invariant, and it is why you return (*T, error) rather than just *T: the error is the channel through which the constructor refuses to hand back a broken value. We cover packages and visibility properly in a later episode; for now just note that the case of the first letter is doing real work here.
Embedding: composition, not inheritance
Go replaces inheritance with embedding: put a type inside a struct with no field name, and its fields and methods are promoted to the outer type, as if they belonged to it. It is reuse by composition, and it is the only "inheritance-like" feature Go has:
package main
import "fmt"
type Animal struct {
Name string
}
func (a Animal) Describe() string {
return "I am " + a.Name
}
type Dog struct {
Animal // embedded (no field name): Dog gets Animal's fields and methods
Breed string
}
func main() {
d := Dog{Animal: Animal{Name: "Rex"}, Breed: "Terrier"}
fmt.Println(d.Name) // promoted field, from Animal
fmt.Println(d.Describe()) // promoted method, from Animal
fmt.Println(d.Breed) // Dog's own field
}
d.Name and d.Describe() work even though they are defined on Animal, because the embedded Animal promotes them to Dog. This is not inheritance -- there is no "is-a" hierarchy and no overriding in the OOP sense -- it is a Dog that has an Animal and borrows its surface. Composition like this is Go's answer to code reuse, and it composes cleanly without the fragile base-class problems of deep inheritance trees.
Two details make embedding genuinely practical. First, you can shadow a promoted method by defining one with the same name on the outer type: if Dog had its own Describe(), that would win, and you could still reach the inner one explicitly with d.Animal.Describe(). That gives you a "call up to the embedded behaviour" move without any super keyword -- the embedded field name is the path. Second, the embedded type is still a plain field under the hood, reachable as d.Animal, so promotion is pure convenience: d.Name is exactly d.Animal.Name with the middle spelled out for you. And here is the part that pays off next episode -- because methods are promoted, embedding a type is also how you make your struct satisfy an interface by borrowing someone else's implementation. Embed a type that already has the methods an interface wants, and your outer struct satisfies that interface for free. Hold that thought. ;-)
Comparison and map keys
If all of a struct's fields are comparable, the struct is comparable: == compares it field by field, and it can be used as a map key. This makes structs a natural fit for compound keys -- a coordinate, a (name, version) pair -- without any extra work:
package main
import "fmt"
type Point struct {
X, Y int
}
func main() {
a := Point{1, 2}
b := Point{1, 2}
c := Point{3, 4}
fmt.Println(a == b) // true -- equal field by field
fmt.Println(a == c) // false
visited := map[Point]bool{}
visited[a] = true
fmt.Println("b visited:", visited[b]) // true, because b == a
}
Because a == b compares fields, and Point is comparable, you can key a map by Point and look up b to find the entry stored under a. This is genuinely handy: a "visited" set of grid coordinates, a cache keyed by a (name, version) pair, a lookup table indexed by a small config struct -- all of them fall out of comparability for free, with no need to concatenate fields into a string key by hand in stead. (Structs containing slices, maps or functions are not comparable, and trying to compare them with == is a compile error, not a runtime surprise -- the compiler catches it. That is a nudge toward writing an explicit Equal method for those types, and later in the series we will meet reflect.DeepEqual for the times you need a general-purpose deep comparison and can accept its cost.)
Anonymous structs
Sometimes you want a small, one-off grouping and a named type would be overkill -- config for a single function, a row shape for a test. An anonymous struct declares the type and the value in one go:
package main
import "fmt"
func main() {
config := struct {
Host string
Port int
}{
Host: "localhost",
Port: 8080,
}
fmt.Printf("serving on %s:%d\n", config.Host, config.Port)
}
There is no type declaration -- the struct type is written inline and immediately given a value. The rule of thumb is simple: if a shape is used in exactly one place and giving it a name would only add noise, an anonymous struct keeps the definition right where it is used. If the same shape shows up twice, promote it to a named type so the two uses cannot drift apart. You will see anonymous structs most often in table-driven tests (a slice of anonymous structs, one per case, is the canonical Go test pattern) and when shaping a quick JSON payload for a single request, both of which we reach later in the series -- so file the syntax away now, and it will feel familiar when it returns.
The same idea in Python
Python models this with classes; the closest low-ceremony equivalent is a dataclass. Python methods always receive self by reference, so there is no value-versus-pointer choice -- mutation is the default, which is convenient but gives you less control:
from dataclasses import dataclass
@dataclass
class Rectangle:
width: float
height: float
def area(self) -> float:
return self.width * self.height
class Counter:
def __init__(self):
self.count = 0
def inc(self): # self is always by reference; this mutates
self.count += 1
r = Rectangle(3, 4)
print(r.area())
Python gives you inheritance and always-by-reference methods; Go gives you composition via embedding and an explicit value-or-pointer receiver choice. The Go way is a little more deliberate and, once it clicks, leaves you in clear control of exactly what copies and what mutates.
Exercises
A mutable stack. Define
type Stack struct { items []int }with pointer-receiver methodsPush(v int)andPop() (int, bool)(the bool reports whether the stack was non-empty). Explain in a comment why these must be pointer receivers.Embed for reuse. Define a
Timestampedstruct with aCreatedAt time.Timefield and aAge() time.Durationmethod, then embed it in aPoststruct that adds aTitle string. Create aPostand call the promotedAge()method on it.A validating constructor. Write
NewTemperature(celsius float64) (*Temperature, error)that rejects anything below absolute zero (-273.15). Return a helpful error for a bad value and a valid*Temperatureotherwise. Keep the field private so the only way in is through the constructor.
What we learned
- A struct bundles named fields; build one with a composite literal (prefer named fields), and print it with
%+vto see the field names; - A method is a function with a receiver; a value receiver operates on a copy, a pointer receiver (
*T) operates on the original and can mutate it; - Use a pointer receiver when the method must mutate the receiver or the struct is large, and keep a type's receivers consistent (all pointer, or all value);
- Go replaces inheritance with embedding: an unnamed embedded type promotes its fields and methods to the outer struct -- composition, not an "is-a" hierarchy;
- Structs of comparable fields compare with
==field by field and can be map keys; structs holding slices/maps/functions are not comparable; - The constructor idiom is a plain
NewTfunction returning(*T, error), the natural place to validate so a value never exists in a broken state.
Next episode is the one that makes Go click: interfaces -- how a type satisfies one just by having the right methods (no implements keyword), why that decouples your code so cleanly, and the empty interface any. See you there.
Tot de volgende keer! ;-)