Skip to content
academia.sh

Lesson 01 / 18

Go's Design Goals

Four things the Go compiler rejects — an unused variable, an unused import, an unwritten return value, an implicit type conversion — are run from inside the program by calling a compiler; all four stop compilation, and rejecting the implicit conversion grows the source doing the same work from 15 characters to 22.

Published

In this lesson

  • Classifies whether a source compiles by calling a compiler from inside the program itself
  • Runs the four things the compiler rejects side by side in the same frame
  • Measures that a rejection does not shorten the source but instead lengthens it
Contents

M08’s six courses asked the same platform six separate questions; the last one measured a behavior that in the source might trigger from no call at all — a provider present on the class path could activate without a single line written in the source. This course walks through the same door but moves the question from the platform to the language itself: a language decides for itself what stands in for the thing you did not write. In some places that stand-in is a defined value; in others the language refuses to recognize the unwritten state at all and rejects the source outright.

This first lesson measures rejection. The Go compiler refuses to accept four forms that would never cause a problem at runtime: an unused variable, an unused import, an unwritten return value, an implicit type conversion. All four are, in most other languages, mostly a warning; here the source never turns into a class file or an executable at all. The question asked is: when one of these is left unwritten, what does the compiler do, and is this rejection really a restriction, or does it just move what needed to be written somewhere else?

The rest of the course will keep asking the same triple question: does an unwritten thing find a defined counterpart in the language, is it rejected by the compiler, or is the outcome left to runtime and open? This first lesson takes up only the middle of the three answers — rejection — because rejection draws the sharpest line around a language’s boundaries: when a language does not allow something, there is no need to even discuss how that thing would have behaved.

The Chain and This Lesson’s Share

The Programming Foundations course (M01/K02) built variable definition, operators, and explicit versus implicit conversion using Python; there, implicit conversion was mostly a runtime behavior that passed without incident. That concept is not rebuilt here — one sentence is enough to state the difference: in Go the same implicitness is not a runtime event but a compile-time rejection. The How Computers Work course (M01/K01) built the compiler–interpreter distinction as a concept; in this lesson the compiler is not a concept but a tool called from inside a program, and the only answer it gives is whether the source passed or not.

The Data Structures course (M01/K03) built the array, the list, and the map as abstract data types and compared them by complexity; this curriculum does not repeat that comparison. M09’s share is which mechanism in Go realizes those already-built concepts — in this first lesson the mechanism is not yet a data type, it is the stance the compiler takes toward the source. The lessons that follow will carry this mechanism into variables, types, and control flow in turn.

Three Design Goals, One Measurable Outcome

Three of Go’s design goals are cited most often: simplicity, compile speed, readability. This lesson does not run all three directly — duration is not a unit of measurement, because how long a compilation takes depends on the machine and the load at that moment and varies from one run to the next. But there is one place where all three leave a visible trace in the source: what the compiler rejects. If a language accepts every construct, the reader has to infer from the source which path was intended; if a language rejects a construct, the party writing it is forced to carry their intent into the source. Simplicity here is not “writing fewer characters” but the smallness of the number of forms the language accepts: the more ways there are to do one task, the more ways the compiler has to reject. Compile speed comes from these rejection decisions not depending on file order — an unused import is visible while that file alone is read, without waiting on how another file compiles. Readability is what this lesson measures directly: all four rejections exist so that whoever reads the source can read every name and every type exactly as written.

These three goals can appear to conflict with each other — writing less and writing intent explicitly do not point the same way. The rest of this lesson measures that tension: do the four rejected forms really block a shorter way of writing, or do they make visible something that could have been left implicit?

The Compiler Is Called From Inside the Program

The whole measurement comes down to a single question: does a piece of source text compile? We answer that question without asking an external tool, using only the standard library’s ability to start a process and write files. The source text is written into a temporary directory together with a module file, the compiler is run in that directory, and only the exit status is read.

// 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 classifySource(source string) string {
	if compiles(source) {
		return "compiled"
	}
	return "not compiled"
}

LB1. The compiler here is not a concept but a call: compiles writes the source into a temporary package, runs the compiler as a subprocess, and reads only the exit status. The error text is never read, because the shape of an error message is version-dependent data; what is measured through the entire course is not “why it was rejected” but “was it rejected”.

This distinction has a cost: since compiles returns only true or false, a source could have been rejected for a reason other than the one intended, and the measurement cannot tell the difference. LB2 closes off that risk — each of the four sources is hand-built and verified to compile if not for exactly one unwritten thing. This is a limit worth keeping in mind while reading the four-line table shown in the lesson: compiled/not compiled is not a debugging tool, it is a classification tool.

LB2. Each of the following four sources compiles if not for exactly one unwritten thing. The rejection comes from that single cause — not from some other syntax error. This condition holds throughout this lesson and the rest of the course wherever a rejection is measured.

Not needing an external tool secures two things at once. First, the measurement is deterministic: the same source text gives the same answer every run, because the compiler itself is called, not a separate interpreter mimicking it. Second, the measurement is portable: compiles only uses the standard library’s process-starting and temporary-file-writing ability, and the rest of the course repeats this pattern.

Four Rejections, Four Reasons

The following four sources are run side by side in the same frame. Each carries a gap that would never cause a problem at runtime — none of them is an infinite loop, a memory bound overrun, or a type mismatch. Yet none of the four compiles.

// main.go — four rejection reasons run side by side
package main

import "fmt"

func main() {
	fmt.Printf("%-32s %s\n", "reason", "result")
	fmt.Printf("%-32s %s\n", "unused variable", classifySource(`package trial
func F() { x := 1 }`))
	fmt.Printf("%-32s %s\n", "unused import", classifySource(`package trial
import "fmt"
func F() {}`))
	fmt.Printf("%-32s %s\n", "unwritten return value", classifySource(`package trial
func F() int { }`))
	fmt.Printf("%-32s %s\n", "implicit type conversion", classifySource(`package trial
func F() { var a int32 = 1; var b int64 = a; _ = b }`))
}
reason                           result
unused variable                  not compiled
unused import                    not compiled
unwritten return value           not compiled
implicit type conversion         not compiled

All four lines give the same result, but they do not come from the same reason. Unused variable and unused import reject a case where something written in the source is never read at all — the language does not recognize a “maybe I will use it later” middle ground; a name is either used or it is never written in the source at all. The unwritten return value belongs to a different class: the signature promised an int, but the body produces no value, and the compiler does not accept a body that fails to keep the promise. The implicit type conversion is a third class: an int32 cannot be assigned directly to an int64 variable — because the target’s bit width differs from the source’s, the compiler requires the widening to be written.

What the three share is that none of them would lead to an observable error at runtime. An unused variable does not crash the program, an unused import breaks nothing, widening from int32 to int64 loses no data. The compiler is rejecting these not based on runtime risk but based on the source’s readability: an unused name misleads the reader, an implicit widening hides from the reader which type stands where. The rejection here is not a safety measure, 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”.

Two of the rejections (unused variable, unused import) share one common escape route: the language’s blank identifier, _. Assigning a value to _ is a written way of saying “I know this value but I will not use it”; the compiler counts this as use and does not reject it. This looks at first like a loophole that punches through the rejection rule, but it actually does the opposite: writing _ erases the difference between an unused name and one silently left as is, because both become equally visible in the source — one a real name, the other a deliberate “I do not care about this” marker. This identifier will reappear later in the course: when skipping one of several return values, and when asking whether a key exists in a map, the same word is used again.

Each rejection captures a concrete situation that arises during a fix. An unused import is a line left behind when code is moved and a call is deleted; if the compiler let it stand, the source would fill up over time with dependencies no longer needed, and seeing which import is truly required would grow harder. An unused variable carries a similar trace: an intermediate result bound to a name and then left unused is most often the mark of a half-finished edit. The unwritten return value comes from a different source — the signature is a promise, the body must keep it; the compiler enforces this to protect the assumption the caller built while reading the signature. The implicit type conversion is the most indirect of all: even if two integer types are in the same family, if their bit widths differ, silently copying one value into the other means, from the reader’s perspective, a widening that never happened; the compiler prefers to make that widening visible rather than let it pass silently.

Limiting Measurement: Rejecting Does Not Mean Writing Less

It would be misleading to think the rejection decision comes free. Take the rejection of the implicit conversion: the line var b int64 = a does not compile when a is an int32. The line that does the same job and does compile is var b int64 = int64(a) — the conversion call has to be written. Compare the two lines directly.

// limiter.go — rejecting does not mean writing less
package main

import "fmt"

func main() {
	implicitLine := "var b int64 = a"
	explicitLine := "var b int64 = int64(a)"

	implicitSource := "package trial\nfunc F() { var a int32 = 1; " + implicitLine + "; _ = b }"
	explicitSource := "package trial\nfunc F() { var a int32 = 1; " + explicitLine + "; _ = b }"

	fmt.Printf("%-38s %-13s length=%d\n", "implicit conversion", classifySource(implicitSource), len(implicitLine))
	fmt.Printf("%-38s %-13s length=%d\n", "explicit conversion", classifySource(explicitSource), len(explicitLine))
}
implicit conversion                    not compiled  length=15
explicit conversion                    compiled      length=22

The result looks reversed at first glance: the line that does not compile is shorter (15 characters), the line that does compile is longer (22 characters). Rejecting does not shorten the source — it does the opposite. Rather than assume a will be widened, the compiler wants that widening to be visible in the source, and it puts the cost of that on the party writing it: a seven-character call.

LB3. The unit measured here is the character, not runtime or memory; the claim is not “rejecting slows things down” but “rejecting does not shorten”. A fifteen-character difference seems small, but the same pattern repeats at every widening point in a source; the readability gain is bought with a fixed writing cost per line.

The four do not all point the same direction. The fix for the unused import is a deletion — the line disappears from the source entirely and the source shortens. The fix for the implicit type conversion is an addition — a call wraps an existing line and the source grows. Both rejections give the same class of result (not compiled), but the direction of the fix is opposite. This breaks the impression that “rejecting” carries a single writing cost: some rejection reasons push the source toward simplification, others push it toward writing intent explicitly; the only thing they share is that neither leaves an ambiguous range in the source.

The same holds for the fourth rejection (unused variable) in exactly the same way: deleting the line x := 1 shortens the source, but using x somewhere (writing _ = x) does not shorten it either, it only adds a word. What stands in for rejection is never “write less”; it is either delete or write your intent explicitly. Simplicity, counted among Go’s design goals, is not aimed at code length but at what appears in the source: a reader who sees the line int64(a) knows the widening was deliberate; had they seen var b int64 = a, they could not have known that.

This reading closes the tension opened earlier. Simplicity and writing intent explicitly conflict at the level of the source’s length but not at the level of its rule count: the conversion rule the compiler recognizes is a single one — widening is always written — and this single rule removes entirely from the reader the question “in which case is an implicit conversion safe”. The reader does not have to separately ask whether an assignment is implicit or explicit, because the compiler already never lets the implicit one through. Simplicity here sits not in a shrinking character count but in a shrinking number of possible readings.

The link to compile speed is built from the same point. None of the four rejections wait for the rest of the source before deciding: an unused import is evident while that file alone is being read; an implicit conversion is seen just by looking at the type on either side of the assignment. Neither requires an inference dependent on what is happening elsewhere in the program. Duration is never measured in this lesson, but the locality of the rejection decisions — each looking only at its own expression — leaves an observable trace of where compile speed comes from.

Summary

  • The compiler is called from inside the program in this lesson and throughout the rest of the course; only whether it passes is read, the error text is never read.
  • An unused variable, an unused import, an unwritten return value, and an implicit type conversion — all four are harmless at runtime yet stop compilation.
  • The three reasons fall into three distinct classes: a name written but never read, a body that fails to keep its promise, a widening invisible in the source.
  • Rejecting the implicit conversion does not shorten the source: the line that fails to compile is 15 characters, its compiling counterpart is 22 characters.
  • Rejection is a decision that narrows what stands in for the unwritten — in these four cases, the language chooses to never allow it rather than put a defined value there.

Next Step

The four rejection reasons were about things a source writes in excess or leaves short of a promise; none of them was about the smallest set of things a source needs in order to run at all. The next lesson asks about that smallest set: which parts are mandatory for a source to compile and run, and what does removing one of them cost when it is written not in the source itself but in a module file?

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close