Saltar a contenido

05 - Structs, Methods, and Interfaces

What this session is

A long one - about ninety minutes. You'll learn the three constructs that make Go feel like Go: structs (group related data), methods (functions attached to a type), and interfaces (a contract that says "if your type has these methods, you can use it anywhere this kind of thing is expected"). These three together are what people mean when they say Go is "lightweight object-oriented" - there are no classes, no inheritance, but you can still model real things and write code that's flexible and testable.

This chapter is denser than the others. If it feels like a lot, take the main flow first; the Going deeper section at the end is for after you've used the material once or twice.

The problem this solves

So far, every piece of data you've worked with has been a single value - one int, one string, one bool. Real things have many properties at once. A person has a name, an age, a city. A point on a graph has an x and a y. A rectangle has a width and a height.

You could pass each property as a separate parameter:

func describe(name string, age int, city string) {
    fmt.Printf("%s, age %d, lives in %s\n", name, age, city)
}

That works for two or three. At six, you're sad. At twelve, you're lost. A struct lets you bundle them up.

A struct

package main

import "fmt"

type Person struct {
    Name string
    Age  int
    City string
}

func main() {
    alice := Person{Name: "Alice", Age: 30, City: "Lagos"}
    fmt.Println(alice)
    fmt.Println(alice.Name)
    fmt.Println(alice.Age)
}

Type it. Run it. You'll see:

{Alice 30 Lagos}
Alice
30

What's new:

  • type Person struct { ... } - defines a new type called Person. It's a struct (a bundle of fields). The fields are listed inside the braces, one per line, each with a name and a type.
  • Person{Name: "Alice", Age: 30, City: "Lagos"} - creates a value of type Person, with the listed fields set. This is called a struct literal.
  • alice.Name - dot notation to read a field. Works the same way to write: alice.Name = "Alicia" changes the name.

Naming convention: types are CapitalCase. Field names are also CapitalCase if you want them visible outside the package (more on that in page 11). For now, capitalize everything.

Structs as function parameters and returns

A struct value is just like any other value - you can pass it to functions and return it from them:

package main

import "fmt"

type Point struct {
    X, Y int
}

func origin() Point {
    return Point{X: 0, Y: 0}
}

func describe(p Point) {
    fmt.Printf("at (%d, %d)\n", p.X, p.Y)
}

func main() {
    o := origin()
    describe(o)
    describe(Point{X: 3, Y: 4})
}

Notice X, Y int - when adjacent fields share a type, you can group them on one line. Same idea as parameters.

Methods: functions attached to a type

A method is a function that belongs to a type. The only difference from a regular function is the receiver - an extra parameter declared before the function name.

package main

import (
    "fmt"
    "math"
)

type Point struct {
    X, Y float64
}

func (p Point) DistanceFromOrigin() float64 {
    return math.Sqrt(p.X*p.X + p.Y*p.Y)
}

func main() {
    p := Point{X: 3, Y: 4}
    fmt.Println(p.DistanceFromOrigin())   // 5
}

The line:

func (p Point) DistanceFromOrigin() float64 {

reads as: "define a method called DistanceFromOrigin. It's attached to the Point type. Inside the method, the value the method was called on is named p."

So p.DistanceFromOrigin() calls the method with p as the receiver.

New thing in this example: float64 is a type for numbers with decimal places. math.Sqrt takes a float64. We changed Point.X and Point.Y from int to float64 so it works.

You could have written this as a regular function:

func distanceFromOrigin(p Point) float64 { ... }

That's not wrong. But methods read better at the call site:

p.DistanceFromOrigin()        // method
distanceFromOrigin(p)         // regular function

The method form puts the most important thing (the point) first. As programs get bigger, this matters.

Value vs pointer receivers (the mental model)

There's a second form of receiver, with a * in front of the type:

func (p *Point) MoveBy(dx, dy float64) {
    p.X += dx
    p.Y += dy
}

This is a pointer receiver. The previous one (func (p Point) ...) is a value receiver.

The simple rule: a value receiver gets a copy; a pointer receiver gets the original.

Why this matters - concrete demo:

type Counter struct{ Value int }

func (c Counter)  IncByValue()   { c.Value++ }     // operates on a copy
func (c *Counter) IncByPointer() { c.Value++ }     // operates on the original

func main() {
    c := Counter{}
    c.IncByValue()
    c.IncByValue()
    fmt.Println(c.Value)     // 0 - both increments mutated a copy and threw it away

    c.IncByPointer()
    c.IncByPointer()
    fmt.Println(c.Value)     // 2 - both increments wrote through to c
}

You'll meet pointers in full on page 08. For now, the practical rules of thumb:

  • If the method changes a field of the receiver → use *Type. Otherwise the change won't stick.
  • If the receiver is a big struct (lots of fields, or fields that are themselves big) → use *Type even if you don't modify it. A pointer is one machine word; copying the whole struct would be wasted work.
  • If the receiver is small and immutable → either works. Pick one and use it for all methods on that type - consistency matters more than the choice itself.

When in doubt: use *Type. Real Go code uses pointer receivers around 80% of the time.

A bit of magic that makes this nicer: when you call c.IncByPointer() and c is a regular value (not a pointer), Go silently rewrites the call to (&c).IncByPointer() for you. The & is inserted automatically if c is addressable - that is, if it has a name (a variable, a struct field of a variable, an array element of a variable). This is why both c.IncByValue() and c.IncByPointer() look the same at the call site, even though the second technically requires a pointer. There's a corner case where this doesn't work - when the value is non-addressable, like a struct returned by a function - but you'll meet that rarely.

Try it: 1. Type the Counter example. Confirm the output is 0 then 2.

  1. Try Counter{Value: 5}.IncByPointer() directly. Compile error: "cannot take the address of Counter{Value: 5}" - the struct literal has no name, so it's non-addressable. You can't take its address, so the auto-& insertion can't happen. Assign it to a variable first.

  2. Change IncByValue to also return c (so it returns the copy after incrementing). Call c = c.IncByValue(). Now the change sticks - because you're explicitly assigning the returned copy back. This is how append works, by the way.

Methods on non-struct types

Methods aren't only for structs. You can define a method on any named type declared in the same package - including a named alias of int, string, float64, even a function type.

package main

import "fmt"

type ByteSize float64

const (
    _           = iota
    KB ByteSize = 1 << (10 * iota)
    MB
    GB
)

func (b ByteSize) String() string {
    switch {
    case b >= GB:
        return fmt.Sprintf("%.2f GB", b/GB)
    case b >= MB:
        return fmt.Sprintf("%.2f MB", b/MB)
    case b >= KB:
        return fmt.Sprintf("%.2f KB", b/KB)
    }
    return fmt.Sprintf("%d B", int(b))
}

func main() {
    var size ByteSize = 5_000_000
    fmt.Println(size)              // "4.77 MB"
}

That's a lot of new things in one example. Let's walk through it line by line so none of it is magic.

type ByteSize float64 - defines a brand-new type that has the same underlying representation as float64 but is a distinct type from the compiler's point of view. You can't add a ByteSize to a float64 without an explicit conversion - the compiler treats them as separate, even though they're the same shape in memory. This is exactly what lets us attach a method to it: methods can only be defined on named types declared in the current package, and float64 doesn't qualify (it's a built-in).

What is iota? Inside a const ( ... ) block, iota is a special built-in identifier that starts at 0 on the first line and increments by 1 on every subsequent line. That's the whole rule. So:

const (
    a = iota   // a = 0
    b = iota   // b = 1
    c = iota   // c = 2
)

It's just a line-numbered counter, scoped to one const block. The first time iota shows up in a fresh const block, it's 0; the next line, 1; and so on. Go doesn't have an enum keyword - iota is how you build one.

Why is the first line _ = iota? The _ (blank identifier) means "throw this value away." We're consuming the line iota = 0 deliberately, because we don't want KB to be on the first line - if KB were on the first line, its calculation would use iota = 0, and we'll see in a second why that would give us the wrong number.

What does 1 << (10 * iota) actually compute? << is the bit-shift left operator. 1 << n means "take the number 1 and shift its bit n places to the left," which (for non-negative n) is exactly the same as 2^n. So:

  • 1 << 10 is 2^10 which is 1024.
  • 1 << 20 is 2^20 which is 1,048,576 (about 1 million).
  • 1 << 30 is 2^30 which is 1,073,741,824 (about 1 billion).

Those three numbers are exactly 1 KB, 1 MB, and 1 GB in binary units (the "real" units a computer uses, as opposed to the decimal 1,000 / 1,000,000 / 1,000,000,000 that hard-drive marketing uses). Now apply the iota counter:

  • Line _ = iota -> iota = 0. Value discarded. (If we had written KB = 1 << (10 * 0) here we'd get KB = 1, not KB = 1024 - that's the line we wanted to skip.)
  • Line KB ByteSize = 1 << (10 * iota) -> iota = 1, so KB = 1 << 10 = 1024.
  • Line MB -> iota = 2, so MB = 1 << 20 = 1,048,576.
  • Line GB -> iota = 3, so GB = 1 << 30 = 1,073,741,824.

Wait - MB and GB don't have an = sign. How does Go know what they equal? A second Go rule for const blocks: if a line omits its right-hand side, Go silently repeats the previous line's expression (verbatim - including the iota reference, which has advanced). So MB re-uses 1 << (10 * iota) with iota now equal to 2, and GB re-uses it again with iota = 3. This "repeat the previous expression" rule is why iota-based enums in Go are so compact: you write the formula once on the first real line, and every subsequent line silently re-applies it with a fresh iota.

Three rules, working together: (1) iota counts lines in a const block; (2) 1 << n is 2^n; (3) an omitted RHS repeats the previous expression. That's the entire trick - no magic.

The String() string method. A method named exactly String with signature () string is special: it makes the type satisfy a built-in interface called fmt.Stringer. We haven't talked about interfaces yet (next section!), but here's the punchline now: whenever you pass a value to fmt.Println, fmt.Printf("%v", ...), or any of the fmt family, the package checks at runtime "does this value have a String() method?" If yes, it calls that method to get the printable form. If no, it falls back to the default formatting.

That's why fmt.Println(size) prints "4.77 MB" and not the raw float 4.76837158203125e+06: size is a ByteSize, ByteSize has a String() method, so Println calls it and prints whatever it returns.

And the "4.77 MB" itself? size is 5,000,000. The switch in String() checks: is 5,000,000 >= GB (about a billion)? No. Is it >= MB (about a million)? Yes. So we return fmt.Sprintf("%.2f MB", b/MB) -> 5,000,000 / 1,048,576 = 4.768... -> formatted as "4.77 MB". The division is real number division because ByteSize is float64 underneath, which is why we get .77 and not just an integer.

You can even define methods on a function type - Go's standard library uses this pattern in http.HandlerFunc:

type HandlerFunc func(http.ResponseWriter, *http.Request)

func (f HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    f(w, r)
}

A HandlerFunc is a function, but it also has a method (ServeHTTP). Why? So a plain function can be passed wherever an http.Handler interface is expected. We'll see the trick in detail in Going deeper.

The only restriction: the receiver's base type must be defined in the same package as the method. You can't add methods to int (a built-in) or to types from other packages (time.Time, bytes.Buffer, etc.). Define your own named type if you want to extend something.

Try it:

  1. Run the ByteSize example. Try printing ByteSize(500), ByteSize(2_000_000_000). The output adapts to scale.

  2. Try var size ByteSize = 1.5; var f float64 = size. Compile error: cannot use size (type ByteSize) as type float64. You need an explicit conversion: f := float64(size). The distinct-type rule has teeth.

  3. Define type WordCount int with a method Plural() string that returns "words" if the count != 1 and "word" otherwise. Use it: fmt.Println(WordCount(3).Plural()).

Interfaces: the contract

Here's the question interfaces solve: how do you write a function that works with anything that can do a certain thing, without knowing what that thing is?

Example. You want a function that writes some text "somewhere." Maybe a file. Maybe the terminal. Maybe a network connection. Maybe a memory buffer (for testing). They're all completely different concrete types, but they all support the operation "write some bytes." You want one function that takes any of them.

That's an interface. An interface is a contract that says "anything that has these methods can be used here."

package main

import "fmt"

// Any type with a Bark() method satisfies this interface.
type Barker interface {
    Bark() string
}

type Dog struct{ Name string }
func (d Dog) Bark() string { return d.Name + " says woof!" }

type Fox struct{ Name string }
func (f Fox) Bark() string { return f.Name + " says ???" }

// makeNoise accepts ANY type that satisfies Barker.
func makeNoise(b Barker) {
    fmt.Println(b.Bark())
}

func main() {
    makeNoise(Dog{Name: "Rex"})       // Rex says woof!
    makeNoise(Fox{Name: "Renard"})    // Renard says ???
}

Barker is a contract: "you can use anything as long as it has a Bark() string method." Dog has one. Fox has one. Both can be passed to makeNoise. makeNoise doesn't know or care about the concrete type - it just calls .Bark().

That's it. That's the whole idea. An interface is a behavior specification, and any type with the right methods automatically satisfies it.

Two pieces of syntax:

  • type Barker interface { Bark() string } - declares the interface type, listing the methods it requires.
  • func makeNoise(b Barker) - uses Barker like any other type. Inside the function, b is an interface value; only the methods listed in the interface are available on it.

The one-method -er naming convention is universal in Go: io.Reader, io.Writer, fmt.Stringer, sort.Interface, error. When an interface has one method, name the interface after the method plus -er.

Try it: 1. Type the example. Confirm it prints both lines.

  1. Add a type Cat struct{ Name string } without a Bark() method. Try makeNoise(Cat{Name: "Whiskers"}). Compile error: "Cat does not implement Barker (missing Bark method)". The compiler checks at compile time, before your program runs.

  2. Add a func (c Cat) Bark() string { return c.Name + " meows" }. Now makeNoise(Cat{...}) works. The cat satisfies Barker purely because it has the right method - even though we never wrote anywhere that Cat "is a" Barker.

Implicit satisfaction: the Go way

The previous example showed something quietly profound: Cat satisfied Barker automatically. We never wrote class Cat implements Barker (Java), nor class Cat : public Barker (C++). The Cat type and the Barker interface might even be in different packages, written by different people, who never heard of each other.

This is called implicit interface satisfaction, and it's the central Go design choice for code reuse. The rule:

A type satisfies an interface as soon as it has all the methods the interface requires. No declaration is needed.

Three big consequences:

1. You can define an interface for a type you didn't write. Maybe you import a package with a type pdf.Document that has a Title() string method. You can define your own type Titled interface { Title() string } and use it to accept pdf.Document (and anything else with a Title() string). You don't need access to the original code.

2. You can add a new method to your own type and it automatically starts satisfying every interface that wants that method. The connections are made by the compiler at the points where you assign a concrete type to an interface variable.

3. Small interfaces become normal. Because you don't have to design hierarchies up front, real Go code is dotted with tiny one- or two-method interfaces, each precise about what it requires. io.Reader has one method. error has one method. fmt.Stringer has one method. The standard library's most flexible types satisfy dozens of different one-method interfaces.

Contrast with how you'd write this in Java:

// Java: every type that wants to be Barker must explicitly declare it.
public interface Barker {
    String bark();
}

public class Dog implements Barker {     // explicit "implements"
    public String bark() { return "woof"; }
}

In Java, Dog must know about Barker at the moment it's defined. In Go, the relationship goes the other way: Barker (or any future interface) can discover Dog later, just by needing a method Dog happens to have. The decoupling is the win.

Try it: 1. Look at the standard library's fmt.Stringer:

type Stringer interface { String() string }
Any type with a String() string method satisfies it. You already defined one - ByteSize has String(). So ByteSize satisfies fmt.Stringer automatically - even though ByteSize lives in your package and Stringer lives in fmt.

  1. That's how fmt.Println(size) printed "4.77 MB" earlier. The fmt package internally does if s, ok := v.(Stringer); ok { return s.String() }. Your ByteSize.String() got called because ByteSize happens to satisfy Stringer.

  2. The error type you've been using is also an interface: type error interface { Error() string }. Same mechanism. Any type with Error() string is an error.

A practical example: fmt.Stringer

fmt.Stringer is the most common interface a beginner runs into. It's used by the fmt package to ask types "how do you want to be printed?" Implement it once, and fmt.Println, fmt.Printf with %v, fmt.Sprintf, and string interpolation all use your version.

package main

import "fmt"

type Money struct {
    Amount   int64   // in cents
    Currency string
}

// By giving Money a String() method, we satisfy fmt.Stringer.
// fmt.Println(m) will now call this method.
func (m Money) String() string {
    return fmt.Sprintf("%s %.2f", m.Currency, float64(m.Amount)/100)
}

func main() {
    price := Money{Amount: 1999, Currency: "USD"}
    fmt.Println(price)               // USD 19.99
    fmt.Printf("paid %v\n", price)   // paid USD 19.99
}

We never imported fmt.Stringer. We never wrote Money implements Stringer. We just defined a method with the right name and signature, and the fmt package's runtime check found us. This is implicit satisfaction at work.

Try it: 1. Run the example. Confirm both prints show USD 19.99.

  1. Comment out the String() method. Re-run. Now fmt.Println(price) prints {1999 USD} - the default struct formatting. The fmt package looked for Stringer, didn't find it, fell back.

  2. Define type Username string with a String() method that returns "@" + string(u). Print one. Notice: a Username is a string underneath but Stringer controls how it prints.

The empty interface and any

There's a special interface that requires no methods:

type interface{}    // older spelling
type any            // Go 1.18+ alias; identical

Since every type has zero or more methods, every type satisfies interface{}. It's the type "anything at all." You'll see it used when a function needs to accept truly any value:

// fmt.Println's actual signature (simplified):
func Println(values ...any) (int, error)

Println takes any number of arguments of any type - int, string, your Money struct, all mixed together. It works internally by inspecting the dynamic type of each argument at runtime (see Going deeper for how).

any is what Go uses where other languages might say Object or void *. Two things to know:

  1. Functions that take any lose all type information at the function boundary. Inside, you have to use type assertions or type switches (next section) to get back to a concrete type before you can do anything useful.

  2. Prefer specific interfaces over any. func writeTo(w io.Writer) is much better than func writeTo(x any) - the first tells the reader (and the compiler) what's expected. Reach for any only when you genuinely accept anything.

Try it:

  1. Define func describe(v any) { fmt.Printf("type=%T value=%v\n", v, v) }. Call it with an int, a string, and your Money struct. The %T verb prints the dynamic type.

  2. Note that you can't do v + 1 inside describe - even if you pass an int. The compiler doesn't know it's an int once it's in a any slot. You'd have to assert (next section).

Type assertions and type switches

When you have an interface value and you want to get back to the concrete type underneath, you use a type assertion:

var v any = "hello"
s := v.(string)                  // type assertion: "treat v as a string"
fmt.Println(s, len(s))           // hello 5

v.(string) reads "give me v's underlying value, asserting it's actually a string." If v really is a string, you get it. If it isn't, the program panics.

The safer comma-ok form doesn't panic:

s, ok := v.(string)
if ok {
    fmt.Println("it's a string:", s)
} else {
    fmt.Println("not a string")
}

You've seen this shape before - same pattern as m["key"] returning (value, ok) on maps. The "comma ok" form is everywhere in Go.

For deciding among several types, use a type switch:

func describe(v any) {
    switch x := v.(type) {
    case nil:
        fmt.Println("it's nil")
    case int:
        fmt.Println("an int:", x*2)
    case string:
        fmt.Println("a string of length", len(x))
    case fmt.Stringer:
        fmt.Println("something stringer-able:", x.String())
    default:
        fmt.Printf("don't know: type=%T value=%v\n", v, v)
    }
}

Read the syntax carefully: switch x := v.(type) is special. The .(type) keyword (not a real type - a contextual keyword) is only legal inside a type switch. Each case is a type; inside that case, x has that type and can be used as such. case int: makes x an int; case fmt.Stringer: makes x a fmt.Stringer.

This is how fmt.Println actually dispatches: a type switch that checks for Stringer, error, []byte, and many others in some order.

Try it: 1. Type the describe example. Call it with 42, "hi", your Money, and nil. Each case fires. 2. The order of cases matters when types overlap. Try putting case fmt.Stringer: before case int:. What happens with describe(42)? (Hint: int doesn't have a String() method, so it's still not a Stringer. But other types might match earlier than you expect.) 3. Add case []int: and case map[string]int:. Test with appropriate values. Type switches handle complex types just fine.

Constructors (a convention, not a feature)

Go has no special "constructor" keyword. The convention is to write a regular function that returns a value of your type, named NewYourType:

func NewPerson(name string, age int) Person {
    return Person{Name: name, Age: age, City: "unknown"}
}

// Use it:
alice := NewPerson("Alice", 30)

This is just a function. The pattern gives you somewhere to put validation, defaults, or setup logic. Look for NewX functions in any Go library - they're everywhere.

Going deeper

The basics above are enough to use methods and interfaces effectively. The material below is for when you want the precise model - the rules, the runtime mechanics, the famous gotchas, and the idiomatic patterns mature Go developers use. Drawn from the Go spec, Effective Go, and Russ Cox's writing on the runtime. Links at the end.

Method sets: the precise rule (and why *T satisfies more interfaces than T)

You met the rule informally above. The exact statement from the Go spec:

  • The method set of T is the set of methods declared with receiver T.
  • The method set of *T is the set of methods declared with receiver T or *T.

In other words: pointer types include value methods, but value types do not include pointer methods.

That asymmetry matters for interface satisfaction. Consider:

type Speaker interface { Speak() string }

type Cat struct{ Name string }
func (c *Cat) Speak() string { return c.Name + " says meow" }  // pointer receiver

Cat's method set is empty (no methods with receiver Cat). *Cat's method set contains Speak. So:

var s Speaker
s = &Cat{Name: "Whiskers"}    // OK - *Cat satisfies Speaker
s = Cat{Name: "Whiskers"}     // COMPILE ERROR - Cat does not satisfy Speaker
                              //   (Speak has pointer receiver)

If you had defined Speak with a value receiver (func (c Cat) Speak()...), both forms would work, because the method would be in both Cat's and *Cat's method set. But once a single method on a type uses a pointer receiver, you've effectively decided that only *T values can satisfy interfaces requiring that method.

Practical advice: within a single type, stick to one receiver style for all methods - either all value or all pointer. Mixing causes confusing interface-satisfaction puzzles and inconsistent behavior.

Try it: Define type Counter struct{ Value int } with a value-receiver Get() int and a pointer-receiver Inc(). Try assigning both Counter{} and &Counter{} to a Speaker interface (after adding a Speak method with a value receiver). Notice that &Counter satisfies any interface either method-set holds; bare Counter only satisfies interfaces in its smaller method set.

Method values vs method expressions

Two ways to refer to a method without immediately calling it.

A method value is a method bound to a specific receiver:

p := Point{X: 3, Y: 4}
dist := p.DistanceFromOrigin    // method value: a function bound to p
fmt.Println(dist())             // 5

dist is a function value of type func() float64. The receiver is captured at the moment of binding. Even if p changes later, dist() still operates on the original p it was bound to (for value receivers; pointer-receiver method values capture the pointer, so they follow mutations to what's behind the pointer).

A method expression is a method as a regular function with the receiver as an explicit first parameter:

dist := Point.DistanceFromOrigin     // method expression: a free function
fmt.Println(dist(Point{X: 3, Y: 4})) // 5

dist here has type func(Point) float64 - the receiver is now just a regular first argument.

You'll see both forms occasionally in real code. Method values are common (passing obj.Method to a function that takes a callback); method expressions are rarer (mostly for reflection-style code or generic helpers).

Try it: 1. Method value with a value receiver:

p := Point{X: 3, Y: 4}
d := p.DistanceFromOrigin
p.X = 99
fmt.Println(d())       // still 5 - d captured a copy of p

  1. Method value with a pointer receiver: define func (p *Point) Magnitude() float64 { ... }. Bind m := pp.Magnitude where pp := &Point{X: 3, Y: 4}. Mutate pp.X = 99. Call m(). The result changes - because m captured the pointer.

Embedded types and method promotion

Go has no inheritance. It has something subtler - embedding - which mostly does the job without the pitfalls.

type Logger struct {
    prefix string
}

func (l *Logger) Log(msg string) {
    fmt.Println(l.prefix + ": " + msg)
}

// Job embeds *Logger as an anonymous field. Logger's methods are "promoted"
// to Job - callers can do job.Log() as if Job had defined Log itself.
type Job struct {
    *Logger              // anonymous (embedded) field
    Command string
}

func main() {
    j := &Job{
        Logger:  &Logger{prefix: "Job"},
        Command: "build",
    }
    j.Log("starting")     // calls Logger.Log via promotion
                          // prints: Job: starting
}

The key bits:

  • *Logger inside Job with no field name is called an anonymous (or embedded) field.
  • Methods on the embedded type are promoted: callers can do j.Log(...) exactly as if Log were defined on Job.
  • This is composition, not inheritance. Job has-a Logger; it does not is-a Logger. There's no class hierarchy, no overriding semantics - just method promotion.

You can "override" by defining a method on Job with the same name; that wins over the promoted one. But it's not polymorphism in the Java/C++ sense - the embedded Logger's code only ever calls Logger's methods, never Job's. There's no virtual dispatch.

A common use of embedding: adapter types. The standard library's http.HandlerFunc is the classic:

// A function type with a method - so a plain function can satisfy http.Handler.
type HandlerFunc func(http.ResponseWriter, *http.Request)

func (f HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    f(w, r)
}

// Now any function with the right signature can be wrapped:
http.Handle("/hi", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "hi")
}))

This is a recurring Go pattern: define a function type with a method, and you've turned plain functions into things that satisfy interfaces.

Try it: 1. Build the Job/Logger example. Confirm j.Log("starting") works through promotion.

  1. Add a method func (j *Job) Log(msg string) on Job itself that prepends the command name. Notice that now j.Log(...) calls Job's Log (which can still call j.Logger.Log(...) if it wants).

  2. Define type StringSet func(string) bool (a function type). Give it a Contains(s string) bool method that just calls f(s). Now a StringSet is both a function (callable) and an object satisfying a Container interface (you'd define) requiring Contains. This dual nature is the trick.

Inside an interface value: iface and the itable

Russ Cox wrote the canonical reference for this, and the picture is simple. Every interface value in Go is a two-word struct:

interface value (iface):
┌──────────────┬──────────────┐
│ itab pointer │ data pointer │
└──────┬───────┴──────────────┘
   itab:
   { interface type info
     concrete type info
     [func pointer for method 1]
     [func pointer for method 2]
     ... }
  • itab pointer - points to an interface table. The itab is keyed by the (interface type, concrete type) pair. It holds type metadata and a list of function pointers - one per method the interface requires, pointing at the concrete type's implementation.
  • data pointer - points to the actual value (or the value itself if it's word-sized and the runtime can pack it in).

When you call s.Speak() on an interface value s, the compiler generates code roughly equivalent to:

s.itab->fun[0](s.data)        // method 0 = Speak; first arg = the underlying value

That's the whole dispatch - one indirect call. No reflection, no method-name lookup, no inheritance walk. The itab does the work once, when the interface value is assigned, then caches.

For the empty interface (any / interface{}), there are no methods to dispatch, so the itab degenerates to just a pointer to the type. The runtime calls this variant eface (for "empty face"). It's used by fmt.Println, type assertions on any, the reflect package - everywhere a value needs to flow with its type but no specific behavior.

Sizes worth knowing:

  • An interface value is two words (16 bytes on 64-bit machines), even if the underlying value is one byte.
  • Passing an interface is cheap (two-word copy).
  • Going from concrete type → interface costs an itab lookup the first time the (interface, concrete-type) pair is seen; cached after that.
  • Going from interface → concrete (type assertion) is a runtime check: compare the itab's type info with the target type.

Try it:

import (
    "fmt"
    "unsafe"
)

type Greeter interface{ Greet() string }
type Person struct{ Name string }
func (p Person) Greet() string { return "Hi, " + p.Name }

func main() {
    var g Greeter = Person{Name: "Ada"}
    fmt.Println(unsafe.Sizeof(g))         // 16 - two words
    fmt.Println(g.Greet())                // Hi, Ada
    fmt.Printf("%T\n", g)                 // main.Person - itab told us the type
}

The %T print is reaching into the itab to recover the concrete type.

The nil interface gotcha (typed nil)

The most famous Go pitfall. Read this carefully - most beginners hit it within their first six months.

An interface value is nil if and only if both its itab pointer and its data pointer are nil. The implication:

type MyError struct{ Code int }
func (e *MyError) Error() string { return "boom" }

func mightFail() error {
    var e *MyError              // e is a typed nil: type=*MyError, value=nil
    return e                    // returned as a non-nil interface!
}

func main() {
    err := mightFail()
    if err != nil {             // TRUE - even though e was nil
        fmt.Println("got an error:", err)
    }
}

Trace it: e is a nil pointer of type *MyError. When returned as an error, Go boxes it into an interface value with itab pointer → *MyError's itabanddata pointer → nil. The itab is *not* nil; the interface is *not* nil. Theerr != nil` check passes even though there was no actual error to report.

This is the typed-nil gotcha. The fix is to return the untyped nil literal directly when there's no error:

func mightFail() error {
    var e *MyError
    if everythingFine {
        return nil              // untyped nil - interface really is nil
    }
    e = &MyError{Code: 42}
    return e
}

Or even more disciplined: avoid declaring an *MyError variable and instead initialize it inline at the return:

func mightFail() error {
    if everythingFine {
        return nil
    }
    return &MyError{Code: 42}    // no chance of accidentally returning typed nil
}

The bug bites most often when functions return concrete error types and a developer thinks "I'll return the zero value when there's no error." Don't. Return the literal nil.

Try it: 1. Build the buggy version. Confirm the err != nil check fires.

  1. Replace with the fixed version. Confirm now err == nil.

  2. Use errors.Is(err, nil) - does it report true for the buggy case? (No - it walks the wrap chain, and a typed-nil error wrapping nothing isn't "equal to nil" by that walk either. This is why nil-checking errors via errors.Is(err, nil) doesn't save you. The fix is at the return site.)

"Accept interfaces, return structs" (Postel's Law for Go)

A Go proverb most strongly associated with Dave Cheney and the broader Go community:

Accept interfaces, return structs.

It's Postel's Law applied to function signatures - "be conservative in what you do, liberal in what you accept." Expanded:

  • Functions should accept the smallest interface that captures what they need. Don't take *os.File when you only need to write bytes - take io.Writer. This lets callers pass anything writable: a file, a buffer, a network connection, a test fixture.
  • Functions should return concrete types. Returning an interface forces callers to use only the methods the interface exposes. Returning the concrete struct lets each caller pick the level of abstraction they want - they can immediately assign to an interface if they prefer.

Example:

// Good: accepts a small interface, returns a concrete type.
func WriteHeader(w io.Writer, name string) (*Header, error) {
    _, err := fmt.Fprintf(w, "X-Source: %s\r\n", name)
    if err != nil {
        return nil, err
    }
    return &Header{Name: name}, nil
}

// Less good: accepts a concrete type (over-specified), returns an interface (under-specified).
func WriteHeader(w *os.File, name string) (io.ReadCloser, error) { ... }

The trick is: don't define interfaces preemptively in the package that supplies a type. Let interfaces emerge from the consumers. The io.Reader interface is defined in io, not in os (where *os.File lives) - because anyone who wants to consume readable bytes can declare their own io.Reader-shaped need.

You'll see exceptions. Interfaces that abstract a major capability (like error) live with the concept. Constructors that only need to return an abstract behavior (like crypto/cipher.NewCBCEncrypter returning cipher.BlockMode) return an interface. The proverb is a default, not an absolute.

Try it: Write a small function func summarize(name string, body io.Reader) (int, error) that returns the number of bytes in body. Call it with strings.NewReader("hello"), with bytes.NewBuffer([]byte("world")), with an open *os.File. All three work. The function never knew or cared which.

Compile-time satisfaction checks

When you've written a type and you want it to satisfy a specific interface, but the type doesn't get used as that interface in your package - there's an idiom to make the compiler check at compile time:

type MyHandler struct{ ... }
func (h *MyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ... }

// Compile-time check: *MyHandler must satisfy http.Handler.
// The variable is named _ so it's discarded.
var _ http.Handler = (*MyHandler)(nil)

The line says: "create a discarded variable of type http.Handler, initialized to a nil *MyHandler." Type-checking forces the compiler to verify *MyHandler satisfies http.Handler. If you later remove or rename ServeHTTP, the compiler complains immediately, instead of at the first downstream caller.

You'll see this idiom at the top of files that export a type intended for interface use. It's a contract-enforcement trick with zero runtime cost (the nil value never gets used).

Try it: In a small file, write a type that almost satisfies io.Writer (e.g., method signature is Write(p []byte) int - missing the error return). Add var _ io.Writer = (*YourType)(nil). The compiler error tells you exactly what's wrong. Without that line, the error would only surface when someone tried to use your type as a writer.

Generics extend interfaces (Go 1.18+)

Since Go 1.18, interfaces have an additional role: they can define type constraints for generic functions and types. The constraint syntax adds type elements alongside (or instead of) method signatures:

// A constraint: "any type whose underlying type is int, int32, int64, or float64".
type Number interface {
    ~int | ~int32 | ~int64 | ~float64
}

// A generic function constrained by Number.
func Sum[T Number](values []T) T {
    var sum T
    for _, v := range values {
        sum += v
    }
    return sum
}

func main() {
    fmt.Println(Sum([]int{1, 2, 3}))           // 6
    fmt.Println(Sum([]float64{1.5, 2.5}))      // 4
}

The ~int means "any type whose underlying type is int" - so a user-defined type Celsius int would also be accepted. The | is a union; the constraint is "any of these types."

Important restriction: interfaces that include type elements (int | float64, ~int, etc.) can only be used as constraints. They can't be the type of a regular variable. The interface concept is split into two roles since 1.18: basic interfaces (methods only, usable as values) and general interfaces (also include type elements, usable only as constraints).

For a deeper treatment, see the Go blog's introduction to generics. The short version: interfaces grew a second job, but most everyday Go is still about basic interfaces and method satisfaction.

Where this material came from

Exercise

In a new file shapes.go:

  1. Define a struct Rectangle with two float64 fields: Width and Height.

  2. Add two methods on Rectangle:

  3. Area() float64 - returns Width * Height.
  4. Perimeter() float64 - returns 2 * (Width + Height).

  5. In main, create a Rectangle with width 5 and height 3. Print its area and perimeter. (Expected: 15 and 16.)

  6. Write a regular function LargerOf(a, b Rectangle) Rectangle that returns whichever rectangle has the bigger area. Test it with two rectangles of different sizes.

  7. Add a constructor NewSquare(side float64) Rectangle that returns a Rectangle with both fields set to side. Test it: NewSquare(4).Area() should be 16.

Now extend it with an interface:

  1. Define a Circle struct with one float64 field Radius. Add methods Area() float64 (returns π·r²; use math.Pi) and Perimeter() float64 (returns 2π·r).

  2. Define a Shape interface that requires both Area() float64 and Perimeter() float64.

  3. Write a function Describe(s Shape) that prints "area=X, perimeter=Y". It should accept any type satisfying Shape - both Rectangle and Circle work.

  4. Call Describe(Rectangle{Width: 5, Height: 3}). Then call Describe(Circle{Radius: 4}). The same function handles both.

  5. Stretch: add a String() string method to Rectangle that returns "Rect(WxH)". Now fmt.Println(r) uses it automatically - because Rectangle satisfies fmt.Stringer implicitly.

  6. Stretch: add a compile-time check at the top of the file:

    var (
        _ Shape         = Rectangle{}
        _ Shape         = Circle{}
        _ fmt.Stringer  = Rectangle{}
    )
    
    Try removing the Area() method from Circle. The compiler tells you exactly where the contract broke.

What you might wonder

"Why uppercase field names?" In Go, capitalizing a name makes it visible from outside the package (other people's code can see it). Lowercase keeps it private to the current package. You'll meet packages properly in page 11. For now: capital is the safer default.

"What's the difference between a struct and a class?" If you've used a language with classes (Java, Python, C++): a Go struct is similar but smaller. No inheritance. No constructors built in. Methods are attached separately (not inside the type declaration). Go gets away with this minimalism because interfaces (which you met above) handle the cases other languages use inheritance for - and they do it in a more flexible way, because satisfaction is implicit instead of declared.

"Can I have a struct that contains another struct?" Yes. Very common. A Person could contain an Address field of type Address. Field access chains: p.Address.Street.

"What's the zero value of a struct?" If you write var alice Person without initializing fields, every field gets its zero value - 0 for numbers, "" for strings, false for booleans, nil for the things we haven't met yet. Useful: a fresh Person is always valid-looking, not garbage.

Done

You can now: - Define your own types with type Name struct { ... } (and on non-struct types too). - Create struct values with the Type{Field: value, ...} literal. - Read and write fields with dot notation. - Pass structs to functions and return them. - Attach methods to types with a receiver. - Choose between value and pointer receivers (modification → pointer). - Define an interface that captures a behavior - and pass any type with the right methods through it. - Use the empty interface (any) when you genuinely accept anything. - Recover a concrete type from an interface value with type assertions or type switches.

You can now model things that have multiple properties and write code that works against contracts rather than against specific types. Combined with what came before, you can build programs that are both data-rich and flexible enough to test, swap implementations, and grow without rewriting.

Next page: how Go handles collections - lists of things, lookups by key. Slices and maps.

Next: Many things at once06-slices-and-maps.md

Comments