Skip to content
ioob.dev
Go back

Go Basics Part 3 — Functions

· 3 min read
Go Series (3/12)
  1. Go Basics Part 1 — Variables, Constants, and Types
  2. Go Basics Part 2 — Conditionals and Loops
  3. Go Basics Part 3 — Functions
  4. Go Basics Part 4 — Error Handling
  5. Go Basics Part 5 — Arrays, Slices, and Maps
  6. Go Basics Part 6 — Structs and Methods
  7. Go Part 7 — Interfaces
  8. Go Part 8 — Pointers
  9. Go Part 9 — Goroutines and Channels
  10. Go Part 10 — Concurrency Patterns
  11. Go Part 11 — Packages and Modules
  12. Go Part 12 — Generics and Practical Patterns
Table of contents

Table of contents

Function Declaration Basics

In Go, functions are declared with the func keyword. Parameter types come after the name, and the return type follows the parameter list.

package main

import "fmt"

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

func main() {
    result := add(3, 5)
    fmt.Println(result)  // 8
}

When consecutive parameters share the same type, you only need to write the type on the last one.

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

In C or Java, types come before names (int add(int a, int b)), but Go reverses this. It might feel awkward at first, but Go’s approach is actually easier to read with complex function signatures.

Multiple Return Values

The most distinctive feature of Go functions is the ability to return multiple values. It’s similar to Python’s tuple returns, but in Go this is a core language pattern.

package main

import (
    "errors"
    "fmt"
)

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

func main() {
    result, err := divide(10, 3)
    if err != nil {
        fmt.Println("error:", err)
        return
    }
    fmt.Printf("result: %.2f\n", result)  // result: 3.33
}

This pattern pervades all Go code. A function returns (value, error), and the caller checks the error. It’s the opposite philosophy from Java’s approach of throwing exceptions. The key idea is to make it hard to ignore errors, which is covered in more depth in Part 4.

Named Returns

You can give names to return values. They’re initialized to their type’s zero value at the start of the function, and a bare return statement returns those variables.

package main

import "fmt"

func swap(a, b string) (first, second string) {
    first = b
    second = a
    return  // Returns first and second
}

func main() {
    x, y := swap("hello", "world")
    fmt.Println(x, y)  // world hello
}

This looks clean in short functions, but in longer ones it becomes hard to track which values are being returned. The Go community consensus is to use named returns only in short functions.

That said, there’s one clearly useful case: documenting the meaning of return values in godoc. A declaration like (quotient, remainder int) serves as documentation by itself.

Variadic Arguments

To let a function accept any number of arguments, use ....

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))       // 6
    fmt.Println(sum(10, 20, 30, 40)) // 100

    // Spread a slice to pass it
    numbers := []int{5, 10, 15}
    fmt.Println(sum(numbers...))     // 30
}

nums ...int is treated as a []int slice inside the function. Conversely, to pass a slice to a variadic function, you need to spread it with slice....

Variadic parameters can only appear at the end of the parameter list. fmt.Println is the quintessential variadic function.

First-Class Functions

In Go, functions are first-class citizens. They can be stored in variables, passed as arguments to other functions, and returned as values.

package main

import "fmt"

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

func double(n int) int {
    return n * 2
}

func main() {
    nums := []int{1, 2, 3, 4}
    doubled := apply(nums, double)
    fmt.Println(doubled)  // [2 4 6 8]
}

The apply function takes a slice and a transform function, applying it to each element. func(int) int is the function type. This pattern is used in callbacks, middleware, strategy patterns, and many other places.

When function types get long, you can create an alias with type.

type transformer func(int) int

func apply(nums []int, fn transformer) []int {
    // ...
}

This makes the signature much cleaner.

Anonymous Functions and Closures

You can create unnamed functions on the spot. They can be called immediately or stored in a variable.

package main

import "fmt"

func main() {
    // Store in a variable
    greet := func(name string) string {
        return "Hello, " + name
    }
    fmt.Println(greet("gopher"))  // Hello, gopher

    // Declare and call immediately
    result := func(a, b int) int {
        return a + b
    }(3, 5)
    fmt.Println(result)  // 8
}

Anonymous functions truly shine when they work as closures. They can capture and continue using variables from the outer scope.

package main

import "fmt"

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

func main() {
    next := counter()

    fmt.Println(next())  // 1
    fmt.Println(next())  // 2
    fmt.Println(next())  // 3

    // A new counter is independent
    another := counter()
    fmt.Println(another())  // 1
}

The function returned by counter() captures the outer count variable. Each call increments count, and this variable persists as long as the function is alive. another is a separate closure, so it has its own count.

Looking at how each closure captures its own environment (count) from a memory perspective, the picture looks like this.

flowchart LR
    Call1["counter() call #1"] --> Env1["Environment #1<br/>count = 0,1,2,3..."]
    Env1 --> Fn1["next closure<br/>references Env #1"]
    Call2["counter() call #2"] --> Env2["Environment #2<br/>count = 0,1..."]
    Env2 --> Fn2["another closure<br/>references Env #2"]
    Fn1 -.independent state.- Fn2

This pattern is useful when you need to encapsulate state without creating a full struct. It’s frequently used for injecting configuration into HTTP handlers or creating mock functions in tests.

One thing to watch out for: creating closures inside a loop that capture the loop variable can lead to unexpected results.

package main

import "fmt"

func main() {
    fns := make([]func(), 3)
    for i := 0; i < 3; i++ {
        fns[i] = func() {
            fmt.Print(i, " ")
        }
    }
    for _, fn := range fns {
        fn()
    }
    // Go 1.22+: 0 1 2
    // Go 1.21 and earlier: 3 3 3
}

Starting with Go 1.22, loop variable scoping was changed so that each iteration creates a new scope, fixing this issue. Before that, you had to copy the loop variable to a local variable. If you see the odd-looking pattern i := i in older Go code, this is the reason.


The next part covers Go’s error handling. We’ll explore the error interface, defer, panic/recover, and Go’s unique approach to handling errors.

-> Part 4: Error Handling


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Go Basics Part 2 — Conditionals and Loops
Next Post
Go Basics Part 4 — Error Handling