Skip to content
academia.sh

Lesson 07 / 18

Functions

A named return value left completely untouched carries its type's zero value; a function never writing a return value at all is rejected at compile time, but a caller ignoring what comes back without ever catching it says nothing at all — since error is itself just a return value in Go, this open behavior is the course's most important example. Limiting measurement: once a return value is captured into a variable and left unused, this openness closes and the unused-variable rejection takes over.

Published

In this lesson

  • Measures which zero value an untouched named return value carries
  • Compiles proof that separates an unwritten return value from an unused one
  • Runs proof of the boundary between fully ignoring a return value and capturing it into a variable and using only part of it
Contents

The previous lesson measured the “early return without else” form but never once looked at what the function actually returned. This lesson looks at that value: what happens when a function returns more than one value and one of them is left unwritten, what value does a named return carry when it is left completely untouched, and what does the language say when a caller never catches a returned value at all?

The Programming Foundations course (M01/K02) built function definition using Python; there, a function returned a single value or none at all, and more than one value was packed into a tuple and passed through a single return slot. That concept is not rebuilt here — one sentence is enough to state the difference: in Go, multiple return is part of the signature itself; it is not packed into a tuple, each value is written separately with its own type. This lesson measures what these separate slots carry when they are left unwritten.

Multiple Return Values and the Zero of a Named Return

In Go, a function can return more than one value, and these values can be named — the names written in the signature can be used as ordinary variables inside the function’s body. The divide function shows this: result and err are named in the func line, and the body returns them with a bare return, never writing return result, err.

The namedReturn function measured in the Zero Values lesson already showed this behavior in a single line: func namedReturn() (result int, err error) { return } returned two zero values without running any body at all. This lesson measures the same mechanism in a function whose body really does run and branches based on a condition — divide can take two different paths, and how much of the named return is touched changes on each path.

// function.go — multiple return values and a named return returning zero when unwritten
package main

import "fmt"

func divide(a, b int) (result int, err error) {
	if b == 0 {
		err = fmt.Errorf("division by zero")
		return
	}
	result = a / b
	return
}

func main() {
	r1, e1 := divide(10, 2)
	fmt.Printf("%-24s result=%v err=%v\n", "successful division", r1, e1)

	r2, e2 := divide(10, 0)
	fmt.Printf("%-24s result=%v err=%v\n", "division by zero", r2, e2)

	divide(10, 0)
	fmt.Printf("%-24s %s\n", "ignored call", "no panic, program continues")
}
successful division      result=5 err=<nil>
division by zero         result=0 err=division by zero
ignored call             no panic, program continues

LB20. In the b == 0 branch, only err is assigned a value; result is never touched at all. The printed result=0 comes out even though the source has no line anywhere reading result = 0 — int‘s zero value is a direct extension of the frame measured in the Zero Values lesson. A named return value behaves, from the moment the function starts, like a local variable pre-initialized with its own type’s zero value; if the body never touches it, what it returns is that initial value.

This initialization is refreshed every single time the function is called. divide(10, 2) and divide(10, 0) are two separate calls to the same function, and in both result starts from zero; in the first call it is assigned 5, in the second it is never assigned and stays zero. The previous call’s result has no effect at all on the next call — every call starts its named returns fresh, with their own zero values.

The second line makes this even clearer: because err is assigned, err=division by zero appears, yet result is still 0. Both values return with the same return statement, but only one was written by hand in the source. A named return opens a way to write some of a multiple return value’s values and leave the rest at their zero value — even though two names appear in the signature, the body is not required to touch both.

The bare return (instead of return result, err) here is more than shorthand. In an unnamed multiple return, every return statement has to list the values to be returned explicitly; once named, it becomes possible to return the function’s current state as is, without writing the names twice in the source. This shows that result and err behave, for the whole function, like local, not package-level, variables — even though they are declared in the signature, they can be read and changed anywhere in the body.

Naming also has a documentation side. Had func divide(a, b int) (int, error) been written, the signature would only declare two types; understanding which value carries the result and which carries the error would require reading the body. Writing the names result and err in the signature gives that information without going down into the body — the signature says, on its own, which return slot carries what. This is why named returns work in exactly the opposite direction from short declaration: short declaration removed the type name; a named return adds a name of meaning.

This documentation gain should be read together with naming being not mandatory. func divide(a, b int) (int, error) is also a valid signature and returns exactly the same two values in exactly the same way; naming is chosen only for readability, it does not change the behavior. Whether a function needs a named return does not depend on how many values it returns, it depends on how clearly the meaning of those values already reads from the signature.

An Unwritten Return Value Is Rejected, an Unused One Is Not

One of the four rejections measured in the first lesson was the return value being left unwritten: the body of func F() int { } ended without ever producing a value, and the compiler rejected it. This lesson measures the exact opposite of that rejection: is the caller never using the return value rejected the same way? Intuition might say “no” — a call being made and its result not used is, in many contexts, entirely ordinary (a log call’s return value is usually unimportant). This lesson measures the real behavior, not the intuition.

The third line answers this by running it. The call divide(10, 0) is written on its own, as a single expression, without being assigned to anything — both return values (the result and the error) are silently dropped. The program does not panic, the compiler says nothing at all; execution continues without interruption.

LB21. No variable at all is defined on this line — only a call is made. The “unused variable” rejection measured in the first lesson applies to a name that is defined and never read; here no name is defined at all, so that rule never comes into play. The two returned values vanish, along with the call itself, without ever being written anywhere.

Read together with the four rejections in the first lesson, this reveals a striking asymmetry. A body like func F() int { } was rejected because it did not write its return value — the compiler would not accept a body that fails to keep the promise its signature made. But the call divide(10, 0) produces its return value in full — the function keeps its promise, both values really are computed — only the caller never asks for these produced values at all. The compiler’s concern stops at production itself; what happens to a produced value afterward is a matter that lies outside the function’s boundary.

This is the most important example so far of the course’s open class, because in Go an error is an ordinary return value — there is no separate mechanism, no exception type, no mandatory catch construct. If a function returns an error, whether to read that value or not is entirely the caller’s decision. The language does not require, in any way, that a returned error be checked; the error returned by divide(10, 0) vanishes as silently as an event that was never logged.

This behavior leaves no trace in the source either. The third line’s output (“no panic, program continues”) confirms this: the “division by zero” error really is produced, but since it is never written anywhere, it has no effect at all — no panic, no log line, no change in exit code. Someone reading the source cannot even tell from the divide(10, 0) line whether that call produced an error at all; understanding that requires reading the function’s definition. This is the shared trait of open behavior: seeing the result is not enough from the call line, the definition itself has to be read.

Limiting Measurement: What Is Ignored Does Not Always Stay Open

This openness has a limit. Ignoring returned values by never catching them at all is free, but capturing them into a variable and then not using them is not free. To measure this, we return to the first lesson’s compiler call and call divide in two separate pieces of source — one never catching the results, the other catching them and using only one.

// compiler.go — helper that calls the compiler from inside the program
package main

import (
	"os"
	"os/exec"
	"path/filepath"
)

func compiles(source string) bool {
	dir, err := os.MkdirTemp("", "trial")
	if err != nil {
		return false
	}
	defer os.RemoveAll(dir)
	os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module trial\n\ngo 1.24\n"), 0o644)
	os.WriteFile(filepath.Join(dir, "code.go"), []byte(source), 0o644)
	cmd := exec.Command("go", "build", "./...")
	cmd.Dir = dir
	cmd.Env = append(os.Environ(), "GOTOOLCHAIN=local")
	return cmd.Run() == nil
}

func classify(ok bool) string {
	if ok {
		return "compiled"
	}
	return "not compiled"
}
// trial.go — fully ignoring is not the same as capturing and half-using
package main

import "fmt"

func main() {
	fullyIgnored := `package trial
import "fmt"
func divide(a, b int) (result int, err error) {
	if b == 0 {
		err = fmt.Errorf("division by zero")
		return
	}
	result = a / b
	return
}
func F() { divide(10, 2) }`

	halfIgnored := `package trial
import "fmt"
func divide(a, b int) (result int, err error) {
	if b == 0 {
		err = fmt.Errorf("division by zero")
		return
	}
	result = a / b
	return
}
func F() { r, e := divide(10, 2); _ = r }`

	fmt.Printf("%-34s %s\n", "fully ignored", classify(compiles(fullyIgnored)))
	fmt.Printf("%-34s %s\n", "captured and half used", classify(compiles(halfIgnored)))
}
fully ignored                      compiled
captured and half used             not compiled

LB22. Both sources call the same divide function and ignore the same result (the error); the only difference is that the second source captures the returned values into variables named r and e. In the first source no name is defined at all, so the unused-variable rejection never comes into play. In the second source, e is defined and never read — _ = r only rescues r, leaving e an unused variable, and compilation is rejected.

This sharpens the boundary of the open class. Never catching a return value is a decision the language can never see at all — no name appears on the call line, there is nothing to check. But once it is bound to a name, that name falls under the scope of the rule measured in the first lesson: every written name must either be used or never written in the source at all. The error itself stays open — the language still does not force you to read it — but binding it to a name and then forgetting it runs into a different rule.

This distinction also explains why error checking in Go is most often written in the form result, err := divide(...). The moment err is captured into a variable, leaving it unread stops compilation — writing if err != nil is not a style preference, it is the most natural way to count the name err as used. Ignoring a return value by never catching it at all is still possible, but once it is caught, ignoring it is no longer possible.

This also carries a signal for someone reading the source: if a call’s result was never caught, that call’s error was certainly never checked — but if a result was caught, it is also certain that result was used at least enough to compile. Using a value does not mean checking it — _ = e could also have been written and e never read, and would still count as “used” — but it at least shows the name stood in the source for a purpose. This is a third use of the blank identifier measured in the first lesson: writing _ = e is the written form of “I know this error but I am deliberately not checking it.” The unused-variable rejection does not force checking an error — it only refuses to let it be named and forgotten.

The difference looks small but matters in practice: finding which errors were never caught at all requires scanning the call lines across a codebase (the compiler leaves no trace here at all), but finding which caught errors went unchecked is somewhat easier — at least a deliberate disregard like _ = err can be searched for. The compiler cannot see the first group at all, and sees the second group only through the question “was it unused,” never through “was it checked.”

This lesson’s three measurements — the zero of a named return, the rejection of an unwritten return, the openness of an unused return — show all three of the course’s classes on a single function. divide itself never changes; what changes is how we call it. The same function carries a defined zero value when called with a named return, stops compilation when left without a signature, and disappears silently when left uncaught at the call site. For the first time in this lesson, what stands in for the unwritten depends not on the function itself but on how it is called.

Reading the three measurements together shows Go’s function design repeating the same principle three times: production is mandatory, consumption is not. A function must produce every value its signature promises — either by writing it by hand or relying on a named return’s zero. But the caller taking that produced value is never something the language forces. Only once it is taken does that taken thing also need to serve a purpose in the source.

Summary

  • If the body never touches a named return value, that value carries its type’s zero value — result=0 appears even though the source never writes result = 0 anywhere.
  • A return value being left entirely unwritten (func F() int { }) is rejected at compile time.
  • A caller ignoring returned values without ever catching them is not rejected in any way — since error is a return value in Go, this is the course’s most important example of open behavior.
  • Once returned values are captured into a variable, the situation changes: the unused-variable rejection takes over, and a half-used capture no longer compiles.
  • The error itself always stays open; binding it to a name and then forgetting it runs into a separate, different rule.
  • The function design’s principle is single: a function must produce every value it promises, but the caller taking that value is never something the language forces.

Next Step

In this lesson the divide function was always called with two fixed arguments, and what was measured was the unwritten cases on the return side — there was no ambiguity at all on the argument side, the signature said exactly how many arguments it wanted. But some functions do not know in advance how many arguments they will take — the caller can pass zero, one, or twenty values. The next lesson, the last lesson of this topic, measures that ambiguity: what happens to a variadic parameter when it is given no argument at all, and what relationship does this lesson’s “name of a captured value” have with a slice itself?

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close