After diving a little too heavily into object-orientation, language design has shifted to interfaces as a way to support dynamic dispatch without tying types into a single inheritance tree.
A common example is a "reader" interface. Functionality which needs to read data (such as a decompression library) only requires that its input implement the reader interface. Multiple types can implement this interface -- files on disk, network sockets, in-memory buffers, and so on.
Lots of simple interfaces work this way: their behavior is defined by one method, and often the interface is named after this method, with an "er" suffix: "Reader", "Listener", "Watcher", etc.
In most languages, a type must declare that it implements an interface. In Rust, this might look like this,
impl Default for MyType {
fn default() -> Self {
Self { foos: 0 }
}
}
where Default
is the interface being implemented (Rust uses the term "trait" instead of "interface", with a slightly different meaning).
Go takes a different approach: an interface defines a set of method signatures, and any type with matching methods implicitly implements the interface. For example, the io.Writer interface is defined as
type Writer interface {
Write(p []byte) (n int, err error)
}
Any type can implement Writer just by implementing a matching Write method.
In an ecosystem of code written by different people, this has the advantage that a type you don't control can implement an interface you do control. This can be especially useful for mocking third-party types when testing.
However, this design choice assumes that the nature of an interface is entirely defined by its method signatures. For example, a module framework might define a "module" interface:
type Module interface {
// Start starts the module's execution, and returns
// immediately.
Start()
// Stop signals the module to stop, blocking until it
// does so.
Stop()
}
func AddModule(module Module) { .. }
Here, the comments capture some information about the interface that is not represented in the method declarations, and thus not checked by the compiler.
In fact, lots of types have Start
and Stop
methods matching this interface. Are these modules? The compiler thinks so! Perhaps types from a package of service utilities have these methods, but with an additional Wait
method that waits for the module to actually stop. Mixing such utilities would silently terminate those modules uncleanly.
If you're familiar with duck-typed languages like Python or JS, this probably isn't surprising: it's up to you as the human at the keyboard to ensure that your types have the necessary behaviors, or things will fail at runtime.
But Go is strongly typed, and one of strong typing's benefits is surfacing errors like this at compile time. In fact, it's quite common to use interface {}
in Go code, and everything satisfies this interface -- hardly strong typing!
The introduction of generics in 2022 has helped this situation before. Before generics, a general-purpose LRU library might have used interface{}
:
func (lru *Lru) get(key string) interface{} { .. }
Here callers would apply a type assertion to downcast that interface{}
to the expected type. But nothing prevents objects of different types being stored in the same cache, so the type must be checked for every object - a real runtime cost for a performance-sensitive library. With generics, that can be written as
func (lru *Lru[T]) get(key string) *T { .. }
`
ensuring, at compile time, that only T's are stored in the cache.
This may be a case where allowing too much flexibility in a language allows undetected bugs and prevents useful optimizations. Conversely, a carefully constrained language can eliminate entire classes of incorrect code, while also allowing optimizations based on broad, language-enforced invariants.
[cover photo: @chrisdag]
P.S. This will be the last installment in this very short series. I've moved on from the work that had me writing Go every day. Look forward to posts from a Chromium developer!
Top comments (0)