Go Interfaces

Unlock the Power of Golang Interfaces: Simplifying Abstractions and Flexibility in Your Codebase

  ·   11 min read

One of the most appealing features of Go is the concept of Interfaces. Go borrows interesting ideas from different systems to make them powerful enough to enable developers to simplify their abstractions and make the system flexible to allow polymorphism.

Basic Concepts

What’s a Go Interface

An interface type is defined as a set of method signatures. [1]

An interface is an abstract concept which enables polymorphism in Go. [2]

In Go, every type has an interface, ‘a set of methods’ defined for that type. Let’s define a struct S with a field and two defined methods:

 1type S struct {
 2  n int
 3}
 4
 5func(p *S) Get() int {
 6  return p.n
 7}
 8
 9func(p *S) Set(v int) {
10  p.n = v
11}

As it is, S already implements the empty interface (more about it below), making our statement that ’every type has an interface’ to be true. However, we can define an interface I with two method signatures as follows.

1type I interface {
2  Get() int
3  Set(v int)
4}

Implicit interface

As it is, S also implements the I interface, because it defines the methods required by I. Why is that? A type implements an interface by defining the methods it enforces. “There is no explicit declaration of intent, no ‘implements’ keyword”. The design behind the implicit interface is meant to decouple the definition from its implementation. [3]

Interface values

Interface values can be thought of as a tuple of a value and a concrete type. [4]

In Go, interface values have a concrete value and a dynamic type. [2]

1func f(p I) {
2  fmt.Println(p.Get())
3  p.Put(1)
4}

Here, the variable p will hold a value of interface I type.

1var s S
2f(&s)

As S implements I, we can call f passing in a pointer to a value of type S. It is not strictly necessary to pass a pointer; only the Get would work as expected, whereas Put won’t. Thus, besides the fact that we have defined it to operate on pointers, we prefer to do so. [5]

This might sound familiar because, in Go, you don’t need to explicitly declare that a type implements an interface, resembling a form of duck typing. However, it’s not purely duck typing, as the Go compiler can statically check if a type implements the interface. This is known as Structural Typing in contrast to Normal Typing as seen in C++, and Duck Typing as in Python. Still, Go has a purely dynamic aspect: you can convert between interfaces for a given type, and this is checked solely at runtime. If the condition isn’t met, the program will produce a runtime error. [5]

Underlying nil values

If the concrete value for an interface is nil, their methods will be called with a nil receiver.

In Go, this allows to write methods that gracefully handle being called with a nil receiver.

Let’s analyze the following code (taken from A Tour of Go - Interface values with nil underlying values)

 1type I interface {
 2  M()
 3}
 4
 5type T struct {
 6  S string
 7}
 8
 9func(t *T) M() {
10  if t == nil {
11  fmt.Println("<nil>")
12    return
13  }
14  fmt.Println(t.S)
15}
16
17func describe(i I) {
18  fmt.Printf("(%v, %T)\n", i, i)
19}

Here it’s clear that T implements the interface I as it has a method M that complies with the given interface’s contract.

Now, let’s assume the following code inside the main function.

1var i I
2
3i = &T{"hello"}
4describe(i)
5i.M()

This yields the following output.

(&{hello}, *main.T)
hello

It’s quite obvious to understand that the dynamic value is a *T and its concrete values is an instance of that type.

Now, let’s check the following code.

1var t *T
2i = t
3describe(i)
4i.M()

In contrast, this one yields the following output.

(<nil>, *main.T)
<nil>

Under this circumstance, other languages would throw a nil pointer exception, but in Go, this is perfectly handled by methods by assessing the receiver was called with a nil receiver. Nonetheless, note that the interface value that holds the nil concrete type is itself non-nil. [6]

But, how it would be if the dynamic type is also nil?

Nil interface values

Nil interface values are those that hold neither value nor concrete type. [7]

Let’s suppose the following code in the main function.

1var i I
2describe(i)
3i.M()

This yields the following output.

(<nil>, <nil>)
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x490339]

goroutine 1 [running]:
main.main()
 /tmp/sandbox2509058701/prog.go:12 +0x19

Calling a method on a nil interface yields a run-time error as there is no type inside the interface tuple to indicate which concrete method to call. [7]

The empty interface

The name is self-explanatory: an interface with no methods declared in it. Just that.

1interface{}

An empty interface may hold values of any type. If you think about it, all the types implement at least zero methods 😄.

Why is it so powerful? Sometimes, you simply don’t know what you are dealing with. Take for instance fmt.Println you may pass a string, an int, or structs.

Pro tip: any is an alias for an empty interface 1.

Implementing multiple interfaces in Go

I wish (https://i.imgflip.com/6bxbvs.jpg)
I wish

In Go you can implement multiple interfaces at the same time. If all the functions defined by a type complies with all the functions of different interfaces, then the type implements all of those.

 1type(
 2  Walker interface {
 3    Walk()
 4  }
 5
 6  Flyer interface {
 7    Fly()
 8  }
 9
10  Swimmer interface {
11    Swim()
12  }
13)
14
15type Duck struct {
16  S name
17}
18
19func(d *Duck) Walk() {
20  // ...
21}
22
23func(d *Duck) Fly() {
24  // ...
25}
26
27func(d *Duck) Swim() {
28  // ...
29}

Interfaces belong to Consumers, not Producers.

Folks coming from other languages like Java, or C++ are likely to follow the pattern where they first define abstract types or interfaces, and then implement them concretely. This pattern is necessary because these languages generate a static dispatch table (e.g., like a vtable in C++) during compilation. The compiler requires explicit declaration of the interfaces a type implements, allowing it to construct a vtable, storing pointers to each available virtual function. This table enables polymorphism at runtime by dynamically calling the appropriate method based on the actual object’s type.

In Go we don’t have a traditional dispatch table. As we have already discussed, it can rely on the interface values during a method dispatch. The work is done during the interface value assignment through a tiny hash-table lookup to check for the concrete type it’s pointing to [8].

By defining interfaces on the consumer side we provide users with a certain level of “Inversion of Control” [9]. They define their abstractions rather than having a producer forcing them to use their idea of an abstraction. Additionally, we minimize the presumptions about the way a package is consumed.

This approach also makes it easier to add new methods to implementations without affecting any consumers.

“The bigger the interface, the weaker the abstraction” - Rob Pike

Back in 2015 Rob Pike succinctly said that “The bigger the interface, the weaker the abstraction”. It has become one of the most known Go Proverbs.

Let’s review for a moment both the io.Reader and the io.Writer interfaces in Go’s standard library.

io.Reader

1type Reader interface {
2  Read(p []byte) (n int, err error)
3}

The io.Reader interface represents anything that can read a stream of bytes. This is useful for reading from different sources like files, network connections, or even memory. It only has one single method: Read, which takes a slice of bytes (p []byte) as an argument, it acts as a container for the data to be read. It fills up p with data and then returns how many bytes were read, or an error if it’s the case.

This interface is extremely powerful because it provides the flexibility to allow any type to implement the Read wherever an io.Reader is expected.

io.Writer

1type Writer interface {
2  Write(p []byte) (n int, err error)
3}

The io.Writer interface represents anything that can receive and store a stream of bytes. This is useful for sending data to files, network connections, memory buffers, or even the console. It only has one single method: Write, which takes a slice of bytes (p []byte) as an argument and writes it to a destination.

This interface is extremely powerful because it provides the flexibility to allow writing data to various outputs without needing to know exactly where the data is going.

Mixing io.Reader and io.Writer Together

Let’s implement a function that copies the content of a file into another. We might create a function that takes two *os.File. Or, we could implement a more generic approach using the abstraction that both io.Reader and io.Writer provide.

1func Copy(src io.Reader, trgt io.Writer) error {
2  // ...
3}

Does it work to copy from one file into another? It perfectly does! Why? Because *os.File implements Read and Write. So it is, at least, a ReadWriter.

What about testing?

 1func TestCopySourceToDest(t *testing.T) {
 2  const input = "foo"
 3  source := strings.NewReader(input) // Creates an io.Reader
 4  dest := bytes.NewBuffer(make([]byte, 0)) // Creates an io.Writer
 5
 6  err := copySourceToDest(source, dest) // Calls copySourceToDest from a *strings.Reader and a *bytes.Buffer
 7  if err != nil {
 8    t.FailNow()
 9  }
10
11  got := dest.String()
12  if got != input {
13    t.Errorf("expected: %s, got: %s", input, got)
14  }
15}

Do we need to test reading from an actual file and writing it into another one? No. We only need to test the function’s behavior. We have made clear *os.File implements both, but it is not the only one (this is the power of abstraction). The package strings also has a reader that implements io.Reader and the package bytes has a buffer that implements io.Writer. Our test perfectly shows taking a string reader and a buffer to write a given content. The important thing here is not what the source and target are, the important thing is that we take content from a source and write it into a target [10].

The Power of Single Method Interfaces (SMI)

In functional languages, High Order Functions [11] are heavily used to achieve useful abstractions by means of closures. In Go, you can use them, but most Go code prefers to use SMI mainly for two reasons:

  1. SMIs naturally extend to multi-method interfaces. [12]
  2. Go values can implement more than one interface. [12]

Nonetheless, while SMIs are more general and powerful, they are also more verbose.

What to use then? Simple. If all you need is a function, then use a function! Otherwise, explore SMIs as they provide enough abstraction to get the job done.

Interface Pollution

Interfaces are all about creating abstractions. As such, they are meant to be discovered, not designed or created prematurely. We define them when we need them, not when we think we might need them.

Interfaces are powerful, but overusing them can clutter our code with unnecessary complexity. Excessive levels of indirection can do more harm than good, making the code unreadable and harder to reason about.

This also means that you shouldn’t export any interfaces until you have to [8]. If you think that “maybe” you should export a generic functionality, you are likely to pollute your code with useless interfaces no one is going to use. You need to justify such a need. This is precisely why in Go we prefer to accept interfaces but return concrete types 2.

Conclusion

The interfaces concept is a powerful tool in Go that allows us to abstract the behavior of a system. However, overusing them makes the code hard to reason about. We can guarantee a good abstraction level by keeping interfaces as short as possible (hopefully striving for SMIs). To maintain a clean code, we prefer to accept interfaces defined on the consumer side, returning concrete types to avoid polluting our codebase with useless interfaces no one is going to use. In the end, make the coding process enjoyable so you get to discover your interfaces rather than try to force them with worthless designs.

Bibliography

  1. A Tour of Go - Interfaces, Oct. 2024. [Online]. Available: https://go.dev/tour/methods/9
  2. Interfaces in Golang - Golang Docs, Jan. 2020. [Online]. Available: https://golangdocs.com/interfaces-in-golang
  3. A Tour of Go - Interfaces are implemented implicitly, Oct. 2024. [Online]. Available: https://go.dev/tour/methods/10
  4. A Tour of Go - Interface values, Oct. 2024. [Online]. Available: https://go.dev/tour/methods/11
  5. C. Paar and J. Pelzl, Go Interfaces, Oct. 2024. [Online]. Available: https://www.airs.com/blog/archives/277
  6. A Tour of Go - Interface values with nil underlying values, Nov. 2024. [Online]. Available: https://go.dev/tour/methods/12
  7. A Tour of Go - Nil interface values, Nov. 2024. [Online]. Available: https://go.dev/tour/methods/13
  8. Interface pollution in Go rakyll.org, Oct. 2014. [Online]. Available: https://rakyll.org/interface-pollution
  9. Martin Fowler: Inversion Of Control, Jun. 2005. [Online]. Available: https://martinfowler.com/bliki/InversionOfControl.html
  10. Interface pollution (#5) - 100 Go Mistakes and How to Avoid Them, Oct. 2024. [Online]. Available: https://100go.co/5-interface-pollution
  11. Higher-order Programming, Nov. 2024. [Online]. Available: https://www.cs.cornell.edu/courses/cs3110/2017fa/l/06-hop/notes.html
  12. The power of single-method interfaces in Go - Eli Bendersky’s website, Mar. 2023. [Online]. Available: https://eli.thegreenplace.net/2023/the-power-of-single-method-interfaces-in-go