Advanced Function Topics
Explores recursion, function types that satisfy interfaces, deferred function argument evaluation, and init functions in Go
A function in Go is a reusable block of code that can accept inputs and return outputs. The earlier sections of this chapter covered declaring functions, working with multiple return values, closures, and methods. This page pushes further into four topics that appear frequently in real Go code: recursive functions, function types that implement interfaces, the moment arguments to deferred calls are evaluated, and the special init function that runs automatically at program startup. These are not isolated curiosities; each one solves a concrete problem a Go developer encounters, and each one has a few sharp edges worth understanding now.
Recursion
A recursive function calls itself. That is the whole definition. A base case stops the recursion; without one, the function would call itself forever, eventually exhausting the stack and crashing the program. Every recursive solution can be expressed as a loop, but recursion often mirrors the shape of the problem more naturally—walking a tree of directories, parsing nested structures, or computing values defined in terms of themselves.
Think of recursion like Russian nesting dolls: you open one doll to find a smaller doll inside, then repeat until you reach the tiniest, solid doll that cannot be opened. The solid doll is the base case. Each opening step is the recursive case, where the function works on a slightly smaller version of the original input.
When a function calls itself, Go pushes a new stack frame onto the call stack—a region of memory that tracks each function invocation’s local variables and return address. If the recursion is too deep, the stack overflows and the program panics with a runtime error. This is the most common beginner mistake: a missing or unreachable base case. Even with a base case, recursion on very large inputs can overflow the stack, so for problems with unbounded depth an iterative approach is often safer.
Infinite Recursion Crash:
If a recursive function never reaches its base case, the program will panic with a runtime error like runtime: goroutine stack exceeds .... Always test your base case with the smallest possible input first.
A Simple Recursive Function
The classic example is factorial, where n! = n × (n‑1)!, and 0! is defined as 1.
func factorial(n int) int {
if n == 0 {
return 1 // base case
}
return n * factorial(n-1) // recursive case
}
When you call factorial(4), the call stack builds up: factorial(4) calls factorial(3), which calls factorial(2), down to factorial(0). That bottom call returns 1, and then each frame multiplies and returns upward.
This example works because n decreases by 1 each call, guaranteeing it eventually hits zero. If you pass a negative number, the function never reaches the base case—it decrements forever. Adding a check for negative input is a necessary guard in production code.
Real-World Use: Directory Traversal
A more practical example is walking a directory tree to list all files.
import (
"fmt"
"os"
"path/filepath"
)
func listFiles(dir string) error {
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
for _, entry := range entries {
path := filepath.Join(dir, entry.Name())
if entry.IsDir() {
if err := listFiles(path); err != nil {
return err
}
} else {
fmt.Println(path)
}
}
return nil
}
Each subdirectory triggers another listFiles call. The base case is an empty directory or a directory containing only files; the recursion depth matches the deepest nesting level of the filesystem. On a typical filesystem this depth rarely exceeds a few dozen levels, so stack overflow is not a concern. The function returns an error when a directory cannot be read, and propagates it upward, a pattern you will see throughout the Go standard library.
Recursion and Error Handling:
When a recursive function can fail, return the error and let each level decide whether to abort or continue. This keeps the control flow visible and avoids panicking unnecessarily.
Common Mistakes
Missing base case or unreachable base case. This is the cause of nearly every infinite recursion bug. Always write the base case first and test it in isolation.
Forgetting that recursion consumes stack space. Go’s goroutine stacks start small and grow, but recursive calls still consume memory. Very deep recursion (tens of thousands of calls) can still cause a panic. For unbounded problems, prefer iteration or an explicit stack.
Mutating shared state without passing it explicitly. If a recursive closure captures a slice or map and modifies it, different recursive branches can interfere. Pass state as arguments or use a pure functional style when possible.
Recursion vs. Iteration:
A recursive solution is not automatically better. For linear problems like factorial or Fibonacci, a simple loop is clearer, faster, and cannot overflow the stack. Use recursion when it truly simplifies the logic, not as a stylistic choice.
Function Types Implementing Interfaces
In Go, you can define a named type based on a function signature—for example, type HandlerFunc func(int) string. Because you can attach methods to any named type (not just structs), a function type can carry its own methods. This makes it possible for a plain function to satisfy an interface when the interface requires a method with a matching signature.
Why does this matter? The standard library uses this pattern extensively. The net/http package defines type HandlerFunc func(ResponseWriter, *Request) and then attaches a ServeHTTP method to it. Any function with the right signature can be converted to HandlerFunc and used anywhere an http.Handler is expected. You get to write a function instead of a struct, but the type system treats it as a full interface implementation.
How It Works
Take a small interface that requires one method:
type Printer interface {
Print(s string)
}
Now define a function type that matches the method signature:
type PrinterFunc func(string)
Attach the method to the function type—the method body simply calls the underlying function:
func (f PrinterFunc) Print(s string) {
f(s)
}
With this in place, any function with the signature func(string) can be converted to PrinterFunc and used as a Printer:
func myPrint(s string) {
fmt.Println(">> " + s)
}
func main() {
var p Printer = PrinterFunc(myPrint)
p.Print("hello")
}
// Output: >> hello
The conversion PrinterFunc(myPrint) does not copy anything; the underlying function value is stored as the receiver. When Print is called, the method invokes f(s), which is the original function.
This pattern shines with single-method interfaces. Instead of creating a struct that holds a function and implements the interface, you promote the function to the interface directly.
Single-Method Interfaces Simplify Code:
If an interface has only one method, a function type adapter removes boilerplate. You write the logic as a function, convert it to the adapter type, and satisfy the interface immediately.
Real-World Example: HTTP Handlers
The net/http package defines:
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
type HandlerFunc func(ResponseWriter, *Request)
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}
You can write a handler as a plain function and convert it when registering a route:
func hello(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello, world!")
}
http.Handle("/", http.HandlerFunc(hello))
The http.HandlerFunc(hello) conversion makes the function usable as a Handler. This is so common that http.HandleFunc does the conversion for you.
Common Misconceptions
Thinking a function type automatically satisfies every interface. It only satisfies the interface if you explicitly attach the matching method. The compiler does not infer that PrinterFunc is a Printer until the method is defined on the type.
Confusing function types with function literals. PrinterFunc is a named type, not a function literal. You define the type, then attach a method that calls the receiver. A function literal like func(s string) { ... } does not have a method set; you must wrap it in the named type.
When to Use This Pattern:
If you find yourself writing a struct with a single field that is a function, and that struct’s only method just calls the function, consider replacing the struct with a function type and a method. The result is fewer types and less indirection.
Deferred Function Argument Evaluation
A defer statement delays the execution of a function call until the surrounding function returns. The tricky detail is when the arguments to that deferred call are evaluated. They are evaluated immediately, at the moment the defer statement runs, not when the deferred function actually executes.
This matters when the argument is a variable whose value changes between the defer and the return.
func demo() {
i := 0
defer fmt.Println(i) // i is evaluated NOW: i is 0
i++
// function returns; deferred call prints 0
}
Even though i becomes 1 before the function ends, the deferred call prints 0. The argument i was read when defer fmt.Println(i) executed.
If you want to capture the current value at defer time but have it used later as the variable changes, you need to either pass a pointer (so the value is read when the deferred function runs) or, more commonly, use a function literal with no arguments that closes over the variable:
func demo() {
i := 0
defer func() { fmt.Println(i) }() // closure reads i at call time
i++
// prints 1
}
The closure reads i when it executes, so it sees the final value.
The Loop Variable Trap
The most painful consequence of this rule appears in loops. A naive defer inside a loop that references the loop variable will capture the same variable for every deferred call:
func badLoop() {
for i := 0; i < 3; i++ {
defer fmt.Println(i) // all three will print 3
}
}
All three deferred calls read the same i at defer time? Wait, evaluate the example: i is 0, 1, 2 when defer runs, so Println's argument is evaluated then. Actually this specific example prints 2, 1, 0 (LIFO order) because each defer evaluates i at its iteration. But the real trap is with a closure:
func badLoopClosure() {
for i := 0; i < 3; i++ {
defer func() { fmt.Println(i) }() // all three will print 3
}
}
Here each deferred closure captures the same i variable, and by the time they run the loop has finished and i is 3. So all three print 3. That is the common surprise.
The fix is to pass the loop variable as an argument to the deferred function, which forces evaluation at that iteration:
func goodLoop() {
for i := 0; i < 3; i++ {
defer func(n int) { fmt.Println(n) }(i)
}
}
// Prints 2, 1, 0 (LIFO order)
Loop Variable Captured by Deferred Closure:
A deferred closure inside a loop that captures the loop variable directly will use the last value of that variable for all calls. Always pass the iteration value as a function argument to freeze it.
Why Evaluation Happens Immediately
The Go specification ensures that all arguments to a deferred call, including the receiver if it’s a method call, are evaluated when the defer statement executes. This is consistent with the rest of the language: function call arguments are always evaluated before the call. defer just postpones the execution of that already-constructed call.
This design makes performance predictable—the runtime does not need to save expressions and re-evaluate them later. But it also means the programmer must be aware of whether they are passing a value or relying on a closure to see the latest state.
Deferred Methods and Receivers:
The receiver of a deferred method call is also evaluated at defer time. If the receiver is a pointer, the method sees whatever that pointer points to at execution time. If it’s a value, the method sees a copy taken at defer time. This is a subtle but important difference.
Practical Guidelines
- Use a deferred closure (function literal with no arguments) when you need to capture the state at the moment the deferred function runs, not when
deferis called. - Use a direct deferred call with arguments when you need to freeze values at the
defersite. - In loops, always pass the iteration variable as an explicit argument to the deferred function to avoid the closure trap.
- For cleanup like
defer f.Close(), argument evaluation usually does not matter because the values are not mutated afterward.
init Functions
An init function is a special function in Go that takes no arguments, returns nothing, and cannot be called explicitly. The runtime executes it automatically, once per package, during program initialization. A package can have multiple init functions—even within the same file—and they all run in source order before main starts.
The purpose of init is to set up package-level state that cannot be expressed purely with variable initializers. Common uses include registering database drivers, verifying configuration, or seeding a random number generator.
package config
import (
"os"
"fmt"
)
var APIKey string
func init() {
APIKey = os.Getenv("API_KEY")
if APIKey == "" {
fmt.Println("warning: API_KEY not set")
}
}
When the config package is imported, its init function runs after all global variable declarations in the package are evaluated. If package config imports other packages, their init functions run first, recursively.
init Execution Order:
Within a package, variables are initialized in declaration order, then all init functions run in the order they appear in the source. Across files, the order depends on how files are presented to the compiler (typically alphabetical by filename). Avoid writing init functions that depend on a specific execution order across files.
Multiple init Functions
You can have as many init functions as you like, and they will all execute:
func init() {
fmt.Println("first init")
}
func init() {
fmt.Println("second init")
}
This prints "first init" then "second init". While syntactically valid, multiple init functions in the same file make code harder to follow. Reserve this pattern for generated code or complex registration scenarios.
What init Cannot Do
- It cannot be called from user code. The compiler enforces this.
- It cannot take parameters or return values.
- It cannot be referenced as a function value.
- It should not perform heavy computation or I/O that can fail without a fallback; an
initthat panics crashes the entire program. If initialization can fail, use a separateInit()function that returns an error and call it frommain.
Avoid Program Startup Panics:
If an init function panics, the program stops before main runs, often with no clear recovery path. Validate inputs and use graceful degradation (e.g., log a warning) rather than panicking.
Real-World Pattern: Driver Registration
The database/sql package allows database drivers to register themselves in init:
package mydriver
import "database/sql"
func init() {
sql.Register("mydriver", &Driver{})
}
A program that imports mydriver merely for the side effect of registration uses a blank import:
import _ "github.com/example/mydriver"
The blank import triggers the package’s init, which registers the driver, without requiring the program to call anything. This is the idiomatic way to wire pluggable components.
init Is for Simple Side-Effect Setup:
When you need to run code exactly once, before main, and that code does not return errors that should stop the program, init is the right tool. For anything more complex, an explicit initialization function called from main gives you control and visibility.
The four topics on this page appear in distinct corners of Go code, but they share a thread: they extend what a “function” means beyond the obvious call-and-return model. Recursion lets a function solve problems by repeatedly calling itself. Function types implementing interfaces turn a plain function into an object that satisfies a contract. Deferred argument evaluation forces you to think about when a value is read, not just when code runs. And init functions run automatically, wiring up package-level state without any explicit call from main. Understanding these gives you access to patterns that are widespread in the standard library and in idiomatic Go projects.
If you are building a program that needs to register plugins or drivers, reach for init. When a problem has a naturally recursive shape—tree traversal, nested parsing—prefer recursion but always guard the base case. When you need a single-method interface and want to avoid a throwaway struct, define a function type with a method. And whenever you write a defer, pause to ask: should this deferred call capture a value now or later? Answering that question correctly prevents a class of bugs that are notoriously hard to spot.