Day 2 — Functions, Arrays & Slices

Updated

July 30, 2025

Day 2 — Functions, Arrays & Slices

Day 4 — Functions, returns & defer

Stage I · ~3h (theory-heavy)
Goal: Make functions the unit of design—multiple returns, named results, defer timing, and first-class function values—then refactor a small pipeline so main only wires and exits.

Note

Go has no exceptions for ordinary failure. The idiomatic channel is (T, error). Panic is for truly exceptional situations (Day 14). Today builds the habits that make panic rare.

Why this day exists

Most Go APIs are shaped by function conventions:

  • Multiple return values instead of out-params or exceptions
  • error as the last return value
  • defer for cleanup that must run on every path
  • Small functions composed into pipelines

If you treat functions like C (single return, manual cleanup everywhere) or like Java (exceptions), you will fight the language. Day 4 aligns muscle memory with the stdlib.


Theory 1 — Declaration, parameters, and results

Signature anatomy

func Name(param1 Type1, param2 Type2) (Result1, Result2) {
    return value1, value2
}

Same-type parameters can share a type:

func add(a, b int) int { return a + b }

Multiple returns

func div(a, b float64) (float64, error) {
    if b == 0 {
        return 0, fmt.Errorf("division by zero")
    }
    return a / b, nil
}

Call site:

q, err := div(10, 2)
if err != nil {
    return err
}

Blank identifier

q, _ := div(10, 2) // discard error — almost always wrong in real code
_, err := os.Stat(path)

Use _ deliberately (e.g., you truly need only one result), never to silence errors you should handle.

Named result parameters

func lookup(id string) (name string, err error) {
    name, ok := db[id]
    if !ok {
        err = fmt.Errorf("id %q not found", id)
        return // naked return
    }
    return name, nil
}

Named results:

  • Document intent at the signature
  • Are zero-initialized
  • Enable naked returns (return alone)

Judgment: naked returns are fine in short functions; in long ones they obscure what is returned. Prefer explicit return name, err for clarity when the body grows.


Theory 2 — Errors as values (preview that you use today)

func readConfig(path string) ([]byte, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("read config %s: %w", path, err)
    }
    return data, nil
}

Conventions:

Rule Why
error is last Matches entire stdlib
nil error = success Check err != nil first
Wrap with %w Enables errors.Is / As later (Day 13)
Do not panic for expected failure Missing file, bad input, network blip

Today: return and check. Full wrapping toolkit on Day 13.


Theory 3 — defer: schedule, LIFO, and arguments

What defer does

f, err := os.Open(path)
if err != nil {
    return err
}
defer f.Close()

defer registers a call to run when the surrounding function returns—normally or via panic (before the panic continues unwinding).

LIFO order

func order() {
    defer fmt.Println("first deferred")  // runs second
    defer fmt.Println("second deferred") // runs first
    fmt.Println("body")
}
// prints: body, second deferred, first deferred

Open A, defer close A; open B, defer close B → B closes before A. Nested resource lifetimes stay correct.

Arguments are evaluated at defer time

func trace() {
    x := 1
    defer fmt.Println("x=", x) // prints x= 1
    x = 2
}

The call’s arguments are evaluated when defer executes, not when the deferred function runs. Closures can observe later mutations:

func trace2() {
    x := 1
    defer func() { fmt.Println("x=", x) }() // prints x= 2
    x = 2
}

Common cleanup patterns

mu.Lock()
defer mu.Unlock()

resp, err := http.Get(url)
if err != nil {
    return err
}
defer resp.Body.Close()

rows, err := db.Query(q)
if err != nil {
    return err
}
defer rows.Close()

defer and named returns

func readAll(path string) (data []byte, err error) {
    f, err := os.Open(path)
    if err != nil {
        return nil, err
    }
    defer func() {
        cerr := f.Close()
        if err == nil {
            err = cerr // surface close error if read succeeded
        }
    }()
    return io.ReadAll(f)
}

This pattern matters for durable correctness. You will revisit it when writing real I/O packages.

What not to use defer for

  • Replacing ordinary control flow in the middle of a hot loop (cost is small but not free; more importantly, defer runs at function end, not loop end)
  • “Maybe cleanup” without clear ownership
  • Business logic that callers need to observe as primary control flow

Theory 4 — Function values and closures

Functions are first-class:

func apply(nums []int, fn func(int) int) []int {
    out := make([]int, len(nums))
    for i, n := range nums {
        out[i] = fn(n)
    }
    return out
}

doubled := apply([]int{1, 2, 3}, func(n int) int { return n * 2 })

Closures capture variables

func counter() func() int {
    n := 0
    return func() int {
        n++
        return n
    }
}

c := counter()
fmt.Println(c()) // 1
fmt.Println(c()) // 2

Each call to counter() gets its own n.

Methods as values (preview)

var w io.Writer = os.Stdout
write := w.Write // method value; receiver bound
write([]byte("hi\n"))

Day 7–11 deepen methods and interfaces. Today: know functions and methods can be passed around.


Theory 5 — Variadic parameters

func sum(vals ...int) int {
    total := 0
    for _, v := range vals {
        total += v
    }
    return total
}

sum()
sum(1, 2, 3)
nums := []int{4, 5}
sum(nums...) // expand slice

Only the last parameter may be variadic. Inside the function, vals has type []int.

fmt.Println and fmt.Sprintf are the classic stdlib examples.


Theory 6 — Structuring main

Idiomatic shape for CLIs:

func main() {
    if err := run(os.Args[1:]); err != nil {
        fmt.Fprintf(os.Stderr, "error: %v\n", err)
        os.Exit(1)
    }
}

func run(args []string) error {
    // all real work; return errors
    return nil
}

Benefits:

  • defer in run works; os.Exit in main skips defers in main if you exit there without returning—keeping work in run preserves defer semantics for cleanup registered in run
  • Testable: call run from tests later without starting a process

Worked examples bank

Example A — Multiple returns + validation

package main

import (
    "fmt"
    "strconv"
    "strings"
)

func parsePair(s string) (int, int, error) {
    parts := strings.Split(s, ",")
    if len(parts) != 2 {
        return 0, 0, fmt.Errorf("want a,b got %q", s)
    }
    a, err := strconv.Atoi(strings.TrimSpace(parts[0]))
    if err != nil {
        return 0, 0, fmt.Errorf("left: %w", err)
    }
    b, err := strconv.Atoi(strings.TrimSpace(parts[1]))
    if err != nil {
        return 0, 0, fmt.Errorf("right: %w", err)
    }
    return a, b, nil
}

Example B — Defer LIFO with files

func copyFile(dst, src string) (err error) {
    in, err := os.Open(src)
    if err != nil {
        return err
    }
    defer in.Close()

    out, err := os.Create(dst)
    if err != nil {
        return err
    }
    defer func() {
        cerr := out.Close()
        if err == nil {
            err = cerr
        }
    }()

    _, err = io.Copy(out, in)
    return err
}

Example C — Pipeline of helpers

func run(args []string) error {
    if len(args) != 1 {
        return fmt.Errorf("usage: tool <path>")
    }
    data, err := os.ReadFile(args[0])
    if err != nil {
        return err
    }
    lines := strings.Split(string(data), "\n")
    summary, err := summarize(lines)
    if err != nil {
        return err
    }
    fmt.Println(summary)
    return nil
}

Example D — Function value table

var ops = map[string]func(int, int) int{
    "+": func(a, b int) int { return a + b },
    "-": func(a, b int) int { return a - b },
    "*": func(a, b int) int { return a * b },
}

func eval(op string, a, b int) (int, error) {
    fn, ok := ops[op]
    if !ok {
        return 0, fmt.Errorf("unknown op %q", op)
    }
    return fn(a, b), nil
}

Example E — Defer argument evaluation trap

func demo() {
    for i := 0; i < 3; i++ {
        defer fmt.Println("arg", i) // args fixed per defer: 0,1,2 then LIFO print 2,1,0
    }
}

func demoClosureWrongOldMentalModel() {
    for i := 0; i < 3; i++ {
        defer func() { fmt.Println("clos", i) }() // Go 1.22+: 2,1,0 still LIFO but each i per iteration
    }
}

Run both; write down what you observe on Go 1.26.


Labs

Suggested workspace: ~/lab/90daysofx/go/day04

Lab 1 — Refactor classifier (or units) behind run

mkdir -p ~/lab/90daysofx/go/day04
cd ~/lab/90daysofx/go/day04
go mod init example.com/day04

Requirements:

  1. main only calls run and maps error → stderr + exit code.
  2. At least three helpers with (T, error) or (…, error) signatures.
  3. At least one defer that closes a resource (os.Open + defer f.Close(), or bufio over a file).
  4. Usage errors return a distinct sentinel or message so main can exit 2 vs 1.

Example exit policy:

func main() {
    err := run(os.Args[1:])
    if err == nil {
        return
    }
    fmt.Fprintf(os.Stderr, "%v\n", err)
    if errors.Is(err, errUsage) {
        os.Exit(2)
    }
    os.Exit(1)
}

Define var errUsage = errors.New("usage") or wrap with a clear prefix—keep it simple.

Lab 2 — Defer timing journal

Write a tiny program that prints:

  1. Body vs deferred order (LIFO)
  2. Argument evaluation vs closure capture
  3. That os.Exit(1) from main skips deferred functions in main

Document findings in NOTES.md or comments.

Lab 3 — Variadic + function value

Implement func join(sep string, parts ...string) string without calling strings.Join first—then compare to strings.Join. Pass a func(string) string mapper over a slice of names (trim + lower).


Common gotchas

Gotcha Fix
Ignoring error with _ Always handle or return
defer f.Close() before checking err from Open Only defer after successful open
Expecting defer at end of loop iteration Deferred until function returns
os.Exit inside helper Return error; exit in main
Naked return in long function Prefer explicit returns
Shadowing err in nested scopes Reuse carefully; name inner errors if needed
Thinking deferred arg sees final variable value Args evaluated at defer line; use closure if needed

Checkpoint

  • Multiple returns used idiomatically with err last
  • Named results explained (even if you rarely naked-return)
  • defer LIFO demonstrated
  • Argument evaluation vs closure capture understood
  • main / run split in place
  • At least one real resource cleaned with defer
  • Exit codes distinguish usage vs runtime failure

Commit

git add .
git commit -m "day04: functions, defer, run() pattern"

Tomorrow

Day 5 — Arrays, slices & append: the slice header, capacity, aliasing bugs, and why almost all “list” code in Go is really about slices—not arrays.


Day 5 — Arrays, slices & append

Stage I · ~3h (theory-heavy)
Goal: Internalize the slice header (pointer, length, capacity), how append may reallocate, and how to avoid silent aliasing—then implement a growable buffer or ring buffer with deliberate capacity control.

Note

If you remember only one picture from Stage I: a slice is a small descriptor pointing at an array, not the array itself. Most production bugs around “I modified a copy” are header misunderstandings.

Why this day exists

Slices are the workhorse collection type:

  • Function args, JSON arrays, HTTP body chunks, table-test cases—all slices
  • Arrays exist and matter for size-in-type and some crypto/fixed buffers, but day-to-day code is slices
  • append, sub-slicing, and shared backing arrays create aliasing that looks like magic until the model is clear

Day 5 is theory you will reuse on every later day that touches data.

Go slice header with pointer length capacity over underlying array

Slice header over array

Theory 1 — Arrays: value types with fixed length

var a [3]int           // [0 0 0]
b := [3]int{1, 2, 3}
c := [...]int{4, 5, 6} // length inferred: [3]int

Length is part of the type

var x [3]int
var y [4]int
// x = y // compile error: different types

Assignment copies the entire array

a := [3]int{1, 2, 3}
b := a
b[0] = 99
fmt.Println(a[0]) // 1 — a unchanged

Arrays are rare in APIs because size is rigid. They appear in:

  • SHA digest sizes ([32]byte)
  • Small fixed protocol headers
  • Backing storage you then slice

Theory 2 — The slice header

A slice value is a header with three fields (conceptually):

Field Meaning
pointer Address of element 0 of this slice view
length Number of accessible elements (len)
capacity Number of elements in the backing array from the pointer to the end (cap)
s := make([]int, 3, 5) // len=3, cap=5
fmt.Println(len(s), cap(s)) // 3 5

Nil vs empty

var nilSlice []int          // nil: len=0, cap=0
empty := []int{}            // non-nil empty (usually)
empty2 := make([]int, 0)    // non-nil empty

fmt.Println(nilSlice == nil) // true
fmt.Println(empty == nil)    // false

Both have len == 0. Prefer len(s) == 0 over s == nil when checking “no elements,” unless you intentionally treat nil as a distinct signal (e.g., “unset” vs “empty list” in JSON can still both become nil depending on encoding—be careful).

Literal and make

s1 := []int{1, 2, 3}     // len=3, cap=3
s2 := make([]int, 10)    // len=10, cap=10, zeroed
s3 := make([]int, 0, 64) // len=0, cap=64 — pre-grow

Theory 3 — Slicing and shared backing arrays

a := []int{0, 1, 2, 3, 4}
b := a[1:4] // [1 2 3], len=3, cap=4 (to end of a's capacity)
b[0] = 99
fmt.Println(a) // [0 99 2 3 4] — shared storage!

Expression forms

s[low:high]      // low included, high excluded
s[low:high:max]  // also limits cap to max-low (full slice expression)
s[:high]
s[low:]
s[:]

Full slice expression to prevent append clobbering

a := []int{0, 1, 2, 3, 4}
b := a[1:3:3] // len=2, cap=2 — append must allocate
b = append(b, 9)
fmt.Println(a) // a[3] not overwritten by 9

Without the third index, append on b may write into a’s unused capacity.

Law

Sub-slicing does not copy elements. It creates a new header into the same array (unless a prior append reallocated).

To own a copy:

cp := append([]int(nil), s...)
// or
cp := make([]int, len(s))
copy(cp, s)

Theory 4 — append growth

var s []int
s = append(s, 1)
s = append(s, 2, 3)
s = append(s, []int{4, 5}...)

Rules of thumb

  1. If len < cap, append writes in place and returns a header with len+n.
  2. If not enough capacity, runtime allocates a larger array, copies, returns a new header.
  3. Always assign the result: s = append(s, x).
s = append(s, x) // correct
append(s, x)     // compile error: result unused (as of modern Go checks) — still: never ignore it

Capacity growth is implementation detail

Do not hard-code assumptions about exact growth factors across Go versions. For performance, preallocate when you know size:

out := make([]T, 0, len(in))
for _, v := range in {
    out = append(out, transform(v))
}

copy

dst := make([]int, 2)
src := []int{1, 2, 3}
n := copy(dst, src) // n=2; dst=[1 2]

copy copies min(len(dst), len(src)) elements and handles overlap correctly.


Theory 5 — Passing slices to functions

Slices are passed as headers by value. The header is copied; the backing array is shared.

func setFirst(s []int) {
    if len(s) > 0 {
        s[0] = 100 // visible to caller
    }
}

func appendLocal(s []int) {
    s = append(s, 9) // may not be visible: local header only
}

func appendReturn(s []int) []int {
    return append(s, 9) // caller must use returned header
}
Mutation Visible to caller?
Change s[i] Yes (shared array)
append that fits in cap Maybe yes for elements, but caller’s len unchanged unless they use returned slice
append that reallocates Caller still holds old header unless they assign return

Idiom: functions that grow a slice return the new slice (or use a pointer to slice: *[]T, less common).


Theory 6 — Strings, bytes, and runes (slice-adjacent)

s := "Go"
b := []byte(s) // copy of bytes
r := []rune(s) // Unicode code points
s2 := string(b)
  • string is immutable; converting to []byte or []rune copies
  • Prefer range on string for runes; prefer []byte for I/O

bytes and strings packages share many APIs (Day 40). Today: do not mutate a []byte that aliases a string’s storage (you cannot get that alias safely without unsafe).


Worked examples bank

Example A — Header introspection

package main

import "fmt"

func header(name string, s []int) {
    fmt.Printf("%s len=%d cap=%d %v\n", name, len(s), cap(s), s)
}

func main() {
    a := make([]int, 3, 6)
    for i := range a {
        a[i] = i + 1
    }
    header("a", a)
    b := a[1:3]
    header("b", b)
    b = append(b, 9)
    header("b after append", b)
    header("a after b append", a) // may show a[3]==9 if capacity shared
}

Example B — Safe grow helper

func push(s []int, v int) []int {
    return append(s, v)
}

func pushAll(s []int, vs ...int) []int {
    return append(s, vs...)
}

Example C — Filter without aliasing pitfalls

func filterEven(in []int) []int {
    out := make([]int, 0, len(in))
    for _, v := range in {
        if v%2 == 0 {
            out = append(out, v)
        }
    }
    return out
}

Example D — In-place filter (same slice, careful)

func filterEvenInPlace(s []int) []int {
    n := 0
    for _, v := range s {
        if v%2 == 0 {
            s[n] = v
            n++
        }
    }
    return s[:n]
}

Still shares capacity with original; fine if you only use the returned header.

Example E — Ring buffer sketch

type Ring struct {
    buf  []int
    head int
    size int
}

func NewRing(cap int) *Ring {
    return &Ring{buf: make([]int, cap)}
}

func (r *Ring) Push(v int) {
    if r.size < len(r.buf) {
        r.size++
    } else {
        r.head = (r.head + 1) % len(r.buf)
    }
    idx := (r.head + r.size - 1) % len(r.buf)
    r.buf[idx] = v
}

func (r *Ring) Snapshot() []int {
    out := make([]int, r.size)
    for i := 0; i < r.size; i++ {
        out[i] = r.buf[(r.head+i)%len(r.buf)]
    }
    return out
}

Example F — Remove element without leaving garbage (slice tricks)

func deleteAt(s []int, i int) []int {
    // order-preserving
    return append(s[:i], s[i+1:]...)
}

func deleteAtUnordered(s []int, i int) []int {
    s[i] = s[len(s)-1]
    return s[:len(s)-1]
}

Know that append(s[:i], s[i+1:]...) can alias; if other headers still point into s, they may see surprising contents.


Labs

Suggested workspace: ~/lab/90daysofx/go/day05

Lab 1 — Prove aliasing

mkdir -p ~/lab/90daysofx/go/day05
cd ~/lab/90daysofx/go/day05
go mod init example.com/day05

Write a program that:

  1. Creates a slice with len < cap
  2. Sub-slices it
  3. appends into the sub-slice
  4. Prints parent and child before/after

Then fix the clobber with a full slice expression s[low:high:high] or an explicit copy.

Lab 2 — Growable buffer

Implement a line buffer type:

type Buffer struct {
    lines []string
}

func (b *Buffer) Add(line string) { /* append */ }
func (b *Buffer) Len() int
func (b *Buffer) Lines() []string // return a copy OR document that caller must not mutate

Decide and document: does Lines() return a defensive copy? Implement that choice.

CLI: read stdin, store lines, print count and last 5 lines.

Lab 3 — Ring buffer (stretch)

Implement fixed-capacity ring of the last N integers (or lines). On overflow, drop oldest. Print contents in chronological order.

go build -o ring .
echo -e "1\n2\n3\n4\n5" | ./ring -n 3
# expect 3 4 5

Common gotchas

Gotcha Fix
Forgetting s = append(s, x) Always assign
Sub-slice append overwrites parent Full slice expr or copy
Assuming []T{} equals nil Compare with len for emptiness
Passing slice to grow in-place without return Return new header
Using arrays when size varies Use slices
range copy of large array for i := range a avoids copying elements if you only need indices; ranging for i, v := range largeArray copies array to range over—prefer slice
Memory “leak” via large backing array Copy to shrink if you keep a tiny sub-slice of a huge buffer

Checkpoint

  • Draw pointer/len/cap for a slice and a sub-slice
  • Explain nil vs empty slice
  • Demonstrate shared backing array mutation
  • Prevent clobber with full slice expression or copy
  • Buffer or ring lab works
  • Can explain why append sometimes reallocates

Commit

git add .
git commit -m "day05: slices, append, buffer/ring"

Tomorrow

Day 6 — Maps: make vs nil, comma-ok, delete, iteration randomness, and using maps as counters and simple caches without data races (single-goroutine rules for now).