JavaRush /Courses /Go SELF /Review — pointers and value semantics

Review — pointers and value semantics

Go SELF
Level 20, Lesson 3
Available

1. The main mantra: “in Go, everything is passed by value”

If you’ve ever felt that pointers in Go are “magic that sometimes works and sometimes bites”, the problem usually isn’t pointers. The problem is expectations: it feels like “I passed it into a function — it changed there”. In Go, the basic rule is different: a function always receives a copy of the argument. And only then do we clarify what exactly we copied: a number, a slice header, a map header, or an address.

The easiest way is to keep a simple diagram in mind:

flowchart TD
    A["Call f(x)"] --> B["Value x is copied"]
    B --> C["The function works with the copy"]
    C --> D["Outside, only what the copy points to changes (if it points to anything)"]

This rule is the foundation. And pointers are just one of the ways to make that “copy point outward”.

2. A pointer in Go: address & and dereferencing *

When people say “pointer”, it’s easy to immediately imagine “scary C and memory leaks”. But in Go a pointer is a much more everyday thing: a value that stores the address of another value. You take an address with the & operator, and to get to the value at that address, you use * (dereferencing). It’s exactly the same pair of operators you’ve already seen as &x with Scan, except now we consciously understand what we’re doing.

The syntax in declarations and expressions is intentionally similar: p *int means “pointer to int”, and *p means “the value that p points to”.

Mini example: why “just passing an int” doesn’t change the variable

You can see the “argument copy” rule here:

package main

import "fmt"

func inc(n int) {
	n++
}

func main() {
	x := 10
	inc(x)
	fmt.Println(x) // 10
}

inc incremented a copy of x. The original stayed the same.

Mini example: changing the original via *int

Now we’ll pass not the number, but the address:

package main

import "fmt"

func inc(p *int) {
	*p++
}

func main() {
	x := 10
	inc(&x)
	fmt.Println(x) // 11
}

We passed a copy of the pointer, but that pointer points to the original x, so the change via *p is visible outside.

3. Where pointers are actually useful

As a beginner, it’s very tempting to do “everything with pointers” so that it “definitely works”. That’s normal: your brain is trying to find a universal tool. The problem is that pointers are a precise tool. They’re good when you can clearly formulate why you need an address rather than a value.

You need to change a variable outside the function

This is the most straightforward case: there’s a variable, and the function must update it. Examples: a counter, remaining money, “next ID”, accumulating stats.

A bit later we’ll apply this in our learning mini app (task list) via nextID.

You need to be able to say “there is no value”

In Go there is no separate “nullable int” type (in the core language). So sometimes people do this: *int instead of int. Then nil means “no value”. This is especially convenient when 0 is a valid number and you don’t want to mix up “0” and “not set”.

A small example:

package main

import "fmt"

func printLimit(limit *int) {
	if limit == nil {
		fmt.Println("limit: not set") // limit: not set
		return
	}
	fmt.Println("limit:", *limit)
}

func main() {
	printLimit(nil)

	x := 5
	printLimit(&x) // limit: 5
}

It’s important to understand that nil is part of the contract. If the contract says “it can be nil”, then inside the function there must be a check.

4. Where pointers are not needed: slices and maps already “reference”

Now an important point that confuses many people: “but I didn’t pass a pointer, and it still changed”. This usually happens with slices and maps.

A slice is not an array, but a “header + data”

A slice internally stores a pointer to the data array, a length, and a capacity. Two different slices can view the same underlying array.

This means two things at the same time:

First: if a function changes a slice element, it’s visible outside, because the element lives in the shared underlying data array.

Second: if a function changes the slice length (via append, Delete, and similar), it must return a new slice, because the header changes (len/cap and possibly the pointer). That’s exactly why append returns a value.

Mini example: changing a slice element is visible without pointers

package main

import "fmt"

func setFirst(s []int) {
	s[0] = 99
}

func main() {
	a := []int{1, 2, 3}
	setFirst(a)
	fmt.Println(a) // [99 2 3]
}

We passed a copy of the header, but that header points to the same underlying data array.

Mini example: append “changes the slice”, so you must store the result

package main

import "fmt"

func addOne(s []int) []int {
	return append(s, 10)
}

func main() {
	a := []int{1, 2}
	a = addOne(a)
	fmt.Println(a) // [1 2 10]
}

If you don’t save the result, you may keep living with the old length or, worse, with the old header.

The same idea applies to functions like slices.Delete: they return a new slice, and ignoring the result is a bug.

5. new, make, and nil: how to create and represent “absence”

When new, make, or nil shows up in code, beginners often switch into “welp, magic has begun” mode. In reality the difference is very practical: they are about different things and different contracts.

new and make: short and to the point

new(T) allocates memory for a value of type T, stores the zero value there, and returns *T.

make creates a ready-to-use value for special “built-in containers”: []T, map[K]V, chan T. (We don’t need channels right now, but we’ll remember the rule.)

A comparison is easiest to see directly in code:

package main

import "fmt"

func main() {
	p := new(int)          // *int, inside 0
	s := make([]int, 0, 2) // []int, ready for append
	m := make(map[string]int)

	fmt.Println(*p)        // 0
	fmt.Println(len(s))    // 0
	fmt.Println(len(m))    // 0
}

If you want a quick “sanity check”: new returns a pointer, make does NOT return a pointer.

nil: one word, different roles

With nil, the story is: one word, several meanings, and that’s why people get bugs. nil can mean “no pointer”, “no map”, “no slice”. But the behavior differs — and that’s fine: the contracts differ.

nil pointer: you must not dereference it

If you have var p *int = nil, then *p is a panic. Nothing philosophical here: there is no address, so there is nothing to read.

nil slice: usually safe

A nil slice behaves like an empty one in terms of len==0, and append works. It will just allocate the data array and return a new slice header.

nil map: you can read, but you can’t write

Reading from a nil map yields the zero value, and range over it simply won’t run. But writing (m["x"]=1]) will panic, because there is no internal map structure.

In practice, this becomes a very simple rule: a map that you plan to write to must be initialized with make.

6. Pointer traps: “the address exists, but it’s not the one you wanted”

Pointer bugs rarely look like “I don’t understand the syntax *p”. More often they look like: “why are all my pointers the same?” or “why did I change it through a pointer, but nothing changed in the slice?”. This is no longer about syntax, but about lifetime and what exactly you took the address of.

The address of one reused variable in a loop

In modern Go (1.25) the most popular case with for , v := range ... is fixed (there the variable is usually a separate one per iteration), but this trap is still easy to create if the variable is declared outside the loop.

package main

import "fmt"

func main() {
	nums := []int{10, 20, 30}

	var v int
	ptrs := make([]*int, 0, len(nums))

	for _, n := range nums {
		v = n
		ptrs = append(ptrs, &v) // same address every time
	}

	fmt.Println(*ptrs[0], *ptrs[1], *ptrs[2]) // 30 30 30
}

The problem isn’t append, isn’t range, it’s that &v is always the same address.

If you really need addresses of slice elements, it’s usually correct to take &nums[i]:

package main

import "fmt"

func main() {
	nums := []int{10, 20, 30}
	ptrs := make([]*int, 0, len(nums))

	for i := range nums {
		ptrs = append(ptrs, &nums[i])
	}

	fmt.Println(*ptrs[0], *ptrs[1], *ptrs[2]) // 10 20 30
}

Pointer to a slice element and append: “moved to a new array”

This is very Go-like: append may grow the underlying data array and move the elements into a new one. Then old pointers to elements will still point to the old array. The slice now points to the new one, but your pointer points to the old one. Nobody is at fault; you’re just holding an address “from before the move”.

Let’s show it in a way that guarantees a move: we’ll make cap=1.

package main

import "fmt"

func main() {
	s := make([]int, 1, 1)
	s[0] = 10

	p := &s[0]
	s = append(s, 20) // cap not enough -> new array

	*p = 99
	fmt.Println(s[0], *p) // 10 99
}

This is not “random” and not “Go is broken”. It’s a consequence of how slices work: the slice header points to the underlying array and may start pointing to a new one.

Why you can’t do &m[k]

You can’t take the address of a map element: &m[k] does not compile. The reason is simple and practical: map elements can move inside the structure during growth/rehashing, and the language does not promise a “stable element address”.

If you want to update a value in a map, do it via assignment:

package main

import "fmt"

func main() {
	m := map[string]int{"a": 1}

	x := m["a"]
	x++
	m["a"] = x

	fmt.Println(m["a"]) // 2
}

7. “Value semantics” using an educational mini app example

So this doesn’t remain just theory, let’s assemble a small piece of logic for an educational task list that we’ve already used many times as a reason to practice input, strings, errors, slices, and maps. This time the focus won’t be on the CLI and commands, but on how to pass state into functions without getting confused.

We’ll store tasks without structs (we’ll get to structs in another part of the course), so we’ll take a minimal set:

  • titles map[int]string — task title by ID
  • done map[int]bool — completion status by ID
  • nextID int — the next ID to be issued

Why we pass map “as a value” and don’t use *map

A map itself behaves like a “reference-like thing”: you pass a copy of the header, but it points to the same internal table. So a function can safely write into the map, and it will be visible outside — without any *map.

Here is a function to add a task: we need a pointer only for nextID, because it’s a plain number and we want to increment it “outside”.

package main

import "fmt"

func addTask(nextID *int, titles map[int]string, done map[int]bool, title string) int {
	id := *nextID
	*nextID++

	titles[id] = title
	done[id] = false
	return id
}

func main() {
	titles := make(map[int]string)
	done := make(map[int]bool)
	nextID := 1

	id := addTask(&nextID, titles, done, "buy milk")
	fmt.Println(id, titles[id], done[id]) // 1 buy milk false
}

Notice: we pass the map without a pointer, but nextID with a pointer. This is “value semantics” in action: where you need to change an external number — use an address; where you’re changing a shared referenced structure — the value is enough.

A slice as state: when you need to return a new one

Now let’s imagine we also have a list of IDs in the order they were added, so we can print tasks in a stable order: order []int. When adding an ID we do append. And here the rule about the slice header shows up again: if the length changes, the function must return a new slice.

package main

import "fmt"

func addToOrder(order []int, id int) []int {
	return append(order, id)
}

func main() {
	order := make([]int, 0, 2)

	order = addToOrder(order, 1)
	order = addToOrder(order, 2)

	fmt.Println(order) // [1 2]
}

If you forget order = ..., then formally the program may “seem to work” in some cases, and then suddenly start behaving strangely. That’s why around slices in Go there’s so much talk about “always save the result of append”. It’s not pedantry; it’s real protection from bugs.

By the way, the same applies to functions from the slices package, like Delete: they return a new slice, and ignoring the result is a bug.

“Unexpected nil inside a slice” as a consequence of incorrect usage

Another interesting point that’s useful when reading someone else’s code: if you misuse functions that change a slice’s length, you can end up with “unexpected zeros” in your data (nil for pointers). In modern Go versions, standard slice functions actively “clear the tail”, and tests may start failing where they previously passed by accident.

Even if you don’t use slices.Delete every day yet, this highlights the general idea: after length-changing operations, it’s better to treat the old slice as “stale”.

8. Common mistakes

Mistake #1: expecting that passing an int lets you change the value outside.
By default, function arguments are passed by value — a copy is created. If you change a parameter of type int inside the function, nothing will change outside. If by meaning you need to modify the caller’s variable — pass a *int. If you don’t — keep int and don’t complicate the contract.

Mistake #2: “fixing everything with pointers”, including slices and maps.
A slice and a map already behave like references to internal data: when copying, you copy the header, not all elements. Changing slice elements is visible without pointers, but changing the length requires returning a new slice (the header changes). Always keep in mind the slice model as pointer + len + cap.

Mistake #3: collecting pointers in a loop to the same variable.
If a variable is declared outside the loop and you take its address on each iteration, you’ll get a set of pointers to the same address. The code compiles, but all the values end up the same. The fix is to take the address of the real element (&nums[i]) or to create a new variable inside each iteration.

Mistake #4: storing a pointer to a slice element and then calling append.
If append increases the capacity and the array moves to a new memory location, the old pointer will keep pointing to the “old” array. In small examples this might not show up (if cap is sufficient), but in real code it will lead to hard-to-catch bugs. The rule is simple: don’t keep pointers to slice elements longer than the current underlying array is guaranteed to live.

Mistake #5: dereferencing a nil pointer.
Dereferencing nil is a guaranteed panic. There is no “maybe you’ll get lucky”: if there is no address, there is nowhere to read or write. Unlike a pointer, a nil slice is often safe, and a nil map is safe only for reading. For writing to a map, it must be initialized in advance via make.

1
Task
Go SELF, level 20, lesson 3
Locked
Piggy Bank Total
Piggy Bank Total
1
Task
Go SELF, level 20, lesson 3
Locked
Coupon or nil
Coupon or nil
1
Task
Go SELF, level 20, lesson 3
Locked
Positive Readings
Positive Readings
1
Task
Go SELF, level 20, lesson 3
Locked
Multiply in place
Multiply in place
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION