Skip to content
academia.sh

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 nil map is defined, writing panics; a string’s length is defined, indexing gives a byte.
  • A nil slice and a nil map carry the same word but do not withstand the same operations — a nil slice can be appended to, a nil map 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.

Start typing to search.

↑↓ ↵ Esc navigate · open · close