Functions in Depth
Functions in Depth
A Go function is a named piece of work with a type you can read in one line: parameters in, results out. The boring default is a short function, explicit results, and a caller that looks at every error. Named results, defer, and ... exist because a few jobs need them — not because every signature should look clever.
Mental model
A signature is the function’s contract: name, parameter types, result types. Callers depend on that line, not on anything you do inside.
Values are copied into parameters. A []int parameter is a copy of the slice header (pointer, length, capacity), not a deep copy of the elements — that sharing is a later chapter. For now: integers, structs, and strings you receive are yours to use; writing to a local parameter does not write back to the caller.
Results can be more than one. The usual pair is (T, error). The blank identifier _ throws a result away. defer schedules a call to run when the surrounding function returns, in last-in first-out order. A variadic parameter (...T) is a []T built from the arguments.
Worked examples
Case 1: A signature you can read aloud
Save as total.go. One parameter, one result, no hidden state.
// total.go
package main
import "fmt"
func total(prices []int) int {
sum := 0
for _, p := range prices {
sum += p
}
return sum
}
func main() {
fmt.Println(total([]int{12, 8, 20}))
}Run:
go run total.goOutput:
40
prices is the parameter name. []int is its type. int after the list is the result type. Names in the signature are for humans; the type is what the compiler checks.
Case 2: Multiple results, error last
Save as split_ticket.go. The desk prints tickets as id: note. The function returns the pieces or an error. Zeros on the failure path are intentional: the caller must check err before using the other results.
// split_ticket.go
package main
import (
"fmt"
"strings"
)
func splitTicket(label string) (int, string, error) {
idStr, rest, ok := strings.Cut(label, ":")
if !ok {
return 0, "", fmt.Errorf("ticket %q: missing colon", label)
}
var id int
_, err := fmt.Sscanf(idStr, "%d", &id)
if err != nil {
return 0, "", fmt.Errorf("ticket %q: bad id", label)
}
return id, strings.TrimSpace(rest), nil
}
func main() {
id, note, err := splitTicket("41: extra napkins")
if err != nil {
fmt.Println("err:", err)
return
}
fmt.Printf("id=%d note=%q\n", id, note)
}Run:
go run split_ticket.goOutput:
id=41 note="extra napkins"
The _ on the Sscanf line discards the count of scanned items. That is a fair use of blank: you already have err. Do not blank-out err itself.
Case 3: Named results, used sparingly
Save as share.go. Named result parameters are extra locals, pre-zeroed, that a bare return will send back. They help when the names are the documentation (each, leftover) and the function is short. They hurt when you mix them with := and defer.
// share.go
package main
import "fmt"
func share(cents int, n int) (each int, leftover int) {
if n <= 0 {
return 0, cents
}
each = cents / n
leftover = cents % n
return
}
func main() {
each, leftover := share(1000, 3)
fmt.Printf("each=%d leftover=%d\n", each, leftover)
}Run:
go run share.goOutput:
each=333 leftover=1
The early return 0, cents is still explicit. Naked return is the part to keep rare. Prefer return each, leftover if anyone on the desk might miss what goes back.
Case 4: defer runs on the way out
Save as close_shift.go. Open the till, count, then close — even if you add more returns later. defer is for cleanup that must not be skipped.
// close_shift.go
package main
import "fmt"
func closeShift(name string) {
fmt.Println("open", name)
defer fmt.Println("close", name)
fmt.Println("count till")
}
func main() {
closeShift("Amina")
}Run:
go run close_shift.goOutput:
open Amina
count till
close Amina
Deferred calls run last-in first-out. Two defers stack like plates:
// defer_order.go
package main
import "fmt"
func main() {
defer fmt.Println("unlock drawer")
defer fmt.Println("write log")
fmt.Println("count cash")
}Run:
go run defer_order.goOutput:
count cash
write log
unlock drawer
Arguments to a deferred call are evaluated when defer runs, not when the function returns. Keep deferred calls simple: f.Close(), unlock(), cancel().
Case 5: Variadic arguments
Save as sum.go. cents ...int means zero or more int arguments. Inside the function it is a []int. Pass an existing slice with order....
// sum.go
package main
import "fmt"
func sum(cents ...int) int {
total := 0
for _, c := range cents {
total += c
}
return total
}
func main() {
fmt.Println(sum())
fmt.Println(sum(250, 400))
order := []int{120, 80, 50}
fmt.Println(sum(order...))
}Run:
go run sum.goOutput:
0
650
250
A function may have at most one variadic parameter, and it must be last. fmt.Println is the standard-library version of the same idea.
The trap
Named results plus := will happily create a new err that dies at the end of the if. The named err stays nil. The function “succeeds.”
Save as named_shadow.go:
// named_shadow.go
package main
import "fmt"
func priceOf(item string) (cents int, err error) {
if item == "" {
err := fmt.Errorf("empty item")
_ = err
}
return
}
func main() {
cents, err := priceOf("")
fmt.Printf("cents=%d err=%v\n", cents, err)
}Run:
go run named_shadow.goOutput:
cents=0 err=<nil>
The inner err := is a different variable. The _ = err only exists so the program compiles. The caller sees no error.
The boring fix is to stop naming results and return values you can see:
// named_shadow_fix.go
package main
import "fmt"
func priceOf(item string) (int, error) {
if item == "" {
return 0, fmt.Errorf("empty item")
}
return 250, nil
}
func main() {
cents, err := priceOf("")
fmt.Printf("cents=%d err=%v\n", cents, err)
}Run:
go run named_shadow_fix.goOutput:
cents=0 err=empty item
If you keep named results, assign with = (err = fmt.Errorf(...)), never :=, and write return cents, err.
The boring rule
- Put
errorlast. Check it before you use the other results. - Name parameters for the reader. Keep the function small enough that named results are optional.
- Prefer
return v, errover a nakedreturn. - Use
deferfor cleanup (close, unlock, cancel), not for clever control flow. - Use
...Twhen the natural call is a list of values. Use a slice parameter when the caller already has a slice and always will. _is for results you have already accounted for. Never_ = errat a desk that cares about money.
Try this
- Change
split_ticket.gosomainalso callssplitTicket("no-colon")and prints the error. Do not crash. - In
close_shift.go, add a seconddeferthat printslog shift done. Confirm it runs beforeclose(last-in first-out). - In
sum.go, add a parameterlabel stringin front ofcents ...intand print the label with the total. Variadic still has to be last. - In
named_shadow.go, replaceerr :=witherr =and drop_ = err. Confirm the caller seesempty item.