Learning Go, Week 4: Functions, Methods, Defer, and File Handling

Last week was maps, control flow, and structs. This week I moved from organizing data to doing things with it — functions (including the variadic kind), methods on structs, the defer keyword, and basic file I/O. Four topics, but they build on each other more than I expected: structs from last week become the receivers for this week's methods, and defer turns out to be the idiomatic glue for the file operations at the end.
Functions, and the Variadic Kind
Regular functions in Go look about how you'd expect coming from any typed language — explicit parameter types, explicit return type:
func add(x int, y int) int {
return x + y
}
func subtract(x int, y int) int {
return x - y
}
Nothing surprising there. What I hadn't used yet was a variadic function — one that accepts any number of arguments of the same type using ...:
func proAdd(values ...int) int {
total := 0
for _, value := range values {
total += value
}
return total
}
Inside the function, values behaves like a regular []int slice — you range over it the same way you'd range over any slice. The interesting part is calling it: you can either pass individual arguments, or explode an existing slice into the call with the same ... syntax:
values := make([]int, 0, 101)
for i := 0; i <= 100; i++ {
values = append(values, i)
}
result := proAdd(values...)
That last line — proAdd(values...) — is the same operator on both ends: ... collects into a slice on the function's side, and spreads a slice back out on the caller's side. It's a small piece of syntax doing two related but opposite jobs depending on which side you're on, which took a second to click.
Methods: Attaching Behavior to Structs
Last week's structs only held data. Methods are how Go attaches behavior to that data without needing classes. A method is just a function with an extra piece before the name — the receiver — that ties it to a specific type:
type User struct {
Name string
Email string
Status bool
Age int
}
func (u User) GetStatus() bool {
return u.Status
}
u is the receiver, User is the type it's attached to, and inside the method u gives you access to that instance's fields — u.Status, u.Email, u.Name, same dot notation as always.
The part I had to sit with is that there are two kinds of receivers, and the difference actually matters:
Value receiver —
func (u User) ...— the method gets a copy of the struct. Anything it changes onudoesn't touch the original.Pointer receiver —
func (u *User) ...— the method gets a pointer to the original struct, so changes made inside the method persist after it returns.
Given last week's deep-dive into pointers, this mapped cleanly onto something I already understood: if a method needs to modify the struct (updating an email, incrementing an age), it needs a pointer receiver, or the change just evaporates on a copy. If it's only reading data (like GetStatus), a value receiver is fine and arguably clearer about intent. The convention I've seen recommended — and the one I'm going with — is to be consistent across a type's methods rather than mixing receiver kinds arbitrarily.
Defer: Delay Until the Function Returns
defer schedules a function call to run right before the surrounding function returns, instead of running it immediately where it's written:
func myDferFunc() {
for i := 0; i < 5; i++ {
defer fmt.Println(i)
}
}
Normal statements keep executing past a defer line — the deferred call just gets registered and parked. What surprised me is the order things come back out in: LIFO, last in first out. The loop above registers 0, 1, 2, 3, 4 in that order, but when myDferFunc returns, they print 4, 3, 2, 1, 0. Same rule applies to any number of defer statements stacked in a function — the most recently registered one fires first, like a stack unwinding.
This clicked once I stopped thinking of defer as "run this later" and started thinking of it as "push this onto a stack that unwinds on the way out." Every function manages its own defer stack, and it only unwinds when that specific function returns — deferred calls inside a nested function fire before control ever gets back to the caller.
The practical reason this exists, and the reason I'll actually use it starting this week, is cleanup: closing files, closing DB connections, unlocking mutexes. It guarantees the cleanup runs even if a function has multiple return points, without having to duplicate the cleanup call before every single return.
File Handling
This is where defer actually earns its keep. Creating and writing a file:
file, err := os.Create("firstfile.txt")
checknillerr(err)
length, err := io.WriteString(file, content)
checknillerr(err)
fmt.Println("Wrote", length, "characters to file.")
file.Close()
os.Create creates the file if it doesn't exist, or truncates and overwrites it if it does, and hands back a file object plus an error. io.WriteString writes a string into that file object and returns the byte count written. In production code, the closing call is usually written as defer file.Close() immediately after the file is successfully created — that way the close is guaranteed to happen when the function returns, instead of depending on execution reaching a file.Close() line at the bottom, which might get skipped entirely if the function returns early on an error somewhere in between.
Reading a file back is the mirror image:
databytes, err := ioutil.ReadFile(filename)
checknillerr(err)
fmt.Println("File read successfully.", string(databytes))
ioutil.ReadFile reads the whole file into memory as a []byte, which then gets converted to a string for printing. Worth flagging: ioutil.ReadFile is deprecated in modern Go — os.ReadFile is the current equivalent and does the same job without the extra import.
Error handling throughout follows the same pattern from earlier weeks — every operation that can fail returns an error alongside its result, and I'm using a small helper to panic on anything non-nil rather than repeating the if err != nil block everywhere:
func checknillerr(err error) {
if err != nil {
panic(err)
}
}
What I Learned This Week
Regular functions look like any typed language; variadic functions (
...T) accept any number of arguments and behave like a slice inside the function body.The same
...syntax spreads a slice into a variadic call on the caller's side — one operator, two directions depending on context.Methods attach behavior to a type via a receiver written between
funcand the method name.Value receivers operate on a copy; pointer receivers operate on the original — mutation requires a pointer receiver.
deferdelays a call until the surrounding function returns, and multiple deferred calls unwind in LIFO order.Each function manages its own defer stack independently — nested functions unwind their own defers before returning control to the caller.
deferis idiomatically used for cleanup — closing files, unlocking mutexes, closing connections — because it guarantees the cleanup runs regardless of how many return points a function has.os.Createcreates or truncates a file and returns a file object plus an error;io.WriteStringwrites into it.ioutil.ReadFilereads a full file as[]byte; the modern equivalent isos.ReadFile.Every I/O operation returns an error that should be checked — a pattern that's been consistent since week one.
Functions and methods felt like a natural extension of last week's structs — once you have data grouped into a type, attaching behavior to it is a small conceptual step. defer was the real "aha" of the week: it's a genuinely different mental model from anything JS has, and understanding it as a per-function LIFO stack rather than "runs at the end" made file handling immediately make more sense, since the deferred close pattern only works because of that guarantee.
Code and notes for this post live in my GitHub repository, organized topic-wise: Go Learning — Architecture Lab



