Day 4 — Pointers, Packages & Stage I Review

Updated

July 30, 2025

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.

Note

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:

  1. Correctness: mutation, optional values, shared identity
  2. Performance: avoid giant copies; sometimes increase heap pressure
  3. 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 dereference

Always 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 idiomatic

Prefer &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.

3. Express identity / shared state

type Conn struct{ /* ... */ }

func (s *Server) handle(c *Conn) {
    // same connection object everywhere
}

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.Time is 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} // OK

Method 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/day08

Copy or reimplement a simplified Item / Store from Day 7.

go build -gcflags='-m' . 2> escape.txt

In escape.txt (or a journal), highlight:

  1. Something that does not escape
  2. Something that escapes to heap
  3. 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

  • & / * / *T vocabulary solid
  • Three good reasons to use a pointer
  • Read -gcflags=-m output 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.

Note

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

  1. All files in a directory (not subdirs) are one package.
  2. The package clause is usually the directory’s base name.
  3. package main is special: builds an executable.
  4. You cannot have package foo and package bar in 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 inventory

The 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

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.26

No 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/day09

Requirements:

  1. inventory package with store/domain types (no package main).
  2. internal/parse for argument or line parsing.
  3. cmd/inv thin main that wires them.
  4. At least one exported and one unexported identifier used correctly.
  5. go build -o inv ./cmd/inv succeeds.
  6. go test ./... runs (add one trivial test in inventory).

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/inventory

Paste 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.

Important

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 (flag is enough)

Theory 2 — Project idea menu (pick one)

A — Todo list CLI

todo add "buy milk"
todo list
todo done 1
todo rm 1

Storage: JSON file in cwd or $HOME using encoding/json lightly (allowed—stdlib). Or line-oriented text if you want less encoding.

B — Inventory CLI (Day 7–9 upgrade)

inv add sku name qty price
inv take sku n
inv list
inv total

In-memory + optional save/load.

C — Log classifier (Day 3 upgrade)

classify <file>
classify # stdin

Structured summary + exit 1 on fatal; multi-package (classify/engine, cmd/classify).

D — Word index

Build inverted index: word → line numbers. Commands: index <file>, find <word>.

Pick one. Do not merge all four.


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 failure

Labs

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/day10

Implement your chosen tool to the must-have bar.

Lab 2 — Bad input matrix

Manually run at least:

  1. No arguments → usage, exit 2
  2. Unknown subcommand → exit 2
  3. Domain error (missing id, empty name, …) → exit 1 with clear message
  4. 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.