Go from the ground up

Pointers, and text done properly

What a pointer actually is — an address, not a mystery — why Go uses them in exactly two places, and how text really works: bytes, runes and the builder that avoids a copy per line.

Part 8 of 12

Intermediate15 min readBack to Courses

A pointer is an address

A variable lives somewhere in memory, and a pointer is that address written down as a value. The ampersand takes the address of something: p := &count. Writing the star on the front reaches through the address to the value behind it, and assigning through it changes the original.

That is the whole idea. Go keeps pointers deliberately plain: no arithmetic on them, no pointer to a local that outlives its function — the compiler would move that value to the heap first, so you never hold a dangling address.

Where Go actually uses them

Two places, mostly. The first is methods that change their receiver, which is the pointer receiver from two chapters ago — same address, same reach-through, and the reason Add could change the counter while Summary could not.

The second is passing something large without copying it, or something that must be shared: a struct passed by pointer is the same struct everywhere, so a change in one place is visible in all of them. A nil pointer, read through, panics — which is why a function that takes one checks it, or documents that it will not.

A pointer is the wrong tool when the value is small and the method only reads it. Copying an int costs less than following an address, and a value receiver cannot be nil, so there is no nil check to write. Reach for a pointer when the method must change the value, when the struct is large, or when nil is a state the type genuinely has — not by habit.

Text is bytes, runes are characters

A Go string is an immutable run of bytes, usually UTF-8. len counts bytes, and one character may take more than one — "é" is two bytes. To walk characters rather than bytes, range over the string gives you runes: the position and the code point, decoded properly.

Joining text in a loop with + builds a new string every time, copying everything already there. strings.Builder appends to one growing buffer instead, and its String() gives the answer at the end — the standard fix for a loop that assembles output, and the reason strings.Join exists for the simple case.

An address, and a buffer that grows

package main

import (
	"fmt"
	"strings"
)

type Tally struct {
	Count int
}

func (t *Tally) Add() {
	t.Count++
}

func main() {
	tally := &Tally{}
	tally.Add()
	tally.Add()
	fmt.Println("counted:", tally.Count)

	name := "Zoë"
	fmt.Println("bytes:", len(name), "- characters:", len([]rune(name)))
	for i, r := range name {
		fmt.Printf("  position %d holds code point %d\n", i, r)
	}

	var b strings.Builder
	for i := 0; i < 3; i++ {
		fmt.Fprintf(&b, "line %d; ", i+1)
	}
	fmt.Println("built:", strings.TrimSuffix(b.String(), "; "))
}

A tally you can share

Run it, then add a method that subtracts, and change the character loop to print the byte position and the character for a word of your own — try one with an accent.

Output
Click "Run" to execute your code

You rarely declare a pointer variable yourself. The pointer appears in a receiver or a parameter, and the compiler takes the address at the call — tally.Add() where Add has a pointer receiver is already passing the address.

Addresses, escapes and string headers

&x is legal only for something addressable, and the pointer stays valid for as long as it is used. When the compiler cannot prove that the value dies with the function, it moves the value to the heap — escape analysis — so returning &T{} from a constructor is safe, not a dangling address.

A string value is a pointer and a length, and the bytes it points at are immutable. s[0] is a byte, s[0] = ... does not compile, and s[1:3] is a window on the same bytes with no copy. Converting to []byte copies them, and converting back copies again.

Ranging over a string decodes UTF-8 and gives the byte index of each rune, which is why the indexes can jump by two or three. Work in runes when the text matters; work in bytes when lengths, slicing or a wire format matter — and be explicit about which one the code needs.

A string, byte by byte

A worked string: "café" is five bytes and four runes. The c, a and f are one byte each; the é is two, 0xC3 0xA9. len answers 5. range yields four values, at byte positions 0, 1, 2 and 3, and the last is the code point 233. s[3] is the first byte of the é, not the character; s[3:5] is the whole character, and s[3:4] is half of it — a string that is no longer valid UTF-8. The same text, measured three ways, and each measure answers a different question.

Three string mistakes: len(s) is a byte count, not a character count; slicing at an arbitrary index can cut a multi-byte rune in half and leave invalid UTF-8; and building a string with += in a loop copies everything so far on every pass. Use strings.Builder for that, and do not copy a Builder after its first write.

Try these next

  • Reverse a string through []rune, then try the same code byte by byte on an accented word and compare.
  • Print the byte index and code point of every rune in "naïve".
  • Build the same report with += and with strings.Builder, and call Grow when the final size is known.

Initials, runes and a built report

Run it, then add a fourth name and a second accented word built from a code point. Change `initials` to keep the first and last initial without breaking the byte counts.

Output
Click "Run" to execute your code

Check what you learned

One question at a time. A wrong answer explains itself — take another go.

Part 1 of 5

What does &count give you?