Lesson 13 / 18
Pointers
Reading from a nil pointer is left open: the language places nothing, the compiler does not reject it either, the result is a panic. Only two of four calls change the caller's value — a value written without taking an address, whether in a function parameter or a method receiver, only operates on its own copy. The limiting measure shows that a pointer is also a value: reassigning a pointer changes only the local copy, while writing to what it points to changes the caller.
Published
In this lesson
- Measures how many of four calls change the caller's value, according to whether an address was taken
- Shows that reading from a nil pointer is left open and results in a panic
- Measures that the pointer itself is a value, and that reassignment affects only the local copy
Contents
The Structs lesson closed with a struct being copied entirely when assigned, and a function that operates on the copy passed to it being unable to change anything at the caller. This lesson looks at how that limit is overcome: taking a variable’s address means passing that address instead of a copy to the called side — and the address is itself also a value.
A pointer (*T) is a type in Go that holds a variable’s place in memory. The notation &x
produces x’s address, the notation *p reads from or writes to the place a pointer points
to. These two operators — taking an address and dereferencing — stand under everything this
lesson measures: whether a call changes the caller depends on which of the two was written and
when.
When Taking an Address Is Necessary
The four types in this topic give four separate answers to the same question: when a value passed to a function is changed, does that change reach the caller without taking an address, or is an address required?
// address.go — whether taking an address is needed for an array, a slice and a struct
package main
import "fmt"
type Record struct {
Number int
}
func changeArray(d [3]int) { d[0] = 99 }
func changeArrayPtr(d *[3]int) { d[0] = 99 }
func writeSliceElement(s []int) { s[0] = 99 }
func writeMap(s map[string]int) { s["a"] = 99 }
func changeStruct(k Record) { k.Number = 99 }
func changeStructPtr(k *Record) { k.Number = 99 }
func main() {
array := [3]int{1, 2, 3}
changeArray(array)
fmt.Println("array, no address :", array)
array2 := [3]int{1, 2, 3}
changeArrayPtr(&array2)
fmt.Println("array, with address :", array2)
slice := []int{1, 2, 3}
writeSliceElement(slice)
fmt.Println("slice, no address :", slice)
dict := map[string]int{"a": 1}
writeMap(dict)
fmt.Println("map, no address :", dict["a"])
record := Record{Number: 1}
changeStruct(record)
fmt.Println("struct, no address :", record)
record2 := Record{Number: 1}
changeStructPtr(&record2)
fmt.Println("struct, with address :", record2)
}
array, no address : [1 2 3]
array, with address : [99 2 3]
slice, no address : [99 2 3]
map, no address : 99
struct, no address : {1}
struct, with address : {99}
Six lines show the different answer four composite types give to the same question. An array
and a struct do not change when passed without taking an address — both are values that are
copied entirely, the same rule established in the Arrays and Slices and Structs lessons. A
slice and a map, on the other hand, can change without ever taking an address:
writeSliceElement and writeMap took only the slice and the map themselves (their headers),
not a pointer, but both changed the data at the caller — because what changed was not the
header, it was the shared backing array or hash table the header points to. The sharing
measured in the Slice Operations and Maps lessons explains here why taking an address is
unnecessary: both already carry a pointer within themselves.
The reason taking an address is necessary for an array and a struct is the exact opposite:
neither carries any pointer within itself, what they carry is the content itself. Taking a
pointer is the only way to prevent this content from being copied. The four types thus
split into two groups: an array and a struct carry their own content and demand an address
when they are to be changed; a slice and a map carry a pointer and even their own copy is
enough when they are to be changed — growth itself (append, or a map’s hash table being
built for the first time) is a separate matter, of course, because in that case what changes
is not the content, it is the header itself.
Reading From a nil Pointer Is Left Open
A pointer becomes nil when declared without an initial value; this was one of the three
cases left open in the Zero Values lesson’s binding distribution. Reading the nil
pointer itself (looking at what the address is) is not a problem, but trying to go to the
place it points to — accessing its field — stops the runtime.
CT14. — The measurement separates two distinct questions: is the pointer itself nil (a
question that can be safely asked), and what is the field at the place the pointer points to
(a question that cannot be safely asked). The two are not mixed in the same line.
// nil.go — reading from a nil pointer is left open
package main
import "fmt"
type Record struct {
Number int
}
func panics(f func()) (p bool) {
defer func() {
if recover() != nil {
p = true
}
}()
f()
return false
}
func main() {
var pointer *Record
fmt.Println("the pointer itself is nil:", pointer == nil)
readPanicked := panics(func() { _ = pointer.Number })
fmt.Println("reading pointer.Number : panic=", readPanicked)
}
the pointer itself is nil: true reading pointer.Number : panic= true
The question pointer == nil itself is answered safely, because this is reading the
pointer’s own value — an address, or the absence of one. But the notation
pointer.Number requires first going to that address, and then reading the Number field
there; if there is no address to go to, this step stops at run time. The difference from the
nil map write panic measured in the Maps lesson is this: in a map, what panicked was only
writing; reading was defined; in a pointer, what panics is reading itself, because in
a map “the looked-for place does not exist” could be tied to a defined outcome (the zero
value), while in a pointer “there is no place to go to” has no defined counterpart.
This difference shows why the two types interpret their nil state differently. A map’s
nil state meant “I do not have any hash table yet, but I can still search” — the search
already produced a defined answer (the zero value, false) for a key that is not found. A
pointer’s nil state, on the other hand, means “there is no place I point to at all”; there
is no defined zero value to fall back on here, because what a pointer needs to read is not its
own value, it is the value at the place it points to. This is exactly the common point of the
three open cases in the Zero Values lesson: a defined counterpart exists only when the
structure of the question being asked allows for it.
Four Calls: Address or Copy
In addition to functions, a type’s methods can also be defined with a value receiver or a
pointer receiver; both are called with dot syntax, and which one is used is not visible from
the call notation. If a method definition has the form func (k Record) Name(), the receiver
is a value; if it has the form func (k *Record) Name(), the receiver is a pointer — this
single character in the parentheses (*) establishes, for methods, exactly the same
distinction just measured for function parameters. The measurement here deliberately reuses
the single-field Record type from the Structs lesson, to make it visible that what creates
the difference is not the number of fields, only the receiver’s definition.
// fourcalls.go — four calls, two of them change the caller's value
package main
import "fmt"
type Record struct {
Number int
}
func changeByCopy(k Record) { k.Number = 99 }
func changeByPointer(k *Record) { k.Number = 99 }
func (k Record) ChangeV() { k.Number = 99 }
func (k *Record) ChangeP() { k.Number = 99 }
func main() {
k1 := Record{Number: 1}
changeByCopy(k1)
fmt.Println("value-parameter function :", k1.Number)
k2 := Record{Number: 1}
changeByPointer(&k2)
fmt.Println("pointer-parameter function :", k2.Number)
k3 := Record{Number: 1}
k3.ChangeV()
fmt.Println("value-receiver method :", k3.Number)
k4 := Record{Number: 1}
k4.ChangeP()
fmt.Println("pointer-receiver method :", k4.Number)
}
value-parameter function : 1 pointer-parameter function : 99 value-receiver method : 1 pointer-receiver method : 99
Two of the four calls change the caller’s value; the goal is not to find these two, it is
to show that changing the caller comes from the receiver’s definition, not from the call
notation. The first two repeat the expected pattern: a value parameter gets its own copy, a
pointer parameter reaches back to the caller. The last two show the same pattern as a
method call: k3.ChangeV() and k4.ChangeP() are syntactically almost identical — both
in the form variableName.MethodName() — but one is defined with a value receiver, the other
with a pointer receiver, and their results differ. Looking at the call, it is not known which
will run; what determines this is the receiver type in the method’s own definition.
The notation k4.ChangeP() itself contains no pointer, but the compiler itself takes
k4’s address and expands it to (&k4).ChangeP() — because k4 is an addressable variable.
This automatic conversion makes it possible to call a pointer-receiver method through an
ordinary value; the address-taking step is not written, but it happens.
This automatic conversion has a limit: it only works for addressable values. A variable
is addressable because it has a permanent place in memory; it is not a temporary value like a
function’s return value. k4.ChangeP() worked because k4 was a variable; a call like
Record{Number: 1}.ChangeP() would not compile, because the value built with curly braces is
not itself addressable. The same automatic conversion also works in reverse: when a
value-receiver method is called through a non-nil pointer of a pointer-receiver type, the
compiler dereferences the pointer on its own.
This two-way automatic conversion turns defining a type’s methods with always the same
receiver kind into a practical habit: if some of a type’s methods have a value receiver and
others a pointer receiver, the caller has to memorize method by method which call changes the
value it calls on and which does not. The ChangeV/ChangeP pair above deliberately displays
this confusion; the two existing side by side on a real type produces exactly the ambiguity
this measurement shows.
Receiver choice does not depend only on the question of “wanting to change the caller or
not.” A value receiver produces a full copy of that value every time the method is
called — for a small type carrying a single integer, like Record, this cost is negligible,
but for a large type with many fields or embedding other structs, as in the Structs lesson, it
means copying every field on every call. A pointer receiver, because the only thing carried is
an address, makes this copying independent of the struct’s size. Even a method that does not
want to change the caller can choose a pointer receiver purely to avoid the copying cost.
Limiting Measure: A Pointer Is Also a Value
Writing to the place a pointer points to and reassigning the pointer itself are different things. In Go, everything is passed by value (established as a concept in the Programming Fundamentals course, not repeated here) — a pointer is no exception either, it is itself also a value, and a pointer parameter is copied too.
CT15. — Both functions take an integer pointer, and both look almost identical in the source; the difference is in a single line. The measurement deliberately places these two lines side by side so that the difference can be read from the text alone, with no need to run it — then the two are run separately to confirm that this reading is correct.
// value.go — a pointer is itself also a copied value
package main
import "fmt"
func reassign(p *int) {
fresh := 99
p = &fresh // changes only the local copy
}
func writeValue(p *int) {
*p = 99 // writes to what the pointer points to
}
func main() {
x := 1
reassign(&x)
fmt.Println("reassigning the pointer :", x)
y := 1
writeValue(&y)
fmt.Println("writing to what it points to :", y)
}
reassigning the pointer : 1 writing to what it points to : 99
When reassign is called, x’s address is copied into p; when p = &fresh is written
inside the function, what changes is only p‘s own copy — it now points to a different
address, but the caller’s x is not affected at all, because x itself never took fresh’s
address. writeValue, on the other hand, writes directly to the address p points to with
*p = 99; because this address is still the same as x’s (y, in this call), the write
reaches the caller.
This difference explains why the “changes once a pointer is taken” rule in the four-call measurement is correct. A pointer-parameter function changing the caller does not come from the pointer being some magic “reference,” it comes from the address the pointer carries being copied and then written to at the copied address. As long as the place it points to stays the same even when the address is copied, a write made to that place is visible from every copy — just as a slice’s backing array continues to be shared when its header is copied. A pointer is a value that continues sharing when copied; its difference from a slice, unlike a slice, is that what it points to is not a backing array, it is a single value.
The four types measured throughout this lesson — array, slice, map, struct — and the pointer itself are five appearances of one rule: in Go, assignment, parameter passing and returning always do the same job, they copy a value. The apparent difference comes from what the copied value carries. An array and a struct carry content, and the copy copies the content too. A slice, a map and a pointer all carry an address, and the copy copies the address, not the place it points to — this is why sharing continues. “Passing by reference” never existed as a separate mechanism; what existed was some values carrying a pointer and others not.
Summary
- An array and a struct do not change when passed without taking an address, because both are copied entirely; a slice can change even without taking an address, because it already carries a pointer within itself.
- Reading a
nilpointer itself is safe; trying to go to the place it points to (accessing a field) stops the runtime — an example of the open class from the Zero Values lesson. - Only two of four calls (value parameter, pointer parameter, value-receiver method, pointer-receiver method) change the caller’s value; the distinction is read not from dot syntax, but from the receiver’s definition.
- A pointer is itself also a value and is copied: reassigning a pointer changes only the
local copy, writing to what it points to (
*p = ...) reaches the caller.
Next Step
The types measured in this lesson — array, slice, struct, pointer — always carried a number or a named field; none of them carried text within itself. Text is a composite type different from these: a string, a value that looks like a single piece from the outside but is a byte array inside. Because a string is also immutable, it stays outside the copy/sharing distinction measured here, but it carries its own unwritten question. The next lesson asks this question: what does a string’s length count, what does indexing a string return, and when do these two diverge from the intuition of the word “character”?
To keep your progress and take notes, Log in
My notes
Log in to take notes.