9. Interfaces and Generics
An interface is a set of method signatures. A type satisfies it by having those
methods, and says nothing about it. That one decision — satisfaction is
structural, checked by the compiler, never declared — is what makes Go’s
abstraction feel different from every language with an implements keyword.
9.1 Interfaces
// A type satisfies an interface by having its methods. Nothing is declared.
package main
import (
"fmt"
"math"
)
type Shape interface {
Area() float64
Perimeter() float64
}
type Rect struct{ W, H float64 }
func (r Rect) Area() float64 { return r.W * r.H }
func (r Rect) Perimeter() float64 { return 2 * (r.W + r.H) }
type Circle struct{ R float64 }
func (c Circle) Area() float64 { return math.Pi * c.R * c.R }
func (c Circle) Perimeter() float64 { return 2 * math.Pi * c.R }
func describe(s Shape) string {
return fmt.Sprintf("%T area=%.2f perimeter=%.2f", s, s.Area(), s.Perimeter())
}
func main() {
for _, s := range []Shape{Rect{3, 4}, Circle{1}} {
fmt.Println(describe(s))
}
}
main.Rect area=12.00 perimeter=14.00
main.Circle area=3.14 perimeter=6.28
Rect and Circle never mention Shape. They have the two methods, so they
are Shapes, and a third type in another package written by somebody who never
heard of yours is a Shape too if it has them. Interfaces can therefore be
defined after the types that satisfy them, and by the package that needs them
rather than the package that provides them.
That leads to the piece of Go design advice worth internalizing early: define
an interface where it is used, not where it is implemented, and keep it small.
A function that only reads should take an io.Reader, not your *FileStore,
and the interface should list the one method the function calls. Then any
caller can pass anything, including a strings.Reader in a test, without either
side having planned for it.
The standard library’s most-used interfaces have one method each:
type Reader interface{ Read(p []byte) (n int, err error) }
type Writer interface{ Write(p []byte) (n int, err error) }
type Stringer interface{ String() string }
// One interface, one method, and a type of your own that satisfies it.
package main
import (
"bytes"
"fmt"
"io"
"os"
"strings"
)
// report writes to anything with a Write method.
func report(w io.Writer, name string) {
fmt.Fprintf(w, "report for %s\n", name)
}
// countingWriter counts the bytes passing through it and forwards them.
type countingWriter struct {
inner io.Writer
n int
}
func (c *countingWriter) Write(p []byte) (int, error) {
c.n += len(p)
return c.inner.Write(p)
}
func main() {
var buf bytes.Buffer
var sb strings.Builder
report(&buf, "a byte buffer")
report(&sb, "a string builder")
report(os.Stdout, "standard output")
fmt.Print(buf.String(), sb.String())
// A wrapper is a writer too, so it drops in anywhere one is expected.
c := &countingWriter{inner: os.Stdout}
report(c, "a counting wrapper")
fmt.Println("bytes written:", c.n)
}
report for standard output
report for a byte buffer
report for a string builder
report for a counting wrapper
bytes written: 30
Files, network connections, HTTP response bodies, gzip compressors, hash
functions, and strings.Builder are all io.Writers, so anything written
against that one method works with all of them. countingWriter shows the other
half: because the interface is small, wrapping one is a few lines, and the
wrapper is itself a writer.
Interfaces embed: io.ReadWriter is declared as interface { Reader; Writer }
and nothing more.
9.2 Interface Values
An interface value holds a dynamic type and a value. %T prints the
first, so the shapes above reported main.Rect. An interface is nil only
when both halves are empty, the bug in section 8.4.
A type assertion extracts the concrete value back out. s.(Circle) panics
if the dynamic type is not Circle; c, ok := s.(Circle) reports it instead
and is what you should write. Asserting to an interface type instead asks
whether the value also satisfies that one. That is how code discovers optional
behavior: if wt, ok := w.(io.WriterTo); ok takes a fast path when the writer
has one.
A type switch handles several possibilities at once, and it is where Go sends you when you want a sum type.
9.3 Rebuilding a Sum Type
Lux gives you an enum with associated values and a match the compiler forces
you to complete. Go has neither. What you build instead is an interface with an
unexported method, which no package but yours can implement, and a type switch.
// What a Lux enum becomes in Go, and what you give up.
package main
import "fmt"
// Shape is closed to other packages: the unexported method cannot be
// implemented outside this one.
type Shape interface {
isShape()
}
type Circle struct{ R float64 }
type Square struct{ Side float64 }
type Tri struct{ A, B, C float64 }
func (Circle) isShape() {}
func (Square) isShape() {}
func (Tri) isShape() {}
func name(s Shape) string {
switch v := s.(type) {
case Circle:
return fmt.Sprintf("circle r=%g", v.R)
case Square:
return fmt.Sprintf("square s=%g", v.Side)
default:
// Nothing made us handle Tri. This is the case Lux would have caught.
return fmt.Sprintf("unhandled %T", v)
}
}
func main() {
for _, s := range []Shape{Circle{1}, Square{2}, Tri{3, 4, 5}} {
fmt.Println(name(s))
}
// A type assertion asks for one specific type.
var s Shape = Circle{7}
if c, ok := s.(Circle); ok {
fmt.Println("radius", c.R)
}
if _, ok := s.(Square); !ok {
fmt.Println("not a square")
}
}
circle r=1
square s=2
unhandled main.Tri
radius 7
not a square
isShape() is a marker. It does nothing, and because it is unexported no type
outside this package can have it, so the set of Shapes is closed and a reader
can find all of them by searching one file. That much translates.
What does not translate is the guarantee. name handles Circle and Square
and silently does the wrong thing for Tri, and the compiler is content:
default covers it. Lux would have refused to build. This is the concrete cost
of the different bet described in the preface, and the mitigations are habits
rather than checks. Put the type switch and the type declarations in the same
file. Make default panic with the unhandled type during development rather
than returning something plausible. And when the set is large or changing, add
a linter that checks type switches for completeness, since the compiler will
not.
9.4 any
any is an alias for interface{}, the interface with no methods, which every
type satisfies. It is the parameter type of fmt.Println and the value type of
a heterogeneous map.
Reach for it rarely. A value in an any has had its type erased as far as the
compiler is concerned, and getting it back means an assertion that can fail at
run time, so a function taking any has given up the checking you came to Go
for. Before generics it was the only tool for writing something that worked on
many types. Now it usually is not.
9.5 Generics
A function can take type parameters, written in square brackets before the value parameters.
// Type parameters: one function, many types.
package main
import (
"fmt"
"strings"
)
// Map applies f to every element, returning a new slice.
func Map[T, U any](xs []T, f func(T) U) []U {
out := make([]U, 0, len(xs))
for _, x := range xs {
out = append(out, f(x))
}
return out
}
// Number constrains T to the types that + works on.
type Number interface {
~int | ~int64 | ~float64
}
func Sum[T Number](xs []T) T {
var total T
for _, x := range xs {
total += x
}
return total
}
type Celsius float64 // ~float64 admits named types too
func main() {
fmt.Println(Map([]int{1, 2, 3}, func(n int) string {
return strings.Repeat("*", n)
}))
fmt.Println(Sum([]int{1, 2, 3}))
fmt.Println(Sum([]float64{1.5, 2.5}))
fmt.Println(Sum([]Celsius{10, 20}))
}
[* ** ***]
6
4
30
Map[T, U any] works on a slice of any type and produces a slice of any other.
The call sites pass no type arguments because the compiler infers them from the
values, as usual.
A constraint is an interface used as a bound on a type parameter. any
allows everything and therefore permits almost nothing, so Sum cannot use it:
+ is not defined on a type parameter bounded by any. Number is a
constraint listing the permitted underlying types, and the ~ means “any type
whose underlying type is this”, so Celsius is admitted. comparable is built
in and allows ==, as a map key parameter needs. cmp.Ordered in the standard
library covers every type that < works on, so most constraints you need are
already written.
When to use generics. When you would otherwise write the same function once
per type, as the slices and maps packages do. Not when an interface with a
method would do the job — a generic function knows nothing about T beyond its
constraint, so if the behavior differs per type you want a method, not a type
parameter. The Go proverb is that a little duplication is cheaper than the wrong
abstraction, and generics have not repealed it.
Methods cannot have their own type parameters. A type can be generic, so
type Stack[T any] struct { items []T } works and its methods use the type’s
parameters, but you cannot add a parameter on the method itself.
Exercise 9-1. Write a Shape implementation in a second file for a right
triangle and confirm you did not have to modify anything.
Exercise 9-2. Write an io.Writer that discards everything and counts
calls. Compare it against io.Discard.
Exercise 9-3. Add Tri to the type switch in sumtype, then add a fourth
shape and do not add it to the switch. Note that nothing tells you.
Exercise 9-4. Write func Filter[T any](xs []T, keep func(T) bool) []T and
func Keys[K comparable, V any](m map[K]V) []K. Then find the standard-library
functions that already do both.