Skip to content
Preptima

Go fundamentals

The answers a Go interviewer works through before the hard questions start: the type system and embedding, slice and map internals, interface values, the scheduler, channels and context, and errors as values. Sixty items, nineteen of them worked through with code or a diagram.

60 questions

Go deeper on Go
Standard Sheet View

The type system and values

Why does Go have no inheritance?

Because implementation inheritance couples a subclass to its parent's internals, and the designers judged that cost higher than the reuse it buys. A subclass that overrides one method has to know which other methods call it, so the parent cannot be refactored without breaking children, and the hierarchy ends up being edited from the middle rather than extended at the leaves. Go replaces it with two narrower mechanisms: composition through embedding, which reuses behaviour without claiming an is-a relationship, and interfaces satisfied implicitly, which give you polymorphism without a declared hierarchy at all. The practical consequence is that there is no super, no protected members, no abstract base class, and no place to put a template method — and the honest answer to "how do I share this code" is usually a plain function or a field, not a type relationship.

What does struct embedding actually do?

It is field promotion with a compiler-generated forwarding rule, not inheritance. The embedded type becomes a real field named after the type, and its exported fields and methods can be reached without naming that field, so s.Logger.Log may also be written s.Log.

type Logger struct{ Prefix string }

func (l Logger) Log(msg string) { fmt.Println(l.Prefix, msg) }

type Server struct {
    Logger        // embedded: a field literally named Logger
    Addr string
}

s := Server{Logger: Logger{Prefix: "srv"}, Addr: ":8080"}
s.Log("starting")     // promoted; identical to s.Logger.Log("starting")

The part that separates embedding from inheritance is what happens when the embedded type calls a method the outer type has "overridden".

type Logger struct{ Prefix string }

func (l Logger) Log(msg string)   { fmt.Println(l.Prefix, msg) }
func (l Logger) Warn(msg string)  { l.Log("WARN " + msg) }  // binds to Logger.Log

type Server struct{ Logger }

func (s Server) Log(msg string) { fmt.Println("server:", msg) }

Server{}.Warn("disk full")   // prints via Logger.Log, NOT Server.Log

There is no dynamic dispatch back into the outer type. Logger.Warn was compiled against Logger.Log and stays bound to it, so the "override" is invisible from inside the embedded type. That is the whole difference: embedding delegates outward, inheritance dispatches inward, and code ported from a language with virtual methods breaks here silently.

Two further details get asked about. Promotion is shallowest-wins, so a field or method declared directly on the outer struct shadows the embedded one, and two embedded types offering the same name at the same depth is not an error until something actually uses the ambiguous name — at which point it is a compile error, and you disambiguate by naming the field. Embedding an interface rather than a struct is the idiom behind partial implementations: embed io.Reader in a struct and the struct satisfies io.Reader by forwarding to whatever was stored, which is how test doubles are built with three lines instead of twenty.

Value or pointer receiver — how do you choose?

A value receiver gets a copy, so it cannot mutate the original and it costs a copy of the struct on every call. A pointer receiver gets the address, so it can mutate and copies only a word. The rule that actually decides it: use a pointer receiver if the method mutates the receiver, if the struct is large enough that copying it matters, or if the type contains a sync.Mutex or anything else that must not be copied. Otherwise a value receiver is fine and marginally safer. The stronger rule on top of that is consistency — do not mix receiver kinds on one type, because the method set rules then make it non-obvious which values satisfy which interfaces, and readers have to check each method individually to know whether they are holding a copy.

Why does Go have zero values instead of constructors?

Because making every declared variable immediately usable removes an entire class of half-initialised object. A struct is zeroed field by field, so an int is 0, a string is "", a pointer, slice, map, channel, function and interface are all nil, and the design goal is that the zero value should be useful without further work. sync.Mutex locks correctly when zero, bytes.Buffer is a working buffer when zero, and appending to a nil slice returns a valid one-element slice. The cost is that "unset" and "legitimately zero" are indistinguishable, which is why a count of 0 and an absent count look the same in a decoded JSON struct, and why API types reach for pointers or an explicit Valid bool when the difference matters. The other cost is nil maps: reading from one works, writing to one panics.

What is the difference between a defined type and a type alias?

type Celsius float64 defines a new type with float64 as its underlying type: it is a distinct type for assignment and method sets, so you cannot pass a Celsius where a float64 is expected without a conversion, and you can hang methods on it. type Celsius = float64 is an alias, meaning the two names refer to the same type entirely — no conversion needed, no separate method set, and declaring a method on the alias is exactly declaring it on float64, which is forbidden for a type from another package. Defined types are how you buy type safety for a bare string identifier or a unit of measure. Aliases exist almost entirely for gradual code migration, where a type moves between packages and the old name must keep compiling.

Are `new` and `make` interchangeable?

No, and the difference is what they initialise. new(T) allocates zeroed storage for a T and returns a *T, which is useful for any type but leaves slices, maps and channels in their nil state — so m := new(map[string]int) gives you a pointer to a nil map, and writing through it panics. make exists for exactly those three types, because each has a runtime header that needs setting up: a slice's pointer, length and capacity; a map's hash table; a channel's buffer and wait queues. make returns the type itself rather than a pointer, and it takes size arguments that new has no way to express. In practice new is rare in idiomatic Go, because &T{} says the same thing and lets you set fields at the same time.

Show me the method set rule that breaks interface satisfaction.

A pointer's method set includes both pointer and value receivers; a value's method set includes only the value receivers. So a type whose method has a pointer receiver is satisfied by *T, never by T.

type Stringer interface{ String() string }

type Point struct{ X, Y int }

func (p *Point) String() string { return fmt.Sprint(p.X, ",", p.Y) }

var _ Stringer = &Point{}   // compiles
var _ Stringer = Point{}    // cannot use Point literal as Stringer:
                            // String method has pointer receiver

The reason is addressability rather than pedantry. Calling a pointer-receiver method needs an address to take, and an interface holds a copy of the value it was given — a copy stored inside an interface is not addressable, so the compiler has nowhere to take the address from. When you call p.String() on a local variable the compiler silently inserts (&p), which is why the method call works on a variable and the interface assignment does not.

p := Point{1, 2}
p.String()                  // fine: compiler rewrites to (&p).String()

m := map[string]Point{"a": {1, 2}}
// m["a"].String()          // fails: map elements are not addressable

The failure mode in real code is a String or Error method with a pointer receiver and a value passed to fmt.Println or returned as an error. Nothing fails to compile at the call site, because fmt accepts any — the method simply is not found at run time, and you get the default struct formatting instead of your carefully written one. MarshalJSON with a pointer receiver on a value stored in a slice produces the same silent wrong output.

The habit that prevents it is consistency: pick one receiver kind per type and use it for every method. The compile-time guard is a blank assignment, var _ Stringer = (*Point)(nil), placed next to the type, which turns a run-time surprise into a build failure the moment the method set stops matching.