0% completed
Small Interfaces and Composition
On This Page
Implicit Satisfaction
Single-Method Interfaces
Building Larger Interfaces
The Cost of More Methods
Checking Satisfaction at Compile Time
Key Takeaways
A well-known Go proverb, from Rob Pike, states: "The bigger the interface, the weaker the abstraction."
An abstraction here means a description of what code needs, with the details of any particular type removed. A large interface describes many details, so few types can satisfy it, and a parameter of that type does not tell the reader which of those methods the function calls. The interface io.Writer has one method, and implementing net.Conn from the net package takes eight. A search of the Go 1.27 standard library source, leaving out tests and the cmd directory, finds more than 100 methods with the signature of Write.
What makes one-method interfaces so widely useful, how are larger ones built from them, and how can code confirm at compile time that a type satisfies an interface?
Implicit Satisfaction
A Go type satisfies an interface when it has all of the interface's methods with the right signatures. No declaration links the two, and this is called implicit satisfaction. The package that defines the type does not mention the interface, and it does not need to import the package that declares it.
This rule makes small interfaces practical. The type strings.Builder in the standard library has a Write method, and nothing in its declaration mentions io.Writer. Any type written later, in any package, satisfies io.Writer in the same way as soon as it has the method. The method set comparison happens at compile time wherever the program converts a value to the interface.
Single-Method Interfaces
The standard library defines many interfaces with exactly one method. Each one describes a single capability that many unrelated types can offer.
| Interface | Method | Some types that satisfy it |
|---|---|---|
io.Reader | Read(p []byte) (n int, err error) | *os.File, *strings.Reader, *bytes.Buffer |
io.Writer | Write(p []byte) (n int, err error) | *os.File, *strings.Builder, *bytes.Buffer |
fmt.Stringer | String() string | time.Duration, *strings.Builder |
error | Error() string | *fs.PathError, *strconv.NumError |
A function that needs only one capability should declare a parameter that requires only that capability. The function below exports orders as lines of comma-separated text. It needs to write bytes and nothing else, so its first parameter is an io.Writer.
Output:
1001,Ada,2500 1002,Lin,1250 builder holds 28 bytes buffer holds 28 bytes
The function ExportCSV does not change for any of the three destinations. The builder, the buffer, and standard output all receive the same calls. Writing to a file or to an HTTP response would need no change either, because both offer the same method. The pointers &builder and &buffer are the values that satisfy the interface, because Write has a pointer receiver on both types.
A one-method interface also lets you add new destinations. The program below declares a small type of its own, which counts bytes instead of storing them. It then uses io.MultiWriter, which returns one writer that duplicates each write to several others. The export function again needs no change.
Output:
<nil> stored 28 counted 28 exporting order 1001: disk full
The last line shows a further benefit. To exercise the error path of ExportCSV, the program needs a writer that fails, and that takes only a few lines of code. With a parameter of type *os.File, the same check would need a real file that fails to write.
Building Larger Interfaces
An interface can list other interfaces among its methods, which is called embedding. The resulting method set is the union of all of them. The io package builds its larger interfaces in this way.
type ReadWriter interface { Reader Writer } type ReadWriteCloser interface { Reader Writer Closer }
Any value that satisfies io.ReadWriter also satisfies io.Reader and io.Writer, so it can be passed to a function that asks for either one. A function that receives an io.ReadWriter can read and write. A function that receives an io.Reader calls only Read through that parameter, even if the value it receives offers much more. The parameter type therefore states what the function expects to use.
Two embedded interfaces may contain the same method when the signatures are identical, and the method then appears once. If the signatures differ, the compiler rejects the declaration.
The Cost of More Methods
Three methods are enough to show the cost. The interface sort.Interface requires Len, Less, and Swap, because its algorithm needs all three. To sort orders by total with sort.Sort, a program must declare a new type and write all three methods for it, and it must do so again for each further sort order. Only Less differs between those types. The function slices.SortFunc asks instead for a single comparison function, and the documentation of sort.Sort itself notes that slices.SortFunc is more ergonomic and runs faster in many situations.
Output:
[{1002 1250} {1001 2500} {1003 4000}] [{1002 1250} {1001 2500} {1003 4000}]
Both calls give the same result. The version with an interface needs a type and three methods, and the other needs one function. That difference appears with only three methods. With an interface of fifteen methods, every implementer must write all fifteen, including a small type written only to check one function.
| Small interface | Large interface | |
|---|---|---|
| Types that can satisfy it | Many, often unrelated | Few, often only one |
| Work to write a new implementation | A few lines | One method body per method |
| What a parameter of this type tells the reader | Exactly what the function uses | Very little |
Checking Satisfaction at Compile Time
Implicit satisfaction has one drawback. If a type is meant to satisfy an interface but its method has the wrong signature, the compiler reports nothing until some code performs the conversion. If that conversion is in another package, or if the only use goes through any, building the type's own package reports nothing, and the mistake appears only when that other package is compiled or the program runs.
A single declaration at package level makes the compiler check the intention in the type's own package.
Output:
1001,2500
The expression (*CSVExporter)(nil) converts nil to the type *CSVExporter. Assigning it to a variable named _ keeps nothing, so the line uses no memory at run time, and the compiler still has to check the conversion. This program never converts the type anywhere else, so the check is the only line that tests it. If the method loses its error result, the build fails, and the first error gives the reason.
./main.go:21:18: cannot use (*CSVExporter)(nil) (value of type *CSVExporter) as Exporter value in variable declaration: *CSVExporter does not implement Exporter (wrong type for method Export) have Export(io.Writer, []Order) want Export(io.Writer, []Order) error
Use a value of the type, such as var _ Exporter = CSVExporter{}, when the methods have value receivers. This declaration is worth writing when a type exists mainly to satisfy an interface and no code nearby performs the conversion. It adds nothing when the same package already converts the type to the interface.
Key Takeaways
- A type satisfies an interface by having its methods, and no declaration links the two.
- A one-method interface such as
io.Writerdescribes one capability, so many unrelated types can satisfy it. - A parameter should use the smallest interface that covers what the function calls.
- Larger interfaces are built by embedding small ones, as
io.ReadWriteris built fromio.Readerandio.Writer. - Each extra method raises the cost of every implementation, as
sort.Interfaceshows with only three methods. var _ Exporter = (*CSVExporter)(nil)makes the compiler check satisfaction, at no cost at run time.
A small interface states one thing that a function needs, and that is why so many types can meet it. The export function in this lesson works with a builder, a buffer, standard output, and a type written a moment ago, because its parameter requires only a Write method. Larger interfaces remain available when a function truly needs more, and they should be assembled from the small ones.
Reading Progress
0%
On This Page
Implicit Satisfaction
Single-Method Interfaces
Building Larger Interfaces
The Cost of More Methods
Checking Satisfaction at Compile Time
Key Takeaways