Interfaces in Golang

Interfaces in Golang

What are interfaces, and how do they work

ยท

7 min read

If you're coming from Java, you definitely know about interfaces. If you're coming from Python, you're probably scratching your head. But, no matter which language you're coming from, you'll be surprised about how Go implements interfaces.

An interface is a type in Go. But, unlike the struct type, the interface type is not concerned with state, but with behavior.

For example, a Dog struct would look like this:

type Dog struct {
    name     string
    age       int
    gender  string
    isHungry bool
}

A Dog interface on the other hand would look like this:

type Dog interface {
    barks()
    eats()
}

The struct shows us some attributes of a dog, but the interface describes what this dog is supposed to do.

Now, let's say we're writing an application about .. well... dogs! We'll have a lot of different breeds. We know that Go does not support inheritance like OOP languages do , so instead of naming the struct Dog, let's name it Labrador, and as our app grows, we'll probably add more breeds.

type Labrador struct {
    name     string
    age    int
    gender string
    isHungry bool
}

For the purpose of our app, we'll want to split these different dogs into two groups - big dogs and small dogs ( suppose we want to know how much food each group needs). We need a function that adds a dog (no matter the breed) , to a group:

func addToGroup(d Dog, group []Dog) []Dog {
    group = append(group, d)
    return group
}

This function is basically just a wrapper to the built-in append function, but for the sake of simplicity let's assume it's some super complex algorithm.

Notice that the addToGroup function only accepts the Dog interface, and a slice of Dogs. Nowhere is the Labrador type mentioned. But, what if we want to add a Labrador named Max to a group called Big dogs? How will the Go compiler know that Labs are dogs?

Easy, Golang asynchronously googles " are labs dogs? " , and then based on that answer it knows. On the odd occasion that Google is down, it goes to Wikipedia. Just make sure you have an internet connection or it will not know.

Ok, probably no one laughed.

In all seriousness though, the Go compiler will know that the Labrador type implements the Dog interface (and thus gains access to all functions that accept a Dog interface) only if the Labrador type implements the methods described in the Dog interface.

In Java, it's a bit more explicit. You would type class Labrador implements Dog and that would already indicate what you are doing. You would have to implement the methods also, but in Golang you only implement the methods, and implicitly your Lab becomes a Dog.

So let's implement the barks() and eats() methods so that the Go compiler will know what a Labrador is:

func (l Labrador) barks() {
    fmt.Println(l.name + " says woof")
}

func (l Labrador) eats() {
    if l.isHungry {
        fmt.Println(l.name + " is eating. Since he is a labrador, give him xxx brand of food.")
    } else {
        fmt.Println(l.name + " already ate. Come back later.")
    }
}

Not really intelligent business logic, I admit, but as you know tutorials tend to keep it as simple as possible.

Now that the Labrador struct implements both methods specified in the Dog interface, it has access to all functions that accept type Dog as an argument.

Let's see some output in our terminal by adding all of this to our main function:

func main() {

    bigDogs := []Dog{}
    max := Labrador{
        name:     "Max",
        age:      5,
        gender:   "Male",
        isHungry: true,
    }

    max.barks()
    max.eats()
    fmt.Println("Our group of big dogs:", bigDogs)
    bigDogs = addToGroup(max, bigDogs)
    fmt.Println("Our group of big dogs now:", bigDogs)
}

We first created a slice of Dogs called bigDogs where we will store all of our big dogs . Then we create a Labrador called Max. We call the barks() and eats() methods just to see that everything works (and also to feed poor Max, since his isHungry attribute is always set to true) .

It works, so let's add Max to our group of big dogs. Had he been a Chihuahua, he probably wouldn't fit in.

As we see , the addToGroup function gladly accepts Max the Lab, since it is clear to the Go compiler that Max is a Lab , and a Lab is a Dog.

But, this is just a toy example. Where does all this actually get used in production code?

Literally everywhere.

For example, the io.Reader and io.Writer interfaces are used all the time. Whenever some type is used for reading some data (wherever it may come from), odds are it is implementing the io.Reader interface. If it is writing somewhere (to standard out, to a file etc.) , it is most likely implementing the io.Writer interface.

They are actually pretty simple to implement. Here is the io.Reader interface:

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

That's it. Any type that wants access to the functions that accept io.Reader as an argument (and there are quite a few), just needs to implement the Write method. Now, this doesn't mean that this implementation will necessarily be good. Interfaces work in a garbage in , garbage out manner, so if you implement the Write method poorly, you won't get the behavior you're expecting.

Another thing to consider is that in order to implement an interface, the methods must have the exact same signature as described in the interface. If your Write method does not accept a byte slice and return an integer and an error, you didn't do it right.

It's also worth mentioning that you can embed interfaces also. Get a load of this:

type ReadWriter interface {
    Reader
    Writer
}

The ReadWriter interface implements both the Reader and the Writer interfaces, meaning any type that wants access to functions that take ReadWriter as an argument must implement all methods from the Reader and from the Writer interfaces (which are a total of only two methods, luckily) .

Finally, I want to talk about the empty interface. Let's say you wan't to use a hashmap, but you want to use various data types for the map's values. Since the hashmap is statically typed, you need to give it some type, so that would probably disable you from using more than one type. This is where the empty interface comes in.

An empty interface doesn't implement any behavior, so basically any data type satisfies that, just by merely existing.

It's kind of a hack tbh, but it can come handy sometimes, and it helps developers who are migrating from loosely typed languages like Python and JavaScript.

You would define your map like this:

maxMap := map[string]interface{}{}

The two curly braces at the end look a bit strange , right? The first one belongs to the empty interface, and the second instantiates the map.

For better readability, you can make use of the make function:

maxMap := make(map[string]interface{})

For even better readability , you can create your own type, which will basically just implement the empty interface:

type Any interface{}

maxMap := map[string]Any{}

And now we can assign different data types as the values of our map, without the Go compiler being in a nasty mood:

maxMap["name"] = "some name"
maxMap["age"] = 5
maxMap["gender"] = "Male"
maxMap["isHungry"] = true

fmt.Println(maxMap)

I personally thought all of this interface logic was really confusing and unnecessarily complex at first (especially coming from Java, where interfaces are more explicit) , but it actually makes sense after time.

The good thing is that you probably won't be writing a lot of your own interfaces, at least not for most day to day features.

But, it is something you definitely need to know, as it will be appearing all the time in code that you use from modules in the standard library. Just look at the http module . It's flooded with interfaces. Understanding them will help you in debugging a lot.

That's all folks, thanks for reading!

Did you find this article valuable?

Support Pavle Djuric by becoming a sponsor. Any amount is appreciated!

ย