Day 4 — Pointers, Packages & Stage I Review
Day 4 — Pointers, Packages & Stage I Review
Day 8 — Pointers & escape analysis
Stage I · ~3h (theory-heavy)
Goal: Use pointers deliberately—not fearfully—and read escape analysis (-gcflags=-m) well enough to explain stack vs heap placement of your Day 7-style types.
Go is garbage collected. You do not free. Escape analysis decides whether a variable can live on the stack or must be heap-allocated. Your job is clear ownership and correct mutability; the compiler handles most placement.
Why this day exists
Pointers show up in three conversations that people conflate:
- Correctness: mutation, optional values, shared identity
- Performance: avoid giant copies; sometimes increase heap pressure
- APIs: method sets, interfaces, JSON
null
Without a model, learners either spray * everywhere or panic at the first *T. Day 8 separates necessity from superstition, then shows the compiler’s notes.
Theory 1 — Addresses and dereferences
x := 10
p := &x // p has type *int; points to x
fmt.Println(*p) // 10
*p = 20
fmt.Println(x) // 20| Expression | Meaning |
|---|---|
&x |
Address of x (type *T if x is T) |
*p |
Dereference pointer p |
*T |
Type: pointer to T |
Zero value is nil
var p *int
fmt.Println(p == nil) // true
// *p // panic: nil pointer dereferenceAlways ensure non-nil before dereference, or document that a method accepts nil receivers.
new and composite literals
p := new(int) // *int, points to zero int
*p = 3
q := &Item{SKU: "A1"} // same idea for structs — more idiomaticPrefer &T{...} for structs; new(T) is rare but valid.
Theory 2 — Why take a pointer?
1. Mutate the caller’s value
func inc(n *int) {
*n++
}2. Avoid copying large structs
func (c *HugeConfig) Validate() error { /* ... */ }A multi-kilobyte config should not be copied on every method call.
4. Optional values
func maxAge(a *int) string {
if a == nil {
return "unspecified"
}
return fmt.Sprintf("%d", *a)
}Alternative later: option types / sql.NullInt64 patterns—but *T is common in APIs.
When values are better
- Small structs (
time.Timeis a value type by design)
- Immutable snapshots
- Map keys (pointers as keys compare by address, which is usually wrong for “same content”)
- Reducing GC pressure when escape would force heap allocation of many tiny objects
Theory 3 — Pointers to pointers (rare)
func setIfNil(pp **int, v int) {
if *pp == nil {
x := v
*pp = &x
}
}Almost never needed in application code. If you want a function to replace a slice header or map variable for the caller, prefer returning the new value:
func add(s []int, v int) []int { return append(s, v) }Theory 4 — Escape analysis (the mental model)
Stack allocation
- Cheap
- Lifetime tied to function activation
- No GC work for that object after return
Heap allocation
- Required when the compiler cannot prove the object dies before the function returns
- GC must track it
- Still automatic—no manual free
What forces escape (typical)
| Pattern | Why it escapes |
|---|---|
Returning &local |
Caller holds reference past return |
| Assigning to interface that outlives | Interface may store pointer |
| Sending pointer on channel | Concurrent lifetime unknown |
| Storing in slice/map that escapes | Shared structure outlives frame |
Calling unknown function with *T |
Compiler conservatively assumes retention |
Exact rules evolve with the compiler; read the output, do not memorize a myth list as law.
How to read -gcflags=-m
go build -gcflags='-m' .
# more detail:
go build -gcflags='-m -m' .Sample messages:
./main.go:12:6: moved to heap: x
./main.go:20:9: &Item{...} escapes to heap
./main.go:33:18: inlining call to fmt.Println
| Phrase | Rough meaning |
|---|---|
moved to heap |
Variable cannot stay on stack |
escapes to heap |
Expression’s result may be heap-allocated |
does not escape |
Stays stack-friendly (good for hot paths) |
inlining call |
Call folded; often enables better analysis |
Escape analysis messages are hints, not performance guarantees. Measure with benchmarks later (Stage VII).
Theory 5 — Common pointer pitfalls
Copying a mutex / copiable-looking struct
type Safe struct {
mu sync.Mutex
n int
}
func (s Safe) IncWrong() { // value receiver copies mutex!
s.mu.Lock()
s.n++
s.mu.Unlock()
}sync.Mutex must not be copied after first use. Use pointer receivers for types containing mutexes.
Returning pointer to loop variable (historical)
Since Go 1.22, per-iteration variables make this safer; still prefer clear code:
var out []*int
for i := 0; i < 3; i++ {
i := i
out = append(out, &i)
}Slice of pointers vs slice of values
items := []Item{{SKU: "a"}, {SKU: "b"}}
var ptrs []*Item
for i := range items {
ptrs = append(ptrs, &items[i]) // OK: address of slice element
}If you append to items later and reallocate, old pointers may dangle logically (point at old array). Prefer owning []*Item from creation if identity matters.
Theory 6 — Pointers and interfaces (preview bridge)
type Stringer interface {
String() string
}
type P struct{ X int }
func (p *P) String() string { return fmt.Sprint(p.X) }
// var s Stringer = P{} // error: P does not implement Stringer
var s Stringer = &P{X: 1} // OKMethod sets differ for T and *T. Day 11 is the full story; today connect it to receiver choice.
Worked examples bank
Example A — Mutation clarity
package main
import "fmt"
type Point struct{ X, Y int }
func moveValue(p Point, dx, dy int) Point {
p.X += dx
p.Y += dy
return p
}
func movePtr(p *Point, dx, dy int) {
p.X += dx
p.Y += dy
}
func main() {
p := Point{1, 2}
p = moveValue(p, 1, 1)
movePtr(&p, 1, 1)
fmt.Println(p)
}Example B — Optional config field
type Options struct {
TimeoutMS *int
Verbose bool
}
func effectiveTimeout(o Options, def int) int {
if o.TimeoutMS == nil {
return def
}
return *o.TimeoutMS
}Example C — Escape demo program
package main
type Node struct {
Value int
Next *Node
}
func stackCandidate() int {
x := 10
return x // x need not escape
}
func heapForced() *int {
x := 10
return &x // x escapes
}
func linked() *Node {
n := &Node{Value: 1}
n.Next = &Node{Value: 2}
return n
}Build with -gcflags=-m and annotate each line in a comment with what you saw.
Example D — Large struct: value vs pointer method
type Big struct {
// pretend many fields
Data [1024]byte
N int
}
func (b Big) CountValue() int { return b.N }
func (b *Big) CountPtr() int { return b.N }Compare escape/inlining notes when calling both in a loop (micro). Prefer clarity; do not optimize blindly.
Example E — Constructor returning pointer
func NewNode(v int) *Node {
return &Node{Value: v} // typically heap
}This is normal and good. Escape is not a failure.
Labs
Suggested workspace: ~/lab/90daysofx/go/day08
Lab 1 — Port Day 7 types and measure
mkdir -p ~/lab/90daysofx/go/day08
cd ~/lab/90daysofx/go/day08
go mod init example.com/day08Copy or reimplement a simplified Item / Store from Day 7.
go build -gcflags='-m' . 2> escape.txtIn escape.txt (or a journal), highlight:
- Something that does not escape
- Something that escapes to heap
- One line you could change (value vs pointer) and how the message changes
Lab 2 — Intentional nil safety
Implement a method on *Item that is safe when the receiver is nil. Call it on a nil pointer and on a real pointer. Contrast with a method that panics on nil.
Lab 3 — Optional pointer fields
Add an optional *int discount percent to a purchase calculator. Nil means no discount. Show JSON-less CLI flags parsing "none" vs a number into *int.
Lab 4 — Prove mutex copy danger (optional, careful)
Create a type with sync.Mutex, call a value-receiver lock method from two sequential calls and explain why this is wrong for concurrent use. Do not need multi-goroutine code yet—just document the copy.
Common gotchas
| Gotcha | Fix |
|---|---|
| Nil dereference | Check or guarantee non-nil |
| Pointers for everything “for speed” | Measure; extra heap can be slower |
Ignoring method set (T vs *T) |
Match receivers to interface needs |
Copying structs containing sync types |
Pointers + no copy |
| Fear of heap escape | Escaping is normal for shared objects |
| Using pointer map keys for equal content | Use values or canonical IDs |
| Dangling mental model from C | GC keeps pointed memory alive |
Checkpoint
&/*/*Tvocabulary solid
- Three good reasons to use a pointer
- Read
-gcflags=-moutput and explained two lines
- Nil-safe method demonstrated
- Did not conclude “heap = bad”
- Connected pointer receivers to future interfaces
Commit
git add .
git commit -m "day08: pointers + escape analysis notes"Tomorrow
Day 9 — Packages & layout: multi-package modules, exported names, internal/, and a layout that scales past a single main.go without inventing a framework.
Day 9 — Packages & layout
Stage I · ~3h (theory-heavy)
Goal: Split a module into multiple packages with clear boundaries, correct import paths, exported APIs, and an internal/ tree that cannot be imported by strangers.
A package is a directory of Go files sharing one package name. A module is the versioned unit with go.mod. Import paths start with the module path. Do not confuse folder names with package clause names—though they usually match.
Why this day exists
Single-file programs teach syntax. Real systems need:
- Separation of CLI wiring vs domain logic
- Importable libraries under test
- Prevention of accidental dependency cycles
internal/as an enforced privacy boundary
Day 9 is the last sequential-core packaging skill before the Stage I gate asks you to ship a multi-package CLI.
Theory 1 — Module path vs import path vs directory
~/lab/90daysofx/go/day09/ # directory on disk
go.mod # module example.com/day09
cmd/inv/main.go # package main → import path example.com/day09/cmd/inv
inventory/store.go # package inventory → example.com/day09/inventory
internal/parse/parse.go # package parse → example.com/day09/internal/parse
| Concept | Example |
|---|---|
| Module path | example.com/day09 |
| Package import path | example.com/day09/inventory |
| Package name (clause) | package inventory |
| Directory | .../day09/inventory |
Rules
- All files in a directory (not subdirs) are one package.
- The package clause is usually the directory’s base name.
package mainis special: builds an executable.
- You cannot have
package fooandpackage barin the same directory.
Theory 2 — Exported identifiers
package inventory
type Item struct { // exported type
SKU string // exported field
qty int // unexported field
}
func NewItem(sku string) *Item { // exported func
return &Item{SKU: sku}
}
func (i *Item) valid() bool { // unexported method
return i.SKU != ""
}Only identifiers starting with an uppercase letter are visible to importers.
Package doc
// Package inventory provides an in-memory stock store.
package inventoryThe comment immediately preceding the package clause documents the package (go doc).
Theory 3 — Imports
import (
"fmt" // stdlib
"example.com/day09/inventory" // local module package
)
func demo() {
it := inventory.NewItem("A1")
fmt.Println(it.SKU)
}Import forms
import "fmt"
import f "fmt" // rename
import . "fmt" // dot import — avoid in production
import _ "net/http/pprof" // side-effect import (init only)Init functions
func init() {
// runs before main, after package-level vars initialized
}Multiple inits allowed; order is constrained but prefer explicit setup over heavy init. Side-effect imports rely on init.
Theory 4 — internal/ directory rule
Any package under a path segment internal can only be imported by code rooted at the parent of that internal directory.
example.com/day09/internal/parse # OK import from example.com/day09/...
example.com/other/foo importing above # FORBIDDEN by compiler
Use internal/ for:
- Implementation details
- Shared helpers you do not want as public API
- CLI-only wiring helpers
Do not put everything in internal/ by reflex—export a small public surface for libraries you intend to reuse.
Theory 5 — Common layouts
Small CLI (recommended for Stage I)
module/
go.mod
cmd/<app>/main.go # thin main
<domain>/ # public library packages
internal/<helper>/ # private helpers
Multi-command
cmd/
inv/main.go
invctl/main.go
Share domain packages; keep mains thin.
Avoid early over-structure
| Premature | Prefer first |
|---|---|
pkg/ for everything |
Direct domain dirs at module root |
models/, utils/, helpers/ mega-packages |
Names that mean something (inventory, parse) |
| Deep 6-level trees | 2–3 levels until pain is real |
| Circular imports “solved” with interfaces everywhere | Rethink ownership; interfaces later |
pkg/ is seen in some monorepos; it is optional fashion, not a language requirement.
Theory 6 — Cycles and layering
Go forbids import cycles.
package a imports b
package b imports a // compile error
Layering sketch
cmd/inv → inventory → (stdlib only)
→ internal/parse
Domain should not import CLI packages. CLI may import domain.
Dependency direction test
Can this package be tested without starting a CLI?
If no, the boundary is wrong.
Theory 7 — Buildable packages vs tests
inventory/
store.go
store_test.go # package inventory — white box
# or store_export_test.go with package inventory_test — black box
Day 16 covers table tests deeply. Today: put library code outside main so tests can import it.
go test ./...
go list ./...Worked examples bank
Example A — Thin main
// cmd/inv/main.go
package main
import (
"fmt"
"os"
"example.com/day09/inventory"
)
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
}
func run(args []string) error {
s := inventory.NewStore()
// parse args, call s methods...
_ = s
return nil
}Example B — Domain package
// inventory/store.go
package inventory
import "fmt"
type Store struct {
items map[string]Item
}
type Item struct {
SKU string
Qty int
}
func NewStore() *Store {
return &Store{items: make(map[string]Item)}
}
func (s *Store) Add(sku string, qty int) error {
if sku == "" {
return fmt.Errorf("empty sku")
}
it := s.items[sku]
it.SKU = sku
it.Qty += qty
s.items[sku] = it
return nil
}Example C — Internal parser
// internal/parse/args.go
package parse
import "fmt"
func SKUQty(args []string) (sku string, qty int, err error) {
if len(args) != 2 {
return "", 0, fmt.Errorf("want sku qty")
}
// parse qty...
return args[0], 1, nil
}// cmd/inv/main.go
import "example.com/day09/internal/parse"Example D — Export control
package inventory
// Pool is an implementation detail.
type pool struct{ /* ... */ }
// Store is public API.
type Store struct {
p *pool
}Keep unexported what you may want to redesign.
Example E — Documenting go.mod
module example.com/day09
go 1.26No external requires yet. Day 15 covers modules in depth.
Labs
Suggested workspace: ~/lab/90daysofx/go/day09
Lab 1 — Multi-package module
mkdir -p ~/lab/90daysofx/go/day09/{cmd/inv,inventory,internal/parse}
cd ~/lab/90daysofx/go/day09
go mod init example.com/day09Requirements:
inventorypackage with store/domain types (nopackage main).
internal/parsefor argument or line parsing.
cmd/invthin main that wires them.
- At least one exported and one unexported identifier used correctly.
go build -o inv ./cmd/invsucceeds.
go test ./...runs (add one trivial test ininventory).
Lab 2 — Prove internal/ enforcement
Create a second module path attempt or a sibling package outside the allowed tree and show the compiler error when importing internal/parse illegally. Document the error text in notes.
(Within one module, create example.com/day09/evil that tries to import another module’s internal—or simply explain the rule with a quote from go help packages / experiment with a nested module if you want full proof.)
Simpler proof: read and restate the rule with your tree diagram in LAYOUT.md.
Lab 3 — go list tour
go list ./...
go list -f '{{.ImportPath}} {{.Name}}' ./...
go doc example.com/day09/inventoryPaste outputs into notes.
Common gotchas
| Gotcha | Fix |
|---|---|
package name ≠ directory |
Keep them aligned |
| Import cycle | Invert dependency; extract third package |
Putting logic only in main |
Extract packages for tests/reuse |
| Exporting everything | Unexport by default; export deliberately |
| Import path typos | Must start with module path |
internal myth “only for stdlib” |
Works in your modules too |
Mega utils package |
Prefer domain-named packages |
Checkpoint
- Can draw module path → import path → directory
- Multi-package build works
internal/purpose explained
- Exported vs unexported demo
- Thin
main+ domain package
go test ./...green on at least one test
Commit
git add .
git commit -m "day09: multi-package layout + internal"Tomorrow
Day 10 — Stage I gate: integrate Days 1–9 into a stdlib-only mini CLI (structs, slices, maps, packages, errors as values) with README and deliberate bad-input handling.
Day 10 — Stage I gate
Stage I · ~3h (integration)
Goal: Ship a small, stdlib-only CLI that integrates the Stage I toolkit—modules, types, control flow, functions/defer, slices, maps, structs/methods, pointers where justified, and multi-package layout—with a README others can follow.
This is a gate, not a feature festival. A polished narrow tool beats a half-broken platform. Cut scope ruthlessly.
Why this day exists
Stage I topics only stick when they cohere in one artifact. Gates force:
- Design trade-offs under time
- Error handling end-to-end
- Package boundaries that tests can import
- README as part of the deliverable
If Day 10 works, you are ready for interfaces, real error wrapping, and modules depth in Stage II—not still fighting slice headers.
Theory 1 — What “good enough” means here
Must-haves
| Bar | Evidence |
|---|---|
| Multi-package module | cmd/... + at least one library package |
| Domain types | Structs + methods (not only maps of primitives) |
| Collections | Slices and/or maps used appropriately |
| Errors as values | No panic for expected bad input |
| Process UX | Non-zero exit on failure; usage on misuse |
| Docs | README: purpose, build, examples, exit codes |
| Build | go build ./... clean on Go 1.26 |
Explicit non-goals
- No web framework
- No external modules required (stdlib only)
- No concurrency requirement
- No database
- No perfect CLI parser library (
flagis enough)
Theory 3 — Suggested layout
day10/
go.mod
README.md
cmd/<app>/main.go
<domain>/
...
internal/
...
main stays thin
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(exitCode(err))
}
}Domain owns rules
Validation, aggregates, and pure logic live outside main so Stage II can put tests on them without refactoring everything.
Theory 4 — Exit code policy
| Situation | Code |
|---|---|
| Success | 0 |
| Domain / runtime error | 1 |
| Usage / args | 2 |
var ErrUsage = errors.New("usage")
func exitCode(err error) int {
if errors.Is(err, ErrUsage) {
return 2
}
return 1
}Theory 5 — README template
# <tool>
One paragraph: what it does.
## Requirements
- Go 1.26+
## Build
go build -o <tool> ./cmd/<tool>
## Usage
Examples with expected output.
## Exit codes
| Code | Meaning |
|------|---------|
| 0 | OK |
| 1 | Failure |
| 2 | Usage |
## Design notes
2–5 bullets: packages, storage, limitations.Worked examples bank
Example A — Flag + subcommand sketch
func run(args []string) error {
if len(args) < 1 {
return fmt.Errorf("%w: <command> [args]", ErrUsage)
}
switch args[0] {
case "list":
return cmdList()
case "add":
return cmdAdd(args[1:])
default:
return fmt.Errorf("%w: unknown command %q", ErrUsage, args[0])
}
}Example B — Persistence sketch (JSON)
func load(path string) (*Store, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return NewStore(), nil
}
return nil, err
}
var s Store
if err := json.Unmarshal(data, &s); err != nil {
return nil, err
}
s.ensure()
return &s, nil
}
func save(path string, s *Store) error {
data, err := json.MarshalIndent(s, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0o644)
}Example C — Smoke script
#!/usr/bin/env bash
set -euo pipefail
go build -o app ./cmd/app
./app list >/dev/null
./app add "demo"
./app list | grep -q demo
! ./app nope # expect failureLabs
Suggested workspace: ~/lab/90daysofx/go/day10
Lab 1 — Ship the gate project
mkdir -p ~/lab/90daysofx/go/day10
cd ~/lab/90daysofx/go/day10
go mod init example.com/day10Implement your chosen tool to the must-have bar.
Lab 2 — Bad input matrix
Manually run at least:
- No arguments → usage, exit 2
- Unknown subcommand → exit 2
- Domain error (missing id, empty name, …) → exit 1 with clear message
- Happy path → exit 0
Record commands and outputs in README or SMOKE.md.
Lab 3 — Self-review checklist
Without adding features, answer:
- Where could a package boundary improve tests?
- Any panic paths for normal errors?
- Any nil map writes?
- Slice ownership documented if you return internal slices?
- README sufficient for a teammate?
Lab 4 — Optional micro-test
One TestX in a library package that locks a pure function (parse, total, classify line). Stage II will expand testing culture.
Common gotchas
| Gotcha | Fix |
|---|---|
| Scope creep | Cut to core commands |
Logic trapped in main |
Extract packages mid-gate if needed |
| Ignoring exit codes | Define and test them |
| README afterthought | Write examples while building |
| Panic on bad input | Return error |
| “I’ll refactor later” forever | Gate requires shippable shape now |
Checkpoint
- Tool builds with Go 1.26
- Multi-package layout
- Structs + methods + maps/slices in play
- Bad input → non-zero exit
- README with build + examples
- Can explain slice header and map comma-ok from this codebase
- Ready to discuss interfaces without unfinished Stage I panic
Commit
git add .
git commit -m "day10: stage I gate mini CLI"Tomorrow
Stage II begins — Day 11 Interfaces: implicit satisfaction, small interfaces, any, and refactoring domain behavior behind interfaces without mock frameworks.