Exercises — Module 5: Pointers¶
Work through exercises in order — they're designed to build on each other. Attempt each problem genuinely before looking at the solution. Seeing the solution first might feel like progress, but it isn't.
Instructions¶
- Attempt first. Spend at least the estimated time on each problem before checking hints or solutions.
- Write your work. Don't just run code mentally — actually write or type your attempt.
- Check your answer against the acceptance criteria, not just the solution code.
- Score yourself honestly in the Scoring Log at the bottom.
- If you're stuck after a genuine effort, use the hints one at a time — not all at once.
Difficulty Legend¶
| Symbol | Difficulty | Expected Time | Points |
|---|---|---|---|
| 🟢 Easy | Recall and basic application | 5–10 min | 1 pt |
| 🟡 Medium | Requires combining 2+ concepts | 15–25 min | 2 pts |
| 🔴 Hard | Multi-step, requires real problem-solving | 30–60 min | 3 pts |
| ⭐ Challenge | Open-ended; more than one good answer | 60+ min | 5 pts |
Exercise 1: Address-Of and Dereference Basics [🟢 Easy] [1 pt]¶
Context¶
The foundation of pointers is two operators: & (take the address of a variable) and * (follow the address to read or write the value). This exercise confirms you can use both in a simple program before moving to more complex use cases.
Task¶
Write a Go program that:
1. Declares an integer variable n := 7
2. Takes its address and stores it in p
3. Prints p (the address itself — a hex number) and *p (the value at the address)
4. Modifies n through the pointer: *p = 42
5. Prints n to confirm it changed (even though you wrote through p, not n directly)
Requirements¶
-
pis declared as a*int(either explicitly or via:=from&n) - The program prints the pointer value (the address) and the dereferenced value
- The modification is done via
*p = 42, not vian = 42 - The final print of
nshows42, confirming the pointer write affected the original variable
Hints¶
Hint 1 (try without this first)
Use `p := &n` to get the pointer. `fmt.Println(p)` prints the address (a hex number like `0xc000014098`). `fmt.Println(*p)` prints the value at that address.Hint 2 (only if Hint 1 wasn't enough)
To modify through the pointer: `*p = 42`. This writes 42 to the memory location that `p` holds — the same location as `n`. After this line, both `*p` and `n` are 42.Expected Output / Acceptance Criteria¶
The exact address will differ each run; the key results are the values:
Solution¶
Show Solution (attempt first!)
package main
import "fmt"
func main() {
n := 7
p := &n // p is *int; holds the address of n
fmt.Printf("p (address): %p\n", p) // %p prints pointer in hex
fmt.Printf("*p (value): %d\n", *p)
*p = 42 // write through the pointer: sets n to 42
fmt.Println("After *p = 42:")
fmt.Printf("n = %d\n", n) // 42 — same memory as *p
}
Exercise 2: Mutating a Struct Through a Pointer [🟢 Easy] [1 pt]¶
Context¶
A function that modifies a struct must receive a pointer to the struct, not a copy of it. This is the most common real-world use of pointers in Go: passing *MyStruct to a function so it can modify the caller's struct.
Task¶
Write a Go function rename(p *struct{ Name string }, newName string) — or use a named struct type — that changes the Name field of the struct it receives. Call the function with a struct and verify the change is visible in the caller.
More specifically:
1. Define a struct type Person with a Name string field
2. Write a function rename(p *Person, newName string) that sets p.Name = newName
3. In main, create alice := Person{Name: "Alice"}, call rename(&alice, "Alicia"), and print alice.Name to confirm it changed
Requirements¶
-
renametakes*Person, notPerson - The assignment inside
renameusesp.Name = newName(automatic dereference — not(*p).Name) - The caller passes
&alice - After the call,
alice.Nameis"Alicia"
Hints¶
Hint 1
Define the struct: `type Person struct { Name string }`. The function signature is `func rename(p *Person, newName string)`. Inside, `p.Name = newName` — Go automatically dereferences `p` for field access, so you don't need `(*p).Name`.Expected Output / Acceptance Criteria¶
Solution¶
Show Solution
package main
import "fmt"
type Person struct {
Name string
}
// rename modifies the Name field of the Person p points to.
// Must take *Person — a plain Person parameter would modify a copy.
func rename(p *Person, newName string) {
p.Name = newName // automatic dereference: equivalent to (*p).Name = newName
}
func main() {
alice := Person{Name: "Alice"}
fmt.Println("Before:", alice.Name)
rename(&alice, "Alicia") // pass the address of alice
fmt.Println("After: ", alice.Name)
}
Exercise 3: nil Guard in Practice [🟡 Medium] [2 pts]¶
Context¶
Any function that accepts a pointer parameter must decide: is nil a valid input? If yes, handle it. If no, document that nil is invalid and detect it early. This exercise practices writing safe pointer-accepting functions with explicit nil guards and demonstrates the panic that occurs without them.
Task¶
Write a Go function safeDouble(p *int) (int, error) that:
1. Returns an error with message "nil pointer" if p is nil
2. Otherwise doubles the value at p in place (*p *= 2) and returns the new value and a nil error
Then demonstrate both paths in main:
- Call with a valid pointer to an integer and print the result
- Call with nil and print the error
- (Optional) Show what happens with a raw nil dereference in a comment or a separate function with recover
Requirements¶
-
safeDoublechecksp == nilbefore any dereference - Returns
(int, error)— idiomatic Go error handling - The nil path returns
(0, errors.New("nil pointer"))(or similar) - The happy path returns
(*p, nil)after doubling -
maindemonstrates both paths and prints results
Hints¶
Hint 1
The function signature is `func safeDouble(p *int) (int, error)`. Import `"errors"`. The nil check is `if p == nil { return 0, errors.New("nil pointer") }`. Then `*p *= 2` modifies in place; `return *p, nil` returns the new value.Hint 2
In `main`:Expected Output / Acceptance Criteria¶
Solution¶
Show Solution
package main
import (
"errors"
"fmt"
)
// safeDouble doubles the value at p in place and returns the new value.
// Returns an error if p is nil.
func safeDouble(p *int) (int, error) {
if p == nil {
return 0, errors.New("nil pointer")
}
*p *= 2
return *p, nil
}
func main() {
n := 5
if val, err := safeDouble(&n); err == nil {
fmt.Println("doubled:", val) // 10
} else {
fmt.Println("error:", err)
}
// nil path
if _, err := safeDouble(nil); err != nil {
fmt.Println("error:", err) // error: nil pointer
}
}
Exercise 4: new() vs &T{} [🟡 Medium] [2 pts]¶
Context¶
Go provides two ways to get a pointer to a heap-allocated value: new(T) (allocates and zeroes) and &T{...} (composite literal then address). Understanding the difference and knowing which to use is an important practical skill.
Task¶
Write a Go program that:
1. Creates a *int using new(int), sets its value to 100, and prints it
2. Creates a *Point (where Point has X, Y float64) using new(Point), sets X = 1.0 and Y = 2.0 through the pointer, and prints it
3. Creates a *Point using &Point{X: 3.0, Y: 4.0} (composite literal) and prints it
4. Creates a *Point using &Point{} (zero value) and prints it
5. Adds a comment explaining which form you would prefer in real code and why
Requirements¶
- At least one use of
new(T)for a primitive type - At least one use of
new(T)for a struct (with post-construction field assignment) - At least one use of
&T{field: value}with initialization - At least one use of
&T{}for zero value - A comment explaining the preference
Hints¶
Hint 1
For `new(Point)`: `p := new(Point)` gives you `*Point` with `X=0, Y=0`. Then `p.X = 1.0; p.Y = 2.0` sets the fields. For `&Point{X: 3.0, Y: 4.0}`: this creates the struct and takes its address in one expression.Expected Output / Acceptance Criteria¶
Solution¶
Show Solution
package main
import "fmt"
type Point struct {
X, Y float64
}
func main() {
// 1. new for a primitive type
p := new(int)
*p = 100
fmt.Printf("new int: %d\n", *p)
// 2. new for a struct — fields set after allocation
pt1 := new(Point)
pt1.X = 1.0
pt1.Y = 2.0
fmt.Printf("new Point: %v\n", pt1)
// 3. &T{} with field initialization — preferred for structs
pt2 := &Point{X: 3.0, Y: 4.0}
fmt.Printf("&Point{}: %v\n", pt2)
// 4. &T{} zero value
pt3 := &Point{}
fmt.Printf("zero: %v\n", pt3)
// Preference: &T{} is preferred because it initializes fields in one expression,
// makes the type visible at the allocation site, and is consistent with
// all composite type literals. new(T) is fine for primitives but
// rarely used for structs in modern Go code.
}
Exercise 5: Value vs Pointer Semantics for a Struct [🔴 Hard] [3 pts]¶
Context¶
This exercise directly confronts the most common pointer mistake: writing a function that appears to modify a struct but doesn't, because the struct was passed by value. You will write two versions of an update function, observe the difference, and fix the broken one.
Task¶
- Define a struct
RectanglewithWidth float64andHeight float64fields - Write a function
scaleValue(r Rectangle, factor float64)that multiplies both dimensions byfactor— passing by value - Write a function
scalePointer(r *Rectangle, factor float64)that does the same — passing by pointer - In
main, createrect := Rectangle{Width: 10, Height: 5} - Call
scaleValue(rect, 2)and printrect— observe it is unchanged - Call
scalePointer(&rect, 2)and printrect— observe it doubled
Then answer (as a comment in your code): why did scaleValue not change the original rectangle?
Requirements¶
- Both functions are implemented and the distinction is visible in the output
-
scaleValueuses a value receiver (Rectangle) — it modifies a copy -
scalePointeruses a pointer (*Rectangle) — it modifies the original - A comment in the code explains why
scaleValuedoesn't affect the original - Output clearly shows rectangle unchanged after
scaleValueand doubled afterscalePointer
Hints¶
Hint 1 (structural hint — try designing without this first)
`scaleValue` takes `r Rectangle` — inside the function, `r` is a copy. Modifying `r.Width` and `r.Height` modifies the copy's fields; the caller's `rect` is untouched. `scalePointer` takes `r *Rectangle` — `r.Width *= factor` uses automatic dereference to modify the caller's fields.Hint 2 (conceptual hint)
Go's call-by-value copies the struct on every call. When `scaleValue` runs, it has its own private copy of the Rectangle. The copy is discarded when the function returns. The original `rect` in main was never touched.Hint 3 (near-solution hint — only if truly stuck)
func scaleValue(r Rectangle, factor float64) {
r.Width *= factor // modifying the copy
r.Height *= factor // discarded on return
}
func scalePointer(r *Rectangle, factor float64) {
r.Width *= factor // automatic dereference: (*r).Width *= factor
r.Height *= factor // modifies the caller's Rectangle
}
Expected Output / Acceptance Criteria¶
Initial: {10 5}
After scaleValue(2): {10 5} ← unchanged (value copy)
After scalePointer(2): {20 10} ← doubled (modified through pointer)
Solution¶
Show Solution
package main
import "fmt"
type Rectangle struct {
Width float64
Height float64
}
// scaleValue receives a COPY of the rectangle.
// Modifications to r do not affect the caller's variable.
func scaleValue(r Rectangle, factor float64) {
r.Width *= factor // modifying a copy — caller's Rectangle is unchanged
r.Height *= factor // this copy is discarded when the function returns
}
// scalePointer receives a POINTER to the rectangle.
// Modifications through r affect the caller's variable.
func scalePointer(r *Rectangle, factor float64) {
r.Width *= factor // automatic dereference: (*r).Width *= factor
r.Height *= factor // modifies the Rectangle that the caller owns
}
func main() {
rect := Rectangle{Width: 10, Height: 5}
fmt.Printf("Initial: %v\n", rect)
// Pass by value — rect is unaffected
scaleValue(rect, 2)
fmt.Printf("After scaleValue(2): %v\n", rect) // {10 5} — unchanged
// Pass by pointer — rect is modified
scalePointer(&rect, 2)
fmt.Printf("After scalePointer(2): %v\n", rect) // {20 10} — doubled
// Why scaleValue doesn't work: Go copies all function arguments.
// scaleValue receives a new Rectangle with the same field values.
// Any changes inside scaleValue affect only that private copy,
// which is thrown away when the function returns.
// scalePointer receives the ADDRESS of rect — the function
// reaches back through the pointer and modifies the original.
}
Exercise 6: Optional Config with Nil Pointer [🔴 Hard] [3 pts]¶
Context¶
Using a pointer to represent an optional value is a common Go idiom, especially in configuration structs and JSON-mapped types. A *int can be nil (the option was not specified) or point to a value (the option was specified, even if that value is the type's zero value). This exercise practices that pattern in a realistic scenario.
Task¶
Implement a ServerConfig struct and a configureServer function:
- Define
ServerConfigwith: Host string(required)Port *int— optional; if nil, use port 8080 as the defaultMaxConns *int— optional; if nil, use 100 as the default-
TLSEnabled *bool— optional; if nil, default to false -
Write
applyDefaults(cfg *ServerConfig)that fills in nil fields with their defaults -
Write
printConfig(cfg ServerConfig)that prints all fields (dereferencing pointers safely) -
In
main: - Create a config with only
Hostset; callapplyDefaults; print it - Create a config with all fields set; call
applyDefaults; print it (defaults should not override explicit values)
Requirements¶
- All three pointer fields are
*int/*boolin the struct definition -
applyDefaultschecks each pointer field for nil before assigning a default -
printConfigdereferences pointer fields safely (nil guard or confirm non-nil after applyDefaults) - The "all set" config shows the explicit values, not the defaults
Hints¶
Hint 1 (structural hint)
To set a default: `if cfg.Port == nil { defaultPort := 8080; cfg.Port = &defaultPort }`. You cannot write `cfg.Port = &8080` — you must use a named variable.Hint 2 (conceptual hint)
After `applyDefaults` runs, all pointer fields are guaranteed non-nil. So in `printConfig`, you can safely dereference them without additional nil guards (assuming `applyDefaults` is always called first). Alternatively, use a helper like `derefInt(p *int) int` that returns 0 if nil.Hint 3 (near-solution hint — only if truly stuck)
Expected Output / Acceptance Criteria¶
=== Minimal config (after defaults) ===
Host: localhost
Port: 8080
MaxConns: 100
TLS: false
=== Full config (after defaults) ===
Host: myserver.com
Port: 9443
MaxConns: 500
TLS: true
Solution¶
Show Solution
package main
import "fmt"
type ServerConfig struct {
Host string
Port *int // nil = use default (8080)
MaxConns *int // nil = use default (100)
TLSEnabled *bool // nil = use default (false)
}
// applyDefaults fills nil optional fields with their defaults.
// Takes *ServerConfig to modify the caller's config.
func applyDefaults(cfg *ServerConfig) {
if cfg.Port == nil {
port := 8080
cfg.Port = &port
}
if cfg.MaxConns == nil {
maxConns := 100
cfg.MaxConns = &maxConns
}
if cfg.TLSEnabled == nil {
tls := false
cfg.TLSEnabled = &tls
}
}
// printConfig prints all fields. Assumes applyDefaults was called first.
func printConfig(cfg ServerConfig) {
fmt.Printf("Host: %s\n", cfg.Host)
fmt.Printf("Port: %d\n", *cfg.Port)
fmt.Printf("MaxConns: %d\n", *cfg.MaxConns)
fmt.Printf("TLS: %v\n", *cfg.TLSEnabled)
}
func main() {
// Minimal config — only Host set
minimal := ServerConfig{Host: "localhost"}
applyDefaults(&minimal)
fmt.Println("=== Minimal config (after defaults) ===")
printConfig(minimal)
// Full config — all fields explicit
port := 9443
maxConns := 500
tls := true
full := ServerConfig{
Host: "myserver.com",
Port: &port,
MaxConns: &maxConns,
TLSEnabled: &tls,
}
applyDefaults(&full) // should not change anything
fmt.Println("\n=== Full config (after defaults) ===")
printConfig(full)
}
Exercise 7: Singly-Linked List [⭐ Challenge] [5 pts]¶
Context¶
A singly-linked list is the canonical data structure for practicing pointer manipulation. Each node holds a value and a pointer to the next node; the last node's Next pointer is nil, terminating the list. This exercise requires building and traversing a pointer chain — directly exercising every pointer concept from this module.
Task¶
Implement a singly-linked list of integers:
- Define
Nodestruct withValue intandNext *Node - Write
prepend(head **Node, value int)that inserts a new node at the beginning of the list (modifies the head pointer) - Write
printList(head *Node)that traverses and prints all values - Write
length(head *Node) intthat counts nodes - In
main, build a list by prepending 1, 2, 3, 4, 5 (so the list ends up as 5 → 4 → 3 → 2 → 1), print it, and print its length
Requirements¶
-
Nodestruct is defined withValue intandNext *Node -
prependtakes**Node(a pointer to the head pointer) so it can replace the head -
printListtraverses viafor current := head; current != nil; current = current.Next -
lengthcounts nodes by traversing the list - The list after prepending 1–5 is: 5 → 4 → 3 → 2 → 1 → nil
Hints¶
Hint 1 (structural hint — try designing without this first)
`prepend` needs `**Node` because it must change what the head points to. The pattern: `*head` is the current head pointer; `newNode.Next = *head` links the new node to the existing list; `*head = newNode` makes the new node the head.Hint 2 (conceptual hint)
Why `**Node` and not `*Node`? If `prepend` took `*Node`, it would receive a copy of the head pointer. Assigning to the parameter would change the local copy, not the caller's `head` variable. `**Node` lets `prepend` write to the address of the caller's head pointer, updating it in place.Hint 3 (near-solution hint — only if truly stuck)
Traversal pattern:Expected Output / Acceptance Criteria¶
Solution¶
Show Solution
package main
import "fmt"
// Node is a singly-linked list node.
type Node struct {
Value int
Next *Node // nil for the last node
}
// prepend inserts a new node with value at the front of the list.
// Takes **Node so it can replace the caller's head pointer.
func prepend(head **Node, value int) {
newNode := &Node{
Value: value,
Next: *head, // new node points to the current head
}
*head = newNode // update the caller's head to the new node
}
// printList prints all values in the list, showing the nil terminator.
func printList(head *Node) {
fmt.Print("List: ")
for current := head; current != nil; current = current.Next {
fmt.Printf("%d", current.Value)
if current.Next != nil {
fmt.Print(" → ")
}
}
fmt.Println(" → nil")
}
// length returns the number of nodes in the list.
func length(head *Node) int {
count := 0
for current := head; current != nil; current = current.Next {
count++
}
return count
}
func main() {
var head *Node // starts as nil (empty list)
// Prepend 1, 2, 3, 4, 5 — result: 5 → 4 → 3 → 2 → 1 → nil
for i := 1; i <= 5; i++ {
prepend(&head, i)
}
printList(head)
fmt.Printf("Length: %d\n", length(head))
}
Scoring Log¶
Record your performance honestly. Include the date and whether you used hints.
| Exercise | Date | Score | Used Hints? | Notes |
|---|---|---|---|---|
| Exercise 1 — Address-Of and Dereference Basics | — | —/1 | — | — |
| Exercise 2 — Mutating a Struct Through a Pointer | — | —/1 | — | — |
| Exercise 3 — nil Guard in Practice | — | —/2 | — | — |
Exercise 4 — new() vs &T{} |
— | —/2 | — | — |
| Exercise 5 — Value vs Pointer Semantics | — | —/3 | — | — |
| Exercise 6 — Optional Config with Nil Pointer | — | —/3 | — | — |
| Exercise 7 — Singly-Linked List | — | —/5 | — | — |
| Total | —/17 |
Passing threshold: 11/17 (65%). Aim for 14/17 (82%) before taking the test.