Day 2 — Functions, Arrays & Slices
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.
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
erroras the last return value
deferfor 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 (
returnalone)
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 deferredOpen 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()) // 2Each 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 sliceOnly 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:
deferinrunworks;os.Exitinmainskips defers inmainif you exit there without returning—keeping work inrunpreserves defer semantics for cleanup registered inrun
- Testable: call
runfrom 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/day04Requirements:
mainonly callsrunand mapserror→ stderr + exit code.
- At least three helpers with
(T, error)or(…, error)signatures.
- At least one
deferthat closes a resource (os.Open+defer f.Close(), orbufioover a file).
- Usage errors return a distinct sentinel or message so
maincan exit2vs1.
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:
- Body vs deferred order (LIFO)
- Argument evaluation vs closure capture
- That
os.Exit(1)frommainskips deferred functions inmain
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
errlast
- Named results explained (even if you rarely naked-return)
deferLIFO demonstrated
- Argument evaluation vs closure capture understood
main/runsplit 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.
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.
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]intLength is part of the type
var x [3]int
var y [4]int
// x = y // compile error: different typesAssignment copies the entire array
a := [3]int{1, 2, 3}
b := a
b[0] = 99
fmt.Println(a[0]) // 1 — a unchangedArrays 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 5Nil 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) // falseBoth 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-growTheory 4 — append growth
var s []int
s = append(s, 1)
s = append(s, 2, 3)
s = append(s, []int{4, 5}...)Rules of thumb
- If
len < cap, append writes in place and returns a header withlen+n.
- If not enough capacity, runtime allocates a larger array, copies, returns a new header.
- 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 itCapacity 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)stringis immutable; converting to[]byteor[]runecopies
- Prefer
rangeon string for runes; prefer[]bytefor 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/day05Write a program that:
- Creates a slice with
len < cap
- Sub-slices it
appends into the sub-slice
- 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 mutateDecide 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 5Common 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
appendsometimes 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).