Learn Go Series (#3) - Functions and First-Class Functions

Words
2754
Reading
13 min
Listen
Play
5h

Learn Go Series (#3) - Functions and First-Class Functions

go-banner.png

What will I learn

  • How Go functions return multiple values, and why that is the language's answer to exceptions;
  • The comma-ok idiom, the little two-value shape you will meet everywhere in Go;
  • Named return values and when they help (and when they hurt);
  • Variadic functions that take any number of arguments, and how to spread a slice into one;
  • That functions are first-class values you can store, pass, and return;
  • Closures: functions that capture and remember the variables around them -- including the one loop-capture trap that used to bite everyone;
  • defer, the tidy way to guarantee cleanup runs no matter how a function exits, and the two timing rules that make it predictable.

Requirements

  • A working modern computer running macOS, Windows or Ubuntu;
  • An installed Go distribution (1.27 or newer, from go.dev/dl) -- tested against Go 1.27;
  • Episodes 1-2, or comfort declaring variables and running go run .;
  • The ambition to learn Go programming.

Difficulty

  • Beginner

Curriculum (of the Learn Go Series):

Learn Go Series (#3) - Functions and First-Class Functions

Functions are where a program actually does its work, and Go's take on them is worth dwelling on because two of its choices -- multiple return values and first-class functions -- shape how idiomatic Go looks everywhere else. There is no try/catch in Go; instead a function that can fail simply returns an extra value saying whether it did. And functions are values like any other, which is what makes closures and clean callback APIs possible. By the end of today you will read a (result, error) signature and a defer line the way a Go programmer does -- as ordinary, obvious things rather than curiosities. Let us clear last episode's exercises and then dig in.

Solutions to Episode 2 Exercises

Exercise 1 -- zero values. Declare one of each type and print before assigning:

package main

import "fmt"

func main() {
    var n int
    var f float64
    var s string
    var b bool
    fmt.Printf("int=%d float64=%g string=%q bool=%t\n", n, f, s, b)
}

The output is int=0 float64=0 string="" bool=false -- every value is its type's zero, nothing is undefined. This is the guarantee we leaned on last episode: a freshly declared variable is always its zero value, never garbage, so you can build on it rather than defend against it.

Exercise 2 -- a units enum. iota with a shift builds the byte-size constants; the first line's expression is reused on each following line:

package main

import "fmt"

type ByteSize float64

const (
    KB ByteSize = 1 << (10 * (iota + 1)) // iota=0 -> 1<<10 = 1024
    MB                                   // iota=1 -> 1<<20 = 1048576
    GB                                   // iota=2 -> 1<<30 = 1073741824
)

func main() {
    fmt.Printf("KB=%.0f MB=%.0f GB=%.0f\n", KB, MB, GB)
}

Because iota is 0 on the first line, KB is 1 << 10; the pattern then rolls forward for free. Add a TB line and it is 1 << 40 with nothing to recompute by hand -- that is the whole point of iota.

Exercise 3 -- conversion round-trip. Converting to int truncates, so the value does not survive the trip:

package main

import "fmt"

func main() {
    original := 3.99
    truncated := int(original) // 3, not rounded
    back := float64(truncated)
    // back is 3, not 3.99, because int(3.99) threw away the fractional part
    fmt.Printf("%.2f -> %d -> %.2f\n", original, truncated, back)
}

int(3.99) is 3; the .99 is gone and no later conversion can bring it back. Go truncates toward zero, it never rounds -- if you want rounding you call math.Round and mean it. On to functions.

Multiple return values: Go's answer to exceptions

A Go function can return more than one value, and the language leans on this hard. The overwhelmingly common shape is (result, error): the function hands back what it computed and whether it worked. The caller checks the error immediately. We do errors properly in a later episode, but you need the shape now because it is everywhere:

package main

import (
    "errors"
    "fmt"
)

func divide(a, b int) (int, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}

func main() {
    q, err := divide(10, 2)
    if err != nil {
        fmt.Println("error:", err)
        return
    }
    fmt.Println("10 / 2 =", q)

    if _, err := divide(1, 0); err != nil {
        fmt.Println("as expected:", err)
    }
}

There is no hidden control flow here: a failure is just a value you return and the caller checks. nil is the zero value of error, meaning "no error". And _ is the blank identifier we met last episode -- when you do not want the quotient, you assign it to _ and Go does not complain about an unused value. This explicit style is more typing than an exception, and in return you can see every place a program can fail just by reading it -- there is no invisible stack-unwinding happening three functions up.

One habit to build early: handle the error first, right after the call, and let the happy path continue at the outer indentation. Notice how the if err != nil block ends in return, so the rest of main does not need an else. Idiomatic Go reads as a flat sequence of "do a thing, check it went well, do the next thing", and that flatness is deliberate.

The comma-ok idiom

The (value, error) pair is the famous one, but Go has a close cousin you will meet just as often: the (value, ok) shape, where a bool reports whether the value is meaningful. It shows up on map lookups, type assertions, and channel receives. Here it is on a map, distinguishing "the key is present with a zero value" from "the key is absent entirely":

package main

import "fmt"

func main() {
    scores := map[string]int{"ada": 0, "linus": 42}

    // A plain lookup can't tell "missing" from "present but zero".
    fmt.Println("ada:", scores["ada"])   // 0
    fmt.Println("grace:", scores["grace"]) // also 0 -- but grace isn't in the map!

    // The comma-ok form removes the ambiguity:
    if v, ok := scores["ada"]; ok {
        fmt.Println("ada is present, score", v)
    }
    if _, ok := scores["grace"]; !ok {
        fmt.Println("grace is absent")
    }
}

A one-value map read returns the value's zero when the key is missing, which is convenient but ambiguous -- scores["grace"] is 0 even though grace was never stored. The two-value read hands you a second bool, ok, that is true only when the key really exists. We will lean on maps properly in a later episode; for now just recognise the shape, because once you see v, ok := ... you know exactly what is going on. Multiple returns are not a one-off trick for errors -- they are a general tool the whole language is built around.

Named returns

You can name a function's return values. They become ordinary variables, pre-set to their zero value, and a bare return sends back whatever they currently hold. Used sparingly they document what each return position means; overused they make control flow murky. A good fit is a function with a couple of results that share some setup:

package main

import "fmt"

// minMax returns both the smallest and largest of a non-empty slice.
func minMax(nums []int) (min, max int) {
    min, max = nums[0], nums[0]
    for _, n := range nums[1:] {
        if n < min {
            min = n
        }
        if n > max {
            max = n
        }
    }
    return // bare return: sends back the current min and max
}

func main() {
    lo, hi := minMax([]int{4, 2, 9, 7, 1})
    fmt.Printf("min=%d max=%d\n", lo, hi)
}

The named results min and max are declared for you and start at 0; the bare return at the end returns their final values. Note the honest limitation baked into this example: it indexes nums[0], so it assumes a non-empty slice -- a real version would take an error return for the empty case, which is exactly the pattern from the previous section.

A word of warning, because this is where named returns earn their bad reputation. In a long function with several early returns, a bare return forces the reader to scroll back up and figure out what the named variables hold at that point. My rule of thumb: use named returns for short functions where they genuinely document the result positions, and prefer explicit return a, b in anything longer. There is one more thing named returns unlock -- the ability for a deferred function to inspect and even change them on the way out -- but that trick belongs at the end of this episode, once we have met defer.

Variadic functions

A function whose last parameter is written ...T accepts any number of T arguments, which arrive inside the function as a slice. fmt.Println is variadic (that is why you can pass it one thing or six); here is one of your own:

package main

import "fmt"

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

func main() {
    fmt.Println(sum(1, 2, 3))       // three args
    fmt.Println(sum())              // zero args is fine: 0
    xs := []int{10, 20, 30, 40}
    fmt.Println(sum(xs...))         // spread a slice with ...
}

Inside sum, nums is a []int. You can call it with loose arguments, with none at all, or -- the reverse trick -- spread an existing slice into it with xs.... That spread operator is the same three dots, read the other way: ...int in the declaration means "collect these into a slice", and xs... at the call site means "unpack this slice as the arguments".

There is one sharp edge worth knowing before it cuts you. When you spread a slice with xs..., Go does not copy it -- the function receives the very same backing array. If the function mutates nums, it mutates your xs. The built-in append is variadic and shows this off:

package main

import "fmt"

func main() {
    xs := []int{1, 2, 3}

    // append is variadic: append(slice, elems...) -- here we spread another slice in
    more := []int{4, 5}
    combined := append(xs, more...)
    fmt.Println("combined:", combined) // [1 2 3 4 5]

    // A variadic call with no spread just lists the args:
    fmt.Println("literal args:", append(xs, 99, 100))
}

We will unpack slices and their backing arrays in full detail in a later episode -- for now, just keep the instinct that a spread shares memory, it does not clone it. If you need an independent copy, you make one deliberately. Having said that, in everyday code variadics are a joy: they are how you write fmt.Printf-style APIs and gather-any-number helpers without forcing the caller to build a slice by hand.

Functions are values

A function in Go is a value with a type, so you can put one in a variable, pass it as an argument, or store it in a slice or map. This is the foundation of clean, composable APIs -- a sort that takes a comparison, a router that takes handlers, a retry helper that takes the operation to retry. Here we pass a function to another function:

package main

import "fmt"

// apply runs op on every element and returns a new slice.
func apply(xs []int, op func(int) int) []int {
    out := make([]int, len(xs))
    for i, x := range xs {
        out[i] = op(x)
    }
    return out
}

func main() {
    double := func(n int) int { return n * 2 }
    nums := []int{1, 2, 3, 4}
    fmt.Println(apply(nums, double))
    // or pass the function literal inline
    fmt.Println(apply(nums, func(n int) int { return n * n }))
}

op func(int) int is a parameter whose type is "a function taking an int and returning an int". You can pass a named function value like double, or an anonymous function literal written right at the call site. This is how so much of the standard library stays small and flexible -- sort.Slice, for instance, takes a less function and does not care one bit what you are sorting.

Because a function type is a real type, you can name it, which makes signatures that pass functions around far easier to read:

package main

import "fmt"

// A named function type: any func taking and returning an int.
type IntOp func(int) int

func twice(op IntOp) IntOp {
    return func(n int) int { return op(op(n)) }
}

func main() {
    inc := func(n int) int { return n + 1 }
    addTwo := twice(inc) // a function that applies inc twice
    fmt.Println(addTwo(10)) // 12
}

type IntOp func(int) int gives the function shape a name, so twice reads as "takes an IntOp, returns an IntOp" instead of a thicket of func(int) int func(int) int. And look what twice does: it takes a function and returns a brand-new function built on the fly. That is only possible because functions are first-class -- and it leads us straight into the most important idea in this episode.

Closures: functions that remember

A function literal can use variables from the scope around it, and it keeps them alive even after that scope returns. That captured state is a closure, and it is a compact way to build stateful helpers without a struct:

package main

import "fmt"

// counter returns a function that increments and returns its own private count.
func counter() func() int {
    count := 0
    return func() int {
        count++
        return count
    }
}

func main() {
    next := counter()
    fmt.Println(next(), next(), next()) // 1 2 3

    other := counter() // a fresh, independent count
    fmt.Println(other())              // 1
}

Each call to counter creates a new count variable, and the returned function captures that variable by reference. So next and other have entirely separate counters -- calling one never touches the other's state. Notice there is no nonlocal keyword, no boxing, no ceremony: the inner function simply refers to count, and Go keeps count on the heap for exactly as long as some closure still needs it. This is how you carry a little state along with behaviour, and you will meet it constantly in real Go: rate limiters, memoisers, ID generators, middleware.

Now the famous trap, because it used to catch everyone. When you close over a loop variable, you are capturing the variable, not its value at that instant. Historically, all the closures created in a loop shared one variable and saw its final value:

package main

import "fmt"

func main() {
    funcs := make([]func(), 0, 3)
    for i := 0; i < 3; i++ {
        funcs = append(funcs, func() {
            fmt.Print(i, " ")
        })
    }
    for _, f := range funcs {
        f() // Go 1.22+: prints "0 1 2". Older Go: prints "3 3 3".
    }
    fmt.Println()
}

Read that carefully, because the answer changed. In Go 1.22 and later (which you are running), each iteration gets its own fresh i, so the closures capture three different variables and you get 0 1 2 -- what you almost certainly wanted. In Go 1.21 and earlier there was one shared i for the whole loop, so by the time the closures ran, i was 3, and you got the notorious 3 3 3. This was such a common bug that the Go team changed the loop semantics in 1.22. I am showing you the old behaviour on purpose: you will still find tutorials, Stack Overflow answers, and older codebases that manually copy the loop variable (i := i) to work around it. Now you know why that line exists, and that on modern Go you no longer need it. It is a rare example of a language quietly fixing a footgun without breaking the mental model.

defer: cleanup that always runs

defer schedules a function call to run when the surrounding function returns, no matter how it returns -- normal return, a panic, an early exit, all of them. It is Go's answer to "make sure this file gets closed / this lock gets released", and deferred calls run in last-in-first-out order:

package main

import "fmt"

func main() {
    defer fmt.Println("this runs last (deferred first)")
    defer fmt.Println("this runs second-to-last")

    fmt.Println("this runs first")
    fmt.Println("doing work...")
}

The two deferred lines are scheduled as main runs but execute only as it returns, in reverse order (LIFO, like a stack). In real code you will almost always see defer file.Close() on the line right after you open a file -- the cleanup sits next to the thing it cleans up, and you cannot forget it down some early-return path. That physical closeness of "open" and "close" is the whole ergonomic win.

There is a timing rule that trips people up, so meet it deliberately: a deferred call's arguments are evaluated when the defer statement runs, not when the deferred call finally executes. The scheduled call captures the argument values right then and there:

package main

import "fmt"

func main() {
    x := 1
    defer fmt.Println("deferred sees x =", x) // x is evaluated NOW: 1
    x = 99
    fmt.Println("current x =", x) // 99
    // on return, the deferred line prints 1, not 99
}

fmt.Println("deferred sees x =", x) grabs x (which is 1) at the moment the defer runs, even though the printing happens later after x has become 99. If you wanted the final value instead, you would wrap the work in a closure -- defer func() { fmt.Println(x) }() -- because a closure captures the variable, not a snapshot. That distinction (arguments snapshot now, closures capture the variable) is the key to reading any defer correctly.

And here is the payoff I promised in the named-returns section. A deferred closure can read and modify a function's named return values on the way out, which is the idiomatic way to, say, tidy up an error or add context right before it leaves:

package main

import "fmt"

func compute() (result int, err error) {
    defer func() {
        // runs after the body sets result/err, and can adjust them
        result *= 10
    }()
    result = 5
    return // bare return sends result=5... then the defer bumps it to 50
}

func main() {
    r, _ := compute()
    fmt.Println("result:", r) // 50
}

The body sets result to 5 and does a bare return; then the deferred closure runs, sees the named result, and multiplies it to 50 before the value actually leaves the function. This is a genuinely useful pattern for wrapping errors (we will use it for real when we reach error handling) -- but it is also exactly why over-using named returns makes code hard to follow, so wield it with care. One last practical note: avoid defer inside a tight loop, because each deferred call stacks up and only fires when the function returns, not when the loop iteration ends. If you are opening files in a loop, close them in the loop body (or pull the work into its own function so each defer fires per call).

The same idea in Python

Python has multiple return via tuples, *args for variadics, first-class functions and closures too -- the shapes are familiar. What Go adds is the pervasive (result, error) convention in place of exceptions, and a defer that is cleaner than a try/finally:

def divide(a, b):
    if b == 0:
        return None, "division by zero"
    return a // b, None

def make_counter():
    count = 0
    def next_():
        nonlocal count
        count += 1
        return count
    return next_

q, err = divide(10, 2)
print(q if err is None else err)

Python needs nonlocal to reassign a captured variable; Go's closures capture by reference and need no keyword. Python's closest thing to defer is a try/finally block or a with statement, both of which wrap the code in extra indentation -- Go's defer sits inline, on one line, next to the resource it guards. And where Python reaches for exceptions, idiomatic Go returns an error value the caller must look at. Neither approach is objectively "right", but they lead to very differently-shaped programs, and learning to read the Go shape fluently is a big part of what these early episodes are for.

Exercises

  1. Safe division, your way. Write safeDiv(a, b float64) (float64, error) that returns an error when b == 0 and the quotient otherwise. Call it twice from main -- once succeeding, once failing -- and handle both with the if err != nil pattern.

  2. A variadic average. Write average(nums ...float64) float64 that returns the mean, and returns 0 for no arguments (so it never divides by zero). Test it with several loose numbers and by spreading a slice with ....

  3. An adder factory. Write adder(step int) func(int) int that returns a closure adding step each time it is called, accumulating across calls. Create two adders with different steps and show that they keep separate running totals.

What we learned

  • Go functions return multiple values, and the (result, error) convention replaces exceptions -- failures are ordinary values the caller checks with if err != nil, with no hidden stack-unwinding;
  • The comma-ok idiom (v, ok := ...) is the same multi-return tool applied to map lookups, type assertions, and channel receives, distinguishing "present" from "absent-but-zero";
  • Named returns pre-declare the result variables and allow a bare return; handy for documentation and for deferred tidy-up, but easy to overuse in long functions;
  • A variadic parameter ...T accepts any number of arguments as a slice, and xs... spreads a slice back into such a call -- sharing the backing array, not copying it;
  • Functions are first-class values with a type like func(int) int (which you can even name with type), so you can store, pass and return them -- named or as inline function literals;
  • A closure captures the variables around it by reference and keeps them alive, giving you stateful behaviour without a struct; since Go 1.22 each loop iteration gets its own variable, so the old 3 3 3 capture trap is gone;
  • defer schedules cleanup to run on function exit in last-in-first-out order; its arguments are evaluated at the defer line while a deferred closure captures live variables (and can even rewrite named returns), and you keep it out of tight loops.

Next episode we get into control flow -- Go's one loop keyword in all its shapes, the switch that is far more powerful than you expect, and the if with its own little init statement. Bring the editor, there is real code to write. See you there.

Bedankt en tot de volgende! ;-)

scipio@scipio

Learn Go Series (#3) - Functions and First-Class Functions | Ecency