Don't sleep on inline interfaces
READER WARNING: This post may contain ramblings.
Quack..
Go is different than most languages when it comes to interface type equality.
The term for the way it works is structural typing, which bears some resemblance to duck typing. Like duck-typing we just check if an “object” (very deliberate quoting here) supports certain methods. As opposed to nominal typing, where 2 types with the same structure but different names, are distinct.
But unlike duck typing, the definition of these methods are formalized in a type definition, and can be checked at compile time. Duck typing is inherently dynamic.
And arguably Go interfaces across packages are in a way nominative (due to package scoping), which is even further removed from duck typing, but we’ll get to that shortly.
Because despite all that, a quick bit of code illustrates how Go interfaces quack like duck typing:
if duck, ok := maybeDuck.(interface { Quack() }); ok {
duck.Quack()
}
Yes, that will actually compile, and yes that is actually used out in the wild. It’s an inline (anonymous) interface definition, and you can use it anywhere one would reference a type, including arguments in functions and other interface definitions. That’s definitely something you wouldn’t be able to do with duck typing, but hey. Maybe I just like ducks.
Now I know what you’re thinking: this seems ad-hoc. And you’re not wrong.
Donald And Daisy
There is a case for inline interfaces beyond “I was too lazy to define a named interface.” While Go uses structural typing for interfaces, it also has a key limitation: package scoping.
Consider two packages, donald
and daisy
, each defining a structurally identical Duck interface:
package donald
type Duck interface {
Quack() string
}
package daisy
type Duck interface {
Quack() string
}
In direct usage, these are interchangeable:
package main
import (
"fmt"
"donald"
"daisy"
)
func MakeDuckQuack(d daisy.Duck) {
fmt.Println(d.Quack())
}
type DonaldDuck struct{}
func (DonaldDuck) Quack() string {
return "Quack from Donald!"
}
func main() {
var donaldDuck donald.Duck = DonaldDuck{}
MakeDuckQuack(donaldDuck) // Works fine
}
MakeDuckQuack
expects a daisy.Duck
, we pass a donald.Duck
.
It compiles because both interfaces are structurally identical: Go checks their methods,
not their origin.
But this breaks down when you try to mix the types in type definitions:
package main
import (
"fmt"
"donald"
"daisy"
)
type Pond interface {
Feed(d daisy.Duck)
}
type MyPond struct{}
func (MyPond) Feed(d donald.Duck) {
fmt.Println(d.Quack())
}
func main() {
var recorder Pond = MyPond{} // Nope
var donaldDuck donald.Duck = DonaldDuck{}
recorder.Feed(donaldDuck)
}
./main.go:41:22: cannot use MyPond{} (value of type MyPond) as Pond value in variable declaration: MyPond does not implement Pond (wrong type for method Feed)
have Feed(donald.Duck)
want Feed(daisy.Duck)
Dipping In Complexity
Inline interfaces solve this by their anonymous nature. An inline interface in one package is compatible with a “different” one in a second package, as long as the definition is the same.
Does it matter in the real world? Maybe. To illustrate, we’ll have to complicate things a bit…
The ‘D’ in the infamous ‘SOLID’, most relevantly dictates to “depend in the direction of abstractness”. That is to say, more abstract code should not depend on less abstract code, but the other way around. So instead of something like this:
Your dependency graph will look something like this:
This requires indirection which is normally accomplished using interfaces and dependency injection (in some form). We may not need an interface to make sure a duck can quack, but we would want to abstract out the technical details of what happens when our duck quacks:
app/ducks.go
package app
type QuackRecorder interface {
Record(q Duck)
}
type Duck struct {
Name string
}
func (d Duck) Quack() string {
return d.Name + " quacks!"
}
type DuckConcert struct {
qp QuackRecorder
}
func NewDuckConcert(qp QuackRecorder) DuckConcert {
return DuckConcert{qp: qp}
}
func (dc DuckConcert) Perform(d Duck) {
dc.qp.Record(d)
}
infra/print.go
package infra
import (
"fmt"
"github.com/kleijnweb/fmt.errorf.com/go-ducks/app"
)
type LineQuackPrinter struct{}
func (d LineQuackPrinter) Record(s app.Duck) {
fmt.Println(s.Quack())
}
main.go
package main
import (
"github.com/kleijnweb/fmt.errorf.com/go-ducks/app"
"github.com/kleijnweb/fmt.errorf.com/go-ducks/infra"
)
func main() {
c := app.NewDuckConcert(infra.LineQuackPrinter{})
c.Perform(app.Duck{Name: "Donald"})
}
What we have now is a bastardized version of a typical DIP implementation. It would work similar to this in most languages.
Ducks Can Fly
But go isn’t most languages. I am reminded of one of my son’s favorite stories: Kikker is kikker (frog is frog). Early in the story the protagonist, a frog, and one of his best friends, a duck, compare attributes. To the frogs amazement, the duck can fly. Something the frog has never seen the duck do before. The duck explains she is a bit lazy, but yes, she can fly.
Go can do things you may not have considered, even if you consider it a good friend. We can decouple these packages further, if you’re a bit less lazy.
We’re going to completely defer committing to any implementation, to a single site (our main
package).
We cannot simply declare an interface in the infra
package with the same signature as the app
package.
This will not work because app.QuackRecorder
would expect the app.Quacker
interface and not the infra.Quacker
interface. It’s the Donald and Daisy problem. While interfaces are structurally typed, package scoping
introduces a nominative aspect.
There’s more than 1 way to solve this. Let’s review.
1. Shared abstraction
We can extract the Quacker
abstraction to a different package. This is probably the most commonly
used solution, but it doesn’t really solve the problem, at least not in the way we want.
type QuackRecorder interface {
Record(q fourthpackage.Quacker)
}
The app
and infra
packages are still dependent, just indirectly. We’ve successfully avoided an
import cycle, but we’ve kinda muddied the waters. Especially if you like to think of packages as
application layers (which you should). Some might argue that in DDD a layer with only abstractions
and data is a great implementation, personally I like my layers to actually have behavior. Is a layer
that contains no behavior really a “layer”?
Putting aside that tangent for a different time, this is an OK solution. But it doesn’t promote decoupling as much as it could. It complicates our import graph, possibly making our design more prone to import cycle problems in future revisions.
What else can we do?
2. Generics to the rescue
We can sidestep the issue completely. Deferring committing to a specific implementation is where generics
shine and that was exactly what we were after in pursuit of decoupling infra
from app
.
We can use an interface in infra
as a constraint, so we can be sure Quack()
is available:
infra/print.go
package infra
import (
"fmt"
)
type Quacker interface {
Quack() string
}
type LineQuackPrinter[T Quacker] struct{}
func (d LineQuackPrinter[T]) Record(q T) {
fmt.Println(q.Quack())
}
In app
, we don’t really care what the type argument to QuackRecorder is. We could create a local
Quacker
interface but app
is not using Quack()
so it would be nonsensical.
app/ducks.go
package app
type QuackRecorder[T any] interface {
Record(q T)
}
type Duck struct {
Name string
}
func (d Duck) Quack() string {
return d.Name + " quacks!"
}
type DuckConcert[T any] struct {
qp QuackRecorder[T]
}
func NewDuckConcert[T any](qp QuackRecorder[T]) DuckConcert[T] {
return DuckConcert[T]{qp: qp}
}
func (dc DuckConcert[T]) Perform(d T) {
dc.qp.Record(d)
}
In main
we tie it together:
main.go
package main
import (
"github.com/kleijnweb/fmt.errorf.com/go-ducks/app"
"github.com/kleijnweb/fmt.errorf.com/go-ducks/infra"
)
func main() {
c := app.NewDuckConcert(infra.LineQuackPrinter[app.Duck]{})
c.Perform(app.Duck{Name: "Donald"})
}
This again is an OK solution. Actually I quite like it, generics are great. I’m especially fond of the flexibility of a difference in specificity in type arguments, as noted above.
But they share a trait with “async” in other languages, in that they tend to proliferate. You put one little type argument in one place and before you know it you open a hatch on a space station and are buried in furry little animals.
You can see the beginnings of that in DuckConcert
: we had to add a type argument because we
couldn’t commit to T
of QuackRecorder
.
3. Inline interfaces
Last but not least, inline interfaces. We leverage the fact that the nominative aspect of interfaces is removed, specifically when providing cross-package integration points.
app/ducks.go
package app
type QuackRecorder interface {
Record(q interface{ Quack() string })
}
type Duck struct {
Name string
}
func (d Duck) Quack() string {
return d.Name + " quacks!"
}
type DuckConcert struct {
qp QuackRecorder
}
func NewDuckConcert(qp QuackRecorder) DuckConcert {
return DuckConcert{qp: qp}
}
func (dc DuckConcert) Perform(d interface{ Quack() string }) {
dc.qp.Record(d)
}
infra/print.go
package infra
import (
"fmt"
)
type LineQuackPrinter struct{}
func (d LineQuackPrinter) Record(q interface{ Quack() string }) {
fmt.Println(q.Quack())
}
main.go
package main
import (
"github.com/kleijnweb/fmt.errorf.com/go-ducks/app"
"github.com/kleijnweb/fmt.errorf.com/go-ducks/infra"
)
func main() {
c := app.NewDuckConcert(infra.LineQuackPrinter{})
c.Perform(app.Duck{Name: "Donald"})
}
You’ll quickly spot the two main characteristics of this solution:
- It’s more terse and simple than the alternatives
- It has interface definition duplication
In a nutshell: its appeal depends on how much duplication it would lead to. To paraphrase Rob Pike: a little bit of duplication is better than a lot of dependency. I think this solution exemplifies this.
Leveraging Type Aliases
To reduce the intra-package duplication, we can create an alias of an unnamed interface, and end up with a handle to something that’s not scoped to a package:
package app
// Create alias of `interface { Quack() string }`
type quacker = interface {
Quack() string
}
type QuackRecorder interface {
Record(q quacker)
}
...
func (dc DuckConcert) Perform(d quacker) {
dc.qp.Record(d)j
}
Aside from adding valuable semantics and squeezing out the last bit of intra-package duplication, this is the only viable option when signatures become more complex.
Consider:
func Perform(recorder interface { Record(q interface { Quack() }) }) {
recorder.Record(Duck{Name: "Donald"})
}
func AnotherFunction(recorder interface { Record(q interface { Quack() }) }) {
recorder.Record(Duck{Name: "Daisy"})
}
Vs:
type quacker = interface {
Quack() string
}
type quackRecorder = interface {
Record(q quacker)
}
func Perform(recorder quackRecorder) {
recorder.Record(Duck{Name: "Donald"})
}
func AnotherFunction(recorder quackRecorder) {
recorder.Record(Duck{Name: "Donald"})
}
Suddenly our “anonymous” interface doesn’t seem so ad-hoc anymore.
Overview
To sum up this chapter, here’s a comparison matrix of the 3 solutions:
Solution | Strengths | Weaknesses |
---|---|---|
Shared Abstractions | Reusable definitions | Complicates dependency graph |
Generics | Simple dependency graph, appealing safety / flexibility balance | Type argument propagation |
Inline Interfaces | Simple dependency graph and implementation, no propagating side effects | At least some duplication |
Putting Our Ducks In A Row
I’ve long, long abandoned the idea of an “ideal solution”. There’s no such thing, in any aspect of life, and definitely not in programming.
Inline (or more generally, anonymous / unnamed) interfaces are a pragmatic choice to reduce package coupling.
I’m not saying that striving for this level of decoupling is strictly needed. It has benefits though, and while it may seem like it complicates things in some way, in a very real way it simplifies by reducing the complexity of the dependency graph / import tree. Something that would be impossible if you use package-scoped types, whether they be interfaces or concrete types.
It’s not ideal, but it gets the job done. Don’t sleep on it.