Do you really need that interface{}?

8 min read Original article ↗

Jordan Bonecutter

I was reading over a popular go library implementing a hash set which read something along the lines of:

type Set[T comparable] interface {
Add(T) bool
Remove(T) bool
Len() int
Union(Set[T]) Set[T]
Difference(Set[T]) Set[T]
...
}

This library then implemented two versions of this interface, a thread unsafe set and a thread safe set which wrapped the set in a sync.RWMutex. On first glance, this could be a good use of an interface type, right? You have two implementations (the thread safe and the thread unsafe set) which both promise the same experience to the programmer, so it’s only natural that it be wrapped in an interface to allow for greater code re-usability. So we go on our merry way and write the following code which receives packets from clients over TCP and pushes the data into a Set:

// run a TCP server and accept new connections
func listenAndServe(listener net.Listener, set Set[int]) error {
for {
conn, err := listener.Accept()
if err != nil {
return err
}

go handleConn(conn, set)
}
}

// handle a connection by adding the sent packet data to a set
func handleConn(conn net.Conn, set Set[int]) error {
for {
packet, err := getNextPacket(conn)
if err != nil {
return err
}

set.Add(packet.Info)
}
}

Looks good, right? So we release our application and all is well for a few weeks until we notice the server begins crashing with the following log:

fatal error: concurrent map writes

Weird, I thought the set was thread-safe! After some digging we discover the culprit, a coworker found your code useful and decided to re-use it elsewhere, only not knowing that Set must be thread safe! So how do we fix this, systematically? We could add the following guard-code:

// handle a connection by adding the sent packet data to a set
func handleConn(conn net.Conn, set Set) error {
if _, isThreadSafe := set.(ThreadSafeSet); !isThreadSafe {
return fmt.Errorf("Expected thread safe set, found: %T", set)
}

for {
packet, err := getNextPacket(conn)
if err != nil {
return err
}

set.Add(packet.Info)
}
}

But this isn’t much better, now we’ve replaced a runtime panic with a function which does nothing if passed the wrong implementation of the interface.

So how can this be fixed? Both runtime panics and runtime errors are undesirable, so there must be a better way. What if the Set type was never declared as an interface in the first place? Well, our code would now look like this:

// run a TCP server and accept new connections
func listenAndServe(listener net.Listener, set ThreadSafeSet[int]) error {
for {
conn, err := listener.Accept()
if err != nil {
return err
}

go handleConn(conn, set)
}
}

// handle a connection by adding the sent packet data to a set
func handleConn(conn net.Conn, set ThreadSafeSet[int]) error {
for {
packet, err := getNextPacket(conn)
if err != nil {
return err
}

set.Add(packet.Info)
}
}

Now imagine your coworker wants to re-use the code, it’s impossible not to use the ThreadSafeSet implementation and thus impossible to have such a bug! By removing the interface the code is actually more flexible and easier to read.

Another case against this interface, consider the runtime of the following function:

type Team struct {
names Set[string]
}

func (t Team) HasEmployeeNamed(name string) bool {
return t.names.Has(name)
}

You might be inclined to say that it’s O(1) because the team uses a HashSet which has constant time lookup. However you’d be wrong! The runtime of this code is actually indeterminate because we don’t know what implementation of Set the code is using! In order to confidently answer the question, we’d have to read the entire codebase and make sure that Team only ever uses an implementation of Set with O(1) lookup, which is quite a tedious job!

With all that being said, I hope you never ever in your entire career ever use an interface, ever. Just kidding! Interfaces are great when used correctly. Before declaring an interface, think about the following questions:

Do I care about the implementation/actual functionality of this type?

If the answer to this question is ever yes, you probably shouldn’t use an interface! Interfaces purposefully hide the implementation behind an abstraction layer which is counterproductive if it’s going to be removed anyways. For the Set example, we discussed why the implementation of this type is important so we’d answer yes to this question. If the answer to that question is no you still may want to consider:

Do I expect this type to change across different uses, or even at runtime?

If the answer to this question is no then you may not want to use an interface. However the answer to this question requires careful thought about your code’s functionality and can often be hard to answer especially in early phases of a project. Regarding Set, we probably are going to stick with a single implementation per use case as each use case will either require thread safety or it won’t, with no in-between. Another helpful question:

Will multiple pieces of my code implement similar functionalities that are used interchangeably?

If the answer to this question is again no, then an interface is almost certainly the wrong choice. If we ask this question about Set we may actually be tempted to answer yes. However, I’d argue that this isn’t the case with the key being that a thread safe and thread unsafe implementation likely won’t be used interchangeably. While the functionality is similar, it certainly isn’t interchangeable. The final question to ask is:

Is this code going to be run in a hot loop?

If your aim is to write performant code you may want to steer clear from interfaces. They add a nontrivial overhead to each method call which is worthy of consideration for the hottest portions of compute bound code. The data within an interface is (most likely) left behind a pointer which causes an extra de-reference on each access, not to mention that it kills cache coherency for iterations on such objects. For Set the answer to this question may actually be yes! Choosing a specialized data structure is typically done for performance based reasons, thus we should expect the users of our code to require high performance.

With this out of the way, let’s go over an instance in which an interface should be used. Let’s say you’re writing an HTTP server which can be configured either via command line arguments or using a config file. For simplicity, let’s assume the only configurable option is the port which the server will run on. We can write the following code:

const DefaultPort = 4567

type PortProvider interface {
Port() (portno uint16, ok bool)
}

func startServer(portProvider PortProvider) error {
portno := DefaultPort
if port, ok = portProvider.Port(); ok {
portno = port
}

return startServerOnPort(portno)
}

Now we can have two implementations:

type ConfigPortProvider struct {
PortNo *uint16 `toml:"Port"`
}

func (c ConfigPortProvider) Port() (uint16, bool) {
if c.PortNo != nil {
return *c.PortNo, true
}

return 0, false
}

type ArgsPortNo []string

func (p ArgsPortNo) Port() (uint16, bool) {
for idx, arg := range p {
if arg == "-p" && idx != len(p) - 1 {
portno, err := strconv.ParseUint(p[idx+1], 10, 16)
return uint16(portno), err == nil
}
}

return 0, false
}

And now, with the magic of interfaces, we can combine them into one which is consumable at the top level:

type ChainPortProvider []PortProvider

func (c ChainPortProvider) Port() (uint16, bool) {
for _, link := range c {
if port, ok := link.Port() {
return port, ok
}
}

return 0, false
}

This is one of my favorite design patterns! Chaining fail-able interface methods together into one interface is a great way to separate out the implementation of the common functionality from the combination of their results. In general, interface wrapping is a super powerful concept and can be used to compartmentalize complexity into atomic components. And if we ask our four questions on this example, we’ll see it pass with flying colors:

  1. Do I care about the implementation of this type? No. I just want a port number.
  2. Do I expect this type to change across uses or even at runtime? Yes. Across different runs we may configure the code either via a command line argument or from a toml file.
  3. Will multiple pieces of my code implement similar functionalities that are used interchangeably? Yes. ConfigPortProvider and ArgsPortProvider both implement similar functionalities and are required interchangeably.
  4. Is this code going to be run in a hot loop? No, it’s run at startup.

There are many more examples I could bring up for great interface usage, but so many great examples exist in the standard library (io.Reader/Writer, net.Dialer, error, etc.) that it’d be more productive for you to poke around and find some for yourself! Using interfaces properly is incredibly powerful, but you should always ask yourself, “Do I really need this interface{}”?