Interfaces in Go

Interfaces in Go

Part 1: What, How and Why?

If you study programming for any significant length of time, you will inevitably come across the concept of interfaces. When I initially learned about interfaces I discovered plenty of tutorials on the subject, but like many newcomers to programming, the bigger picture continued to elude me. In speaking with students that I've taught, this is common, partly because so many tutorials refer to interfaces as a "powerful" concept but then provide only the most basic examples, taking care to explain the What and How, but failing to explain the Why. It was like someone showing me how to drive a stick shift without explaining why I would ever use one over a vehicle with an automatic transmission.

The purpose of this 2 part tutorial is to explain the What, How, Where and Why. The 1st part covers the basics of interfaces, I use a simple example along with some analogies (I'm very big on analogies) to explain what they are, how you should view them, and why they are considered to be a powerful tool. The 2nd part will take a more in-depth look at interfaces with more complex examples, helping you understand where you should use them.

Interfaces are not unique to Go

As with many other programming concepts, the way you define and use interfaces can vary from language to language. Go takes an implicit approach to interfaces (more about that later). With that said, the general use case and purpose of interfaces are consistent across most languages.

The What?

The simple definition of an interface is a type that declares a behavior or set of behaviors as belonging to that interface. I believe it helps to imagine an interface as a generic label that can be applied to any type that implements the behaviors declared in the interface. For example, a person who engages in the behaviors of singing and playing the piano could be labeled as a musician. Someone who frequently cooks could be labeled as a chef. To understand how this analogy translates to programming, it's important that you first have a clear understanding of structs and methods.

Struct

As a quick refresher, in Go, a struct (short for "structure") is a type that gives you the ability to define a group of variables (aka fields) of different types and store them in a single entity.

type Rectange struct {
    width float64
    height float64
}

You can create an instance of that struct which can be passed around your code allowing you to access and update its fields.

func main() {

    // Create an instance of a rectange
    r := Rectangle{
        width: 4.0,
        height: 2.5,
    }

    // I can access the field to change its value
    r.width = 5.0

    // Or read the value
    fmt.Println(r.width)
}

If you imagine a variable as a container holding a value, then each instance of a struct is a container of containers, holding its own set of values.

Methods

A method (aka receiver function) is just a function that is bound to an instance of a struct, allowing you to access the fields of the struct within the function itself. Methods are like the verbs to a structs noun. Fields define what a struct is while methods define what a struct can do. Another way to put this is that a method is a behavior of a struct.

// Returns the area of a Rectangle
func (r *Rectangle) Area() float64 {
    return r.width * r.height
}

// Perimeter returns the total length of all sides of a Rectangle
func (r *Rectangle) Perimeter() float64 {
    return 2*r.width + 2*r.height
}

The Area method is one behavior I have defined for my Rectangle type, Perimeter is another. I can define other customs types that have a similar behavior...

type Triangle struct {
    base   float64
    height float64
}

type Circle struct {
    radius float64
}

// Returns the area of a Triangle.
func (t *Triangle) Area() float64 {
    return (t.base * t.height) / 2
}


// Returns the area of a Circle.
func (c *Circle) Area() float64 {
    return math.Pi * c.radius * c.radius
}

// Returns the circumference of a Circle
func (c *Circle) Perimeter() float64 {
    return 2 * math.Pi * c.radius
}

Hopefully, you've picked up on the fact that the term behavior can be used interchangeably with the term "function signature". The signature of a function is made up of 3 things:

  1. The function name

  2. The function parameters

  3. The function return type(s)

This makes sense considering that the function name, parameters and return type are a great indication of what the function does (assuming it was defined appropriately). If we go back and alter our definition of an interface to use the updated terminology, it would read something like...

"An interface is a generic label that can be applied to any type that has methods matching the function signature(s) declared in the interface"

The How?

Let's create an interface that could be applied to all 3 of our custom types

type Shape interface {
    Area() float64
}

We've created a Shape interface with one function, Area which returns a float64. If a type has the functions declared in the interface, it is said to implement that interface. In other words, if a type has a method called Area which takes no parameters and returns a float64, the type implements the Shape interface.

❗️ IMPORTANT ❗️

The act of implementing an interface is an all-or-nothing deal. A type must have ALL the functions of an interface to implement it, otherwise, it does not implement it all.

If a type has additional methods that are NOT declared in the interface, it still implements the interface as long as it has at least the functions in the interface.

Example:

Interface Alpha declares functions A() and B()

struct type One has methods A(),B () and C()

struct type Two has methods A() and B()

struct type Three has method A()


Both One and Two implement Alpha since they both have methods A() and B()

Three does not implement Alpha since it only has method A()

We can now define fields and variables of type Shape the same way we would use any other type in Go. We can use it as a function argument or return type. We can also use it as the type for values stored in a composite data type.

// GetArea returns the area of whatever shape was passed into it
func GetArea(s Shape) float64 {
 return s.Area()
}

// Total calculates the total area of a list of shapes.
func Total(shapes []Shape) float64 {
    var total float64
    for _, s := range shapes {
        total += s.Area()
    }
    return total
}


// NOTE: I can avoid writing 2 separate functions by using a variadic function that takes a variable amount of shapes

// TotalArea calculates the total area of a list of shapes. 
// It can accept 1 shape or a slice of shapes
func TotalArea(shapes ...Shape) float64 {
    var total float64
    for _, s := range shapes {
        total += s.Area()
    }
    return total
}

The benefit of using the Shape interface is that we can now substitute Shape with any type that implements the Shape interface. The compiler will treat that value as type Shape, meaning that it only knows about the functions declared in the interface. Calling the Area function will result in calling the function of the underlying type, known as the concrete type. In other words, you can pass an instance of Rectangle to a function that accepts a Shape. You can then call the Area function on that value and the compiler will call the Area function of the underlying Rectangle. If you try to call the Perimeter method on that value, the program will panic because the compiler does not view it as a Rectangle. It views it as a Shape and only knows about the Area method.

func main() {
    c := Circle{radius: 5}
    r := Rectangle{height: 10, width: 5}
    t := Triangle{base: 10, height: 5}

    shapes := []Shape{c, r, t}
    total := TotalArea(shapes)

    fmt.Println("Total Area: ", total)
}

The Why?

When used correctly, interfaces enable you to write code in a way that takes advantage of well-established patterns and principles. Stay tuned for future articles on patterns and principles, but for now, just know that the ultimate goal of all patterns/principles is to write clean maintainable code that is efficient, easy to read and easily updated.

Interfaces are core to some of the most popular patterns, in part, because they prevent duplication of code. The final result is improved readability and a reduction in the number of changes one would need to make if that duplicated code were to change. That is the case with our example. Rather than write a function that uses type assertion (more about that in part 2) or accepts 3 parameters (each a slice of a different type), we can write a function that accepts only 1 parameter, a slice of Shape. The function then calls the Area method on each item of the slice. Our function doesn't need any additional code to try and differentiate a Rectangle from a Triangle or Circle. We simply pass them all in as a Shape and let the compiler do the rest.

At this point, you should understand how to declare and define an interface as well as how to create custom types that implement an interface. You should also understand that interfaces are a tool that can aid in writing clean maintainable code.

Stay tuned for part 2 where I will provide more advanced, real-life examples that illustrate where to user interfaces.

Did you find this article valuable?

Support Gerald Parker by becoming a sponsor. Any amount is appreciated!