Go Basics
Syntax, zero values, constants and iota, control flow, defer and closures
- 01
You split a working
main.gointo astorepackage and the build now fails withundefined: store.parseRow, even though the function is right there in the file you moved. What rule are you hitting?EasyGo's only access control is the case of the identifier's first letter —
parseRowstarts lowercase, so it is visible everywhere inside packagestoreand nowhere outside it.// store/row.go — one directory, one package; every file in it sees the others. package store type Row struct { ID int // exported: other packages and encoding/json can see it name string // unexported: invisible outside package store, and to json … - 02
A reviewer asks why your
Serverliteral sets two of its six fields and the code still runs. What did Go put in the other four, and which of those are usable as they stand?EasyGo initialises every declaration it compiles — there is no uninitialised variable in the language — so the other four hold their zero value; the real question is which zero values are ready to use and which are merely
nil.type Server struct { Addr string // "" Timeout time.Duration // 0 mu sync.Mutex // usable with no constructor buf bytes.Buffer // ditto tags []string // nil … - 03
version := "1.0"at the top of a file is a syntax error while the identical line insidemaincompiles. Where is each declaration form legal, and when must you still reach forvar?EasyThe short variable declaration
:=is legal only inside a function body; at package scope every declaration has to begin with a keyword —var,const,funcortype.// ✅ package scope: every declaration starts with a keyword. // `build := "dev"` out here is a syntax error. var build = "dev" const maxRetries = 3 … - 04
Go has exactly one loop keyword. Name the shapes it takes, and say which one refuses to compile in a module whose
go.modstill saysgo 1.21.Easyforcovers all of them — three-clause, condition-only, infinite andrange— and the shape that needs a newer module isfor i := range 10, the integer range added in Go 1.22.// 1. three-clause — init, condition and post are each optional for i := 0; i < len(rows); i++ { use(rows[i]) } // 2. condition only — this is Go's while … - 05
go run .is fine on your laptop, but the binary your Dockerfile builds will not start at all in ascratchimage. What dogo run,go buildandgo installeach actually do, and what is missing from that build?MediumThe three commands compile identically and differ only in where the result lands — a temp directory
go runthen deletes, the current directory forgo build,$GOBINforgo install— and thescratchfailure is cgo: the defaultCGO_ENABLED=1links against the host's libc, which ascratchimage does not contain.// cmd/api/main.go — only a package named main produces an executable. package main // Stamped by the linker, never by hand: // go build -trimpath -ldflags="-s -w -X main.version=$(git rev-parse --short HEAD)" \ // -o bin/api ./cmd/api … - 06
A production log line reads
user=%!s(int=42) ok=%!v(MISSING). Reconstruct thePrintfcall from those two markers, and say which tool should have caught it.Mediumfmtnever panics on a bad format — it writes the error into the output — so%!s(int=42)is a%shanded anintand%!v(MISSING)is a verb with no argument left: the call wasfmt.Printf("user=%s ok=%v", u.ID).u := User{ID: 42, Name: "ada"} // ❌ every one of these is a go vet finding, and every one of them still runs fmt.Printf("user=%s ok=%v\n", u.ID) // user=%!s(int=42) ok=%!v(MISSING) fmt.Printf("%d\n", u.ID, u.Name) // 42, then %!(EXTRA string=ada) fmt.Printf("100%") // 100%!(NOVERB) … - 07
const timeout = 30followed bytime.Sleep(timeout * time.Second)compiles. Change the first line tovar timeout = 30and the build breaks. What is different about the two?Mediumconst timeout = 30is an untyped constant: it has a default type but no actual type until it is used, so at the multiplication it becomes atime.Duration.type State uint8 const ( StateUnknown State = iota // 0 — the zero value; make it the honest default StatePending StateRunning … - 08
pct := done / total * 100logs 0 when 3 of 4 items are done, andfloat64(done/total) * 100logs 0 as well. What is Go doing, and what is the rule behind it?MediumBoth operands are
int, so/is integer division and truncates toward zero before anything else runs —3/4is already0by the time the conversion sees it.done, total := 3, 4 pct := done / total * 100 // ❌ 0 — integer division truncates first bad := float64(done/total) * 100 // ❌ 0 — the conversion is one step too late good := float64(done) / float64(total) * 100 // ✅ 75 … - 09
Inside
for _, job := range jobs { switch job.Kind { case "stop": break ... } }thebreaknever ends the loop, and everything after the stop marker is processed anyway. Why, and what are the ways out?Mediumbreakbinds to the innermostfor,switchorselect— inside a case it only leaves theswitch, which was about to end anyway.// ❌ break leaves the switch, which was ending anyway; the loop keeps going for _, job := range jobs { switch job.Kind { case "stop": break default: … - 10
A loop that opens 4 000 files with
defer f.Close()in its body dies withtoo many open files, and thedefer fmt.Println("took", time.Since(start))on line two of the same function always reports a few microseconds. What are the two rules at work?MediumA deferred call's arguments are evaluated at the
deferstatement while the call itself runs when the enclosing function returns — sotime.Since(start)was computed immediately, and all 4 000Closecalls queued up until the function returned.func timed() { start := time.Now() defer fmt.Println("took", time.Since(start)) // ❌ evaluated NOW: ~19µs defer func() { fmt.Println("took", time.Since(start)) }() // ✅ read at return work() } … - 11
func scale(factor int, xs ...int) []intmutates the caller's slice when it is called asscale(2, a...)but not asscale(2, 1, 2, 3). What is different about the two call sites?MediumPassing a slice with
s...does not copy it — the variadic parameter is that slice, sharing its backing array — whereas the value-by-value form makes the compiler build a fresh slice the callee owns.func scale(factor int, xs ...int) []int { for i := range xs { xs[i] *= factor // writes straight into whatever slice the caller passed } return xs } … - 12
applyDefaults(cfg Config)sets three fields and the caller sees none of them. What is Go's argument-passing rule, and why does the fix use&Config{}rather thannew(Config)?MediumGo passes everything by value, so the function mutated a copy; it has to take
*Config.type Config struct { Addr string Timeout time.Duration } // ❌ cfg is a copy; the caller sees none of this … - 13
A retry helper logs the error from every attempt and still hands the caller
nil. The only odd-looking line isif resp, err := http.Get(u); err != nil {. What did that:=do, and why is the build clean?HardThe
:=in theifheader declared newrespanderrvariables scoped to thatifstatement, so the outererrthe function returns was never assigned.// ❌ returns nil for every url, and logs a real error for every one of them func fetch(urls []string) (err error) { for _, u := range urls { // := declares a NEW resp and a NEW err, scoped to this if statement if resp, err := http.Get(u); err != nil { log.Printf("attempt %s: %v", u, err) // the inner err — used, so no warning … - 14
A reviewer says
func read(p string) (data []byte, err error)is swallowing the error from its deferredf.Close()and that the barereturnat the bottom will bite you. What single mechanism explains both remarks?HardNamed results are ordinary variables, zero-initialised at function entry and assigned by
returnbefore the deferred calls run — which is how a deferred closure can rescueClose's error, and equally how a barereturnships something you never meant to send.// ✅ the name exists so the deferred closure has somewhere to put Close's error func write(path string, data []byte) (err error) { f, err := os.Create(path) if err != nil { return err } … - 15
for _, u := range users { go audit(&u) }audited the same user three times on Go 1.21 and audits three different ones on Go 1.22. A teammate on the same toolchain still sees the old behaviour. What changed, and what is gating it?HardBefore Go 1.22 the
rangevariable was one variable reused by every iteration, so every closure and every&upointed at the same storage; 1.22 gives each iteration its own copy, and the switch is gated on thegoline in that module'sgo.mod, not on the toolchain you build with.users := []User{{ID: 1}, {ID: 2}, {ID: 3}} var jobs []func() for _, u := range users { jobs = append(jobs, func() { fmt.Print(u.ID, " ") }) } … - 16
A package-level
var cfg = mustLoad()reads an environment variable that another package'sinit()sets. It works on your machine and comes back empty in the container. What is the real initialisation order, and why is this broken either way?HardWithin a package, variables are initialised in dependency order, not source order, then every
init()in filename order, and an imported package is fully initialised before the package that imports it.// ❌ package-level work: runs on import, before flags are parsed, inside every // test binary that reaches this package, with no error path but panic. var cfg = mustLoad(os.Getenv("CONFIG_PATH")) // dependency order, not source order: b is initialised first, so a is 3 var a = b + 1 …