curayious Language Lab

Go — Deliberate Austerity

Why here, and why before Rust. Go is the cleanest possible contrast to Java 8 on the two axes that matter most in Arc III: what a concurrent program looks like, and what an error is. It is also easy enough that the concurrency model itself is the only hard thing in the track — which is exactly what you want before Rust, where ownership and concurrency arrive together and you need one of them to already be familiar.

Q1 · Origin and niche

Robert Griesemer, Rob Pike, and Ken Thompson at Google, announced 2009, 1.0 in 2012. The founding grievance is well documented and unusually specific: C++ build times at Google, and the complexity cost of a language whose feature set no single engineer held entirely in their head.

So Go is a language designed by subtraction. No inheritance. No exceptions. No generics until 1.18, thirteen years in, and then only reluctantly. No operator overloading, no implicit conversions, no ternary operator, and — famously — an unused import or variable is a compile error, not a warning.

The consistent theme: optimise for the reader, and specifically for the reader who joined the team last month and has to fix something today. That is a different target from Python's reader (who is writing the code now) and from Java's (who is maintaining 500k lines in five years), and the three languages are shaped accordingly.

Who pays for it today: cloud infrastructure, almost totally — Docker, Kubernetes, Terraform, Prometheus, etcd, Consul, and most of the CNCF landscape. Also CLI tooling, network services, and a growing share of backend APIs. Go took much of this territory from Python after 2013 for one unglamorous reason covered in Q2.

Q2 · Hello, build

go build -o build/logagg.exe .
go run . ../../bench/data/access.log
.\build.ps1

Three things here are the whole story of why Go spread through infrastructure:

A single static binary, cross-compiled trivially.

GOOS=linux GOARCH=arm64 go build -o logagg-linux-arm64 .

No runtime to install, no interpreter version, no JVM, no libc to match. Copy the file to the target and it runs. This is the reason kubectl and terraform are Go programs and not Python ones — the Python distribution problem covered in that track's Q2 is simply not a problem Go has.

Modules, and the toolchain that comes with them. go.mod is three lines. go test, go vet, gofmt, go doc, and the race detector are all in the box — there is no separate linter, formatter, test runner, or build tool to choose. Compare with the Java track's Maven-versus-Gradle decision and the Python track's five-way environment-manager table.

gofmt is not configurable. Formatting is not a matter of team preference because the tool does not accept preferences. An enormous category of code review argument simply does not exist in Go, and it is worth noticing how much of a language feature that turns out to be.

Q3 · Type and memory model

A variable is a typed slot holding a value, and — unlike Java — user-defined types can be values, not just references. Stats here could have been stored by value in the map; it is stored as *Stats so that s.add(...) mutates the real thing rather than a copy. That choice does not exist in Java and cannot be expressed in Python.

Static and strong, with no implicit conversion at all. int and int64 are different types and mixing them is a compile error. Verbose, and it makes SPEC 2's integer arithmetic unambiguous in a way it is not in C, where promotion rules quietly do work for you.

Garbage collected, with a collector tuned hard for low pause times rather than peak throughput — the opposite of the JVM's historical default, and the right trade for a network service.

The things Go gives you that Java does not:

Zero values are a design pillar: every type has a usable zero. A nil map reads as empty, a zero sync.Mutex is an unlocked mutex, a zero bytes.Buffer is an empty buffer. It removes a whole class of constructor ceremony — and it is why res.services[f[1]] returning nil for a missing key is idiomatic rather than a bug.

Q4 · The unit of abstraction

The package, and the interface — and the interface is the interesting half.

Go interfaces are structural and satisfied implicitly. A type implements an interface by having the methods. There is no implements keyword and, critically, the interface can be declared by the consumer after the fact:

type Reader interface { Read(p []byte) (n int, err error) }

Anything with that method is an io.Reader — including types written years before io.Reader existed, in packages that have never heard of it. Java's BufferedReader must declare its interfaces up front, so retrofitting a contract onto someone else's type requires an adapter. Go's approach is duck typing with compile-time checking, and it is the single most-copied idea in the language.

The convention that follows: "accept interfaces, return structs", and keep interfaces tiny. io.Reader has one method. error has one method. The Go proverb is "the bigger the interface, the weaker the abstraction" — which is the exact inverse of the instinct a Java codebase trains.

Composition instead of inheritance, via embedding: put a type in a struct without a field name and its methods are promoted. It looks like inheritance and is not — there is no virtual dispatch to a subclass and no super.

Q5 · The error model

Errors are values. No exceptions, no unwinding. error is an ordinary interface with one method, returned alongside the result:

res, err := aggregate(args[0])
if err != nil {
    fmt.Fprintf(os.Stderr, "logagg: cannot read %s: %v\n", args[0], err)
    return 12
}

The much-mocked if err != nil appears three times in this program. The case for it, which is stronger than the mockery suggests:

The cost is real: it is repetitive, and there is nothing stopping you writing res, _ := and discarding the error entirely. Go's protection is cultural plus go vet, not the type system — Rust's Result with #[must_use] is the same idea with teeth, and reading them in this order makes that obvious.

panic/recover exist and are not the error mechanism. They are for programmer error — index out of range, nil dereference — and a library that panics across its API boundary is considered broken.

Note that classify here returns a string, not an error, for the same reason it did in Java and Python: a malformed line is expected input, not a failure. Three tracks, three error models, same judgement. That convergence is worth more than any of the three mechanisms.

Q6 · The concurrency model

CSP: goroutines and channels. This is the reason Go is in Arc III and the reason to learn it at all.

A goroutine is a function call with go in front of it. It is not an OS thread — it starts at a ~2 KB growable stack and is multiplexed onto a small pool of OS threads by the runtime scheduler. A million of them is routine. A million Java 8 platform threads is not possible; this is precisely the gap Java 21's virtual threads were built to close, and the Java 21 track should be read directly against this section.

A channel is a typed, synchronised queue. The slogan — "do not communicate by sharing memory; share memory by communicating" — means the default answer to "how do these two pieces of work coordinate" is a channel, not a mutex.

For --shard N (SPEC 6) the Go version is genuinely the shortest of any track:

partials := make(chan map[string]*Stats, n)
for i := 0; i < n; i++ {
    go func(chunk []string) { partials <- aggregateChunk(chunk) }(chunks[i])
}
for i := 0; i < n; i++ { merge(total, <-partials) }

No lock, because no memory is shared — each goroutine owns its map until it hands it over.

select waits on several channels at once and is what makes timeouts and cancellation expressible without a callback. context.Context is built on it and is how cancellation propagates through every real Go codebase.

What Go makes easy to get wrong: goroutine leaks. A goroutine blocked forever on a channel nobody will write to is not collected and not reported. It is the Go equivalent of a memory leak, and context plus disciplined channel closing is the defence. Run anything concurrent under go test -race — the race detector is excellent and built in.

Q7 · The idiom test — logagg

logagg.go · lab notes in NOTES.md

~250 lines — between Python's 190 and C's 330, and notably longer than Java 8's 210 despite Go being the simpler language. Where the lines go:

Java 8 Go
Map insert-or-create computeIfAbsent(k, Stats::new) 5 lines: read, check nil, construct, store
Sort Comparator.comparingLong(...).reversed().thenComparing(...) sort.Slice with an explicit two-branch closure
Error handling one try block if err != nil × 3

Go has fewer conveniences on purpose. The trade is that every one of those Go lines says exactly what it does, and none of them required knowing which of thenComparing's two overloads the compiler would pick.

Three things Go made easier than every other track:

No CRLF problem. Go has no text mode; os.Stdout.WriteString writes the bytes given. The C track needed _setmode, Java needed System.out.write(bytes), Python needed sys.stdout.buffer. Go needed nothing, and that is a deliberate design position about platform differences rather than an accident.

strings.Fields is SPEC 1's whitespace rule exactly, same as Python's str.split().

Map iteration is randomised, not merely unspecified:

sort.Slice(ordered, func(i, j int) bool { ... })

Go actively shuffles map iteration order on every run so that a program depending on it fails on your laptop immediately rather than in production in two years. Without SPEC 3's mandatory tie-break this program's output would differ between consecutive runs of the same binary — which is the single best argument for that clause in the spec, and it only became visible in this track.

One structural thing worth copying into other languages:

func main() { os.Exit(run(os.Args[1:])) }

os.Exit does not run deferred functions. Calling it from inside aggregate would skip defer fh.Close() silently. Keeping os.Exit to exactly one line, with all the work in a function that returns a code, is the standard Go answer and a genuinely good habit everywhere.

Q8 · How it fails

  1. The nil interface that is not nil. A nil *MyError stored in an error interface makes err != nil true, because the interface holds a type and a value and only the value is nil. This is Go's worst wart, it has caused real outages, and go vet does not catch every case.
  2. Goroutine leaks. Covered above. No warning, no error, just a process that grows.
  3. Loop variable capture. Before Go 1.22, for _, v := range xs { go func() { use(v) }() } captured one shared v and every goroutine saw the last value. Fixed in 1.22 by making the variable per-iteration — worth knowing because an enormous amount of pre-1.22 code and tutorial material is still out there.
  4. Slice aliasing via append. append may or may not allocate. Two slices backed by the same array, one appended to, is either aliasing or not depending on capacity. The failure shows up as data mysteriously changing.
  5. bufio.Scanner's 64 KB token limit. Silently stops on a long line and sets sc.Err() to bufio.ErrTooLong — which you only see if you check it. logagg.go does check; a great deal of production Go does not.
  6. Unhandled errors by convention only. res, _ := doThing() compiles. errcheck and go vet help; nothing enforces it.

Q9 · Read real code

Recommended: the standard library's net/http/server.go, specifically (*conn).serve. It is the canonical example of one-goroutine-per-connection, and it shows the real-world shape of defer, panic recovery at a boundary, and context cancellation in code that has been beaten on by everyone.

Shorter, and read it first: sort/sort.go and errors/wrap.go. errors/wrap.go is about forty lines and is the entire error-wrapping mechanism — Is, As, Unwrap. Reading it makes %w stop being magic.

For the concurrency model specifically: sync/errgroup (in golang.org/x/sync). Small, and it is the pattern every Go codebase reaches for when it needs the --shard N structure.

Q10 · What transfers


Status

done. gofmt -l clean, go vet clean, and byte-exact against the golden file on the first compile — the only track so far where that happened, for the reasons in NOTES.md.

PASS  c
PASS  java8
PASS  go
PASS  python

Outstanding: --shard N (SPEC 6), which for this track is the actual content rather than an extra, and a repo-level fixture gap — access.log has no tab-separated line, so no track's whitespace handling is exercised against one.