Lesson 05 / 18
Zero Values
Fifteen unwritten cases are run through a single frame: eight find a defined counterpart from the language, four are rejected by the compiler, three are left open to runtime. Limiting measurement: nil can be appended to a slice but cannot be written to a nil map — both are the same kind of zero value.
Published
In this lesson
- Builds and runs a single frame that sorts fifteen unwritten cases into defined, rejected, and open
- Measures what a type's zero value is and that nil carries four separate meanings across four separate types
- Shows, by comparing a nil slice against a nil map, that a defined zero value is not always usable
Contents
The previous lesson ended without asking what would happen if we wrote var b int8 and never
gave it a value. That question is the real one this course has been deferring since its
opening: when a variable is left without an initial value, what does the language find there?
This lesson asks that question not in one example but in fifteen separate places, and sorts
each into one of three classes — defined, the language puts something in place of the
unwritten; rejected, the compiler will not accept the unwritten state at all; open, the
result is left to runtime. The numbers referred to throughout the rest of the course are
produced here.
The Programming Foundations course (M01/K02) built binding a variable to a name using Python; there, a name could not exist without being given a value. That concept is not rebuilt here — one sentence is enough to state the difference: in Go a variable can exist without ever being given a value, because the type system has already assigned every type a zero value in advance. What this lesson measures is what that zero value is for each type, and whether it is really “there” in every situation.
The Premise
The single most useful question when reading a Go program is not what is written but what is not. Every variable left without an initial value has a defined value; an unused variable stops compilation; a nonexistent key returns two values at once; a variadic parameter given no argument at all becomes an empty slice too. In the same language, some unwritten things have no counterpart at all, and the program stops while running.
The measurement reduces this triple to a single question: for each unwritten case, does the language put something in place, does the compiler reject it, or is the result left open? The second half of finding the answer calls the compiler from inside the program: a piece of source is written into a temporary package, the compiler is run, and only whether it passed is read — the error text is never read, because what is being measured is not “why was it rejected” but “was it rejected.”
The Core: An Observer That Sorts Into Three Classes
// unwritten.go — what does the unwritten thing become: does the language put something defined, does the compiler reject it, or is it left open
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
)
var defined, rejected, open int
func observe(name, class, evidence string) {
switch class {
case "defined":
defined++
case "rejected":
rejected++
default:
open++
}
fmt.Printf("%-42s %-11s %s\n", name, class, evidence)
}
func summarize() {
fmt.Printf("unwritten: %d defined, %d rejected, %d open\n", defined, rejected, open)
}
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 panics(f func()) (p bool) {
defer func() {
if recover() != nil {
p = true
}
}()
f()
return false
}
LB13. observe is the single entry point that writes every observation to a class; the
three package-level variables holding the counters (defined, rejected, open) only
increase here, so the totals stay consistent with the observations whenever summarize is
called.
LB14. panics runs a function and catches with recover whether it panicked, returning
only panic=true/panic=false — no stack trace is ever printed. This is the single method
used everywhere in the course that measures a panic.
These two helpers give a tool to each of the three classes this lesson measures: observe and
summarize hold the classification and the total, panics catches the open-ended behavior,
compiles (defined in the previous lesson’s section) measures the rejection. Together, the
three reduce the body of a fifteen-line measurement to a single call shape: each line gives
observe a name, a class, and evidence, and everything else is handled by these three helpers.
Fifteen Unwritten Cases
// measurement.go — fifteen unwritten cases, split into three classes
package main
import "fmt"
type Record struct {
Name string
Number int
Active bool
}
func namedReturn() (result int, err error) { return }
func variadic(numbers ...int) []int { return numbers }
func main() {
fmt.Println("-- what stands in for the unwritten --")
var integer int
observe("an integer with no initial value", "defined", fmt.Sprint(integer))
var slice []int
observe("a slice with no initial value", "defined",
fmt.Sprint("nil=", slice == nil, " length=", len(slice), " appendable=", append(slice, 1)))
var m map[string]int
observe("reading a map with no initial value", "defined", fmt.Sprint(m["missing"]))
writePanicked := panics(func() { m["new"] = 1 })
observe("writing to a map with no initial value", "open", fmt.Sprint("panic=", writePanicked))
var pointer *Record
readPanicked := panics(func() { _ = pointer.Number })
observe("reading a pointer with no initial value", "open", fmt.Sprint("panic=", readPanicked))
var record Record
observe("unwritten struct fields", "defined", fmt.Sprintf("%q %d %t", record.Name, record.Number, record.Active))
s, e := namedReturn()
observe("an unwritten named return", "defined", fmt.Sprint(s, " ", e == nil))
observe("a variadic parameter with no argument", "defined",
fmt.Sprint("nil=", variadic() == nil, " length=", len(variadic())))
source := []int{10, 20, 30, 40}
observe("unwritten slice bounds", "defined", fmt.Sprint(source[:], " ", source[1:], " ", source[:2]))
value, ok := m["missing"]
observe("a nonexistent key returning two values", "defined", fmt.Sprint(value, " ", ok))
text := "élan"
observe("a string's byte instead of a character", "open",
fmt.Sprint("length=", len(text), " first=", text[0], " runes=", len([]rune(text))))
observe("unused variable", classifySource(`package trial
func F() { x := 1 }`), "")
observe("unused import", classifySource(`package trial
import "fmt"
func F() {}`), "")
observe("unwritten return value", classifySource(`package trial
func F() int { }`), "")
observe("implicit type conversion", classifySource(`package trial
func F() { var a int32 = 1; var b int64 = a; _ = b }`), "")
summarize()
}
func classifySource(source string) string {
if compiles(source) {
return "open"
}
return "rejected"
}
-- what stands in for the unwritten -- an integer with no initial value defined 0 a slice with no initial value defined nil=true length=0 appendable=[1] reading a map with no initial value defined 0 writing to a map with no initial value open panic=true reading a pointer with no initial value open panic=true unwritten struct fields defined "" 0 false an unwritten named return defined 0 true a variadic parameter with no argument defined nil=true length=0 unwritten slice bounds defined [10 20 30 40] [20 30 40] [10 20] a nonexistent key returning two values defined 0 false a string's byte instead of a character open length=5 first=195 runes=4 unused variable rejected unused import rejected unwritten return value rejected implicit type conversion rejected unwritten: 8 defined, 4 rejected, 3 open
The last four lines are the four rejections measured in detail in the first lesson; here they
sit as the completion of the list and fall into the same class (rejected). The remaining
eleven lines belong to this lesson and cover eight separate types: integer, slice, map,
pointer, struct, named return value, variadic parameter, string. Each line asks the same
question of a single type — what happens when it is left with no initial value — and delivers
the answer to observe in three parts: a name, a class, evidence. The evidence column carries
the real values that back up the claim; the next section reads these one by one.
Reading the Numbers
First claim: there is no such thing as “uninitialized” in Go. In eight of the fifteen
unwritten cases, the language puts something defined in place of the unwritten: an integer’s
zero value is 0, a slice’s zero value is nil but its length is 0 and it is still
appendable, reading from a map returns 0, a struct’s fields carry their own zero values, an
unwritten named return returns 0 and nil, a variadic parameter given no argument becomes a
nil slice, unwritten slice bounds stretch from the start to the end, a nonexistent key
returns 0 and false. All eight are not a convenience but a contract: the reader knows what
they will find without looking at the source.
What these eight share is that the zero value looks different per type but is always
predictable. For an integer the zero value is numeric zero; for a string it is the empty
string (""); for a boolean it is false; for a pointer, a slice, a map, or a function it is
nil. All four are called by the same word (nil) without carrying the same idea, but all
four carry the same intent: “not yet bound to anything.” nil itself is not a single value —
a pointer’s nil means it points to no address in memory, a slice’s nil means it has no
backing array, a map’s nil means no storage has been allocated, a function’s nil means
there is no code that can be called. All four print with the same word, but all four represent
a distinct absence within their own type.
Second claim: all four things the compiler rejects are, in other languages, a warning level issue. Unused variable, unused import, an unwritten return value, implicit type conversion — all four make the source fail to compile. Rejecting is not a show of strictness, it is a decision to narrow what stands in for the unwritten: the language chooses, in these four cases, “never allow it” over “put something defined there.”
Compared with the first eight, these four show an interesting reversal. In the first eight, the language lets the source run despite something unwritten, and fills the gap itself. In these four, instead of filling the gap, the language never lets the source run at all. Both are “what stands in for the unwritten,” but one is a value carried into runtime, the other is a door closed at compile time.
Third claim: definedness works in the reading direction and stops in the writing and
interpreting direction. All three of the open cases show this. Reading from a map with no
initial value is defined, but writing to it stops the program — a panic. A pointer with no
initial value is a defined value (nil), but reading it stops the program — again a
panic. A string’s length is defined, but what indexing into it gives is not a character, it is
a byte — "élan" is five bytes, four runes, and the first byte is 195 (the first part of
a multi-byte encoding, not a letter on its own).
Limiting Measurement: “Defined” Does Not Mean Usable
Not all eight defined lines are equally usable. Comparing the second line (nil slice) with
the third line (reading from a nil map) makes the difference clear: both are left with no
initial value, and both are nil. But append works on a nil slice — the output shows
appendable=[1] — while writing to a nil map panics. The same word (nil), the same class
(defined), two different results.
LB15. The difference is not in nil itself, it is in the separate contract the
language gives each of these two types. A slice, even when nil, is a header that carries a
length (0) and a capacity (0); append can allocate a new backing array for that header and
grow, because growing is a behavior a slice already has. A map, when nil, contains no storage
at all; reading can cope with that emptiness (the key being looked for does not exist at all,
so its zero value is returned), but writing requires allocating room for a new entry, and a
nil map has nowhere to allocate it. The word “defined” is correct in both, but what is
defined changes from type to type.
This is a warning worth keeping in mind while reading the eight defined lines: a value having a zero value does not mean that value can withstand every operation. Which operations work with a zero value will be measured again when this course’s next topic treats slice and map types on their own.
The same limiting reading appears once more in the second open line. A pointer’s zero value is
also nil, and like a nil slice, it withstands some operations — comparing a nil pointer
against another pointer, or reassigning it, works without a problem. But reading a field
(pointer.Number) does not work, because reading a field first requires a struct instance to
exist, and a nil pointer points to no instance at all. Three types — slice, map, pointer —
all three carry nil as their zero value, but all three define their own set of behaviors;
there is no single “nil rule,” each type carries its own separate contract.
Blind Spot: The Rejection Reason Is Not Measured
The four rejection lines rely on compiles’s true/false answer, and the error text is
never read. This has a cost: a source could have been rejected for a reason other than the
one being measured, and the measurement cannot tell the difference. This is why the rule holds
— every rejected piece of source must be compilable if not for exactly one unwritten thing; in
the unused-variable example there is nothing wrong besides x := 1, in the return-value
example there is nothing wrong besides func F() int { }. This condition was set up and
verified by hand; the measurement tool does not check it on its own.
LB16. This is a deliberate limitation coming from the measurement’s method, not an
oversight. compiles only reads cmd.Run()’s exit status; not reading the error text protects
both the ban on version-dependent data and the “what, not why” question. The cost is that the
four sources in this lesson had to be verified by hand — each was written by trying exactly
one change and checking that everything else stayed fixed. The same discipline continues
everywhere in the rest of the course wherever a compiler rejection is measured.
The eight-line defined group has a similar blind spot: the observe function prints the
evidence it is given as is, it does not test its correctness. Whether a line truly belongs to
the “defined” class is decided by hand while the code is being written — the frame only makes
that decision countable, it does not produce it itself. The oracle — the knowledge of which
unwritten case falls into which class — sits outside the program, in the hands of whoever wrote
this lesson.
Summary
- Of fifteen unwritten cases, 8 are defined, 4 are rejected, 3 are left open — the course’s binding split was produced here.
- There is no such thing as “uninitialized” in Go: each of the eight defined lines carries a concrete value the language puts in place of the unwritten.
- The four things the compiler rejects stop compilation by a decision to narrow what stands in for the unwritten, even though all four are harmless at runtime.
- Definedness works in the reading direction and stops in the writing and interpreting
direction: reading from a
nilmap is defined, writing panics; a string’s length is defined, indexing gives a byte. - A
nilslice and anilmap carry the same word but do not withstand the same operations — anilslice can be appended to, anilmap cannot be written to.
Next Step
Zero values measured what happens when a variable is never touched at all. But a program
almost never stays untouched — it branches, it loops, it moves from one state to another. The
next lesson turns to control flow: why can for be written without a condition, why can
switch be written without an expression, and what stands in for the break left unwritten at
the end of a branch?
To keep your progress and take notes, Log in
My notes
Log in to take notes.