Interfaces

10 min read

Authors
banner

In this section, let's talk about the interfaces.

What is an interface?

So, an interface in Go is an abstract type that is defined using a set of method signatures. The interface defines the behavior for similar types of objects.

Here, behavior is a key term that we will discuss shortly.

Let's take a look at an example to understand this better.

One of the best real-world examples of interfaces is the power socket. Imagine that we need to connect different devices to the power socket.

no-interface

Let's try to implement this. Here are the device types we will be using.

type mobile struct {
	brand string
}

type laptop struct {
	cpu string
}

type toaster struct {
	amount int
}

type kettle struct {
	quantity string
}

type socket struct{}

Now, let's define a Draw method on a type, let's say mobile. Here we will simply print the properties of the type.

func (m mobile) Draw(power int) {
	fmt.Printf("%T -> brand: %s, power: %d", m, m.brand, power)
}

Great, now we will define the Plug method on the socket type which accepts our mobile type as an argument.

func (socket) Plug(device mobile, power int) {
	device.Draw(power)
}

Let's try to "connect" or "plug in" the mobile type to our socket type in the main function.

package main

import "fmt"

func main() {
	m := mobile{"Apple"}

	s := socket{}
	s.Plug(m, 10)
}

And if we run this we'll see the following.

$ go run main.go
main.mobile -> brand: Apple, power: 10

This is interesting, but let's say now we want to connect our laptop type.

package main

import "fmt"

func main() {
	m := mobile{"Apple"}
	l := laptop{"Intel i9"}

	s := socket{}

	s.Plug(m, 10)
	s.Plug(l, 50) // Error: cannot use l as mobile value in argument
}

As we can see, this will throw an error.

What should we do now? Define another method? Such as PlugLaptop?

Sure, but then every time we add a new device type we will need to add a new method to the socket type as well and that's not ideal.

This is where the interface comes in. Essentially, we want to define a contract that, in the future, must be implemented.

We can simply define an interface such as PowerDrawer and use it in our Plug function to allow any device that satisfies the criteria, which is that the type must have a Draw method matching the signature that the interface requires.

And anyways, the socket doesn't need to know anything about our device and can simply call the Draw method.

interface

Now let's try to implement our PowerDrawer interface. Here's how it will look.

The convention is to use "-er" as a suffix in the name. And as we discussed earlier, an interface should only describe the expected behavior. Which in our case is the Draw method.

interface-implementation
type PowerDrawer interface {
	Draw(power int)
}

Now, we need to update our Plug method to accept a device that implements the PowerDrawer interface as an argument.

func (socket) Plug(device PowerDrawer, power int) {
	device.Draw(power)
}

And to satisfy the interface, we can simply add Draw methods to all the device types.

type mobile struct {
	brand string
}

func (m mobile) Draw(power int) {
	fmt.Printf("%T -> brand: %s, power: %d\n", m, m.brand, power)
}

type laptop struct {
	cpu string
}

func (l laptop) Draw(power int) {
	fmt.Printf("%T -> cpu: %s, power: %d\n", l, l.cpu, power)
}

type toaster struct {
	amount int
}

func (t toaster) Draw(power int) {
	fmt.Printf("%T -> amount: %d, power: %d\n", t, t.amount, power)
}

type kettle struct {
	quantity string
}

func (k kettle) Draw(power int) {
	fmt.Printf("%T -> quantity: %s, power: %d\n", k, k.quantity, power)
}

Now, we can connect all our devices to the socket with the help of our interface!

func main() {
	m := mobile{"Apple"}
	l := laptop{"Intel i9"}
	t := toaster{4}
	k := kettle{"50%"}

	s := socket{}

	s.Plug(m, 10)
	s.Plug(l, 50)
	s.Plug(t, 30)
	s.Plug(k, 25)
}

And it works just as we expected.

$ go run main.go
main.mobile -> brand: Apple, power: 10
main.laptop -> cpu: Intel i9, power: 50
main.toaster -> amount: 4, power: 30
main.kettle -> quantity: Half Empty, power: 25

But why is this considered such a powerful concept?

Well, an interface can help us decouple our types. For example, because we have the interface, we don't need to update our socket implementation. We can just define a new device type with a Draw method.

Unlike other languages, Go Interfaces are implemented implicitly, so we don't need something like an implements keyword. This means that a type satisfies an interface automatically when it has "all the methods" of the interface.

Empty Interface

Next, let's talk about the empty interface. An empty interface can take on a value of any type.

Here's how we declare it.

var x interface{}

But why do we need it?

Empty interfaces can be used to handle values of unknown types.

Some examples are:

  • Reading heterogeneous data from an API.
  • Variables of an unknown type, like in the fmt.Println function.

To use a value of type empty interface{}, we can use type assertion or a type switch to determine the type of the value.

Type Assertion

A type assertion provides access to an interface value's underlying concrete value.

For example:

func main() {
	var i interface{} = "hello"

	s := i.(string)
	fmt.Println(s)
}

This statement asserts that the interface value holds a concrete type and assigns the underlying type value to the variable.

We can also test whether an interface value holds a specific type.

A type assertion can return two values:

  • The first one is the underlying value.
  • The second is a boolean value that reports whether the assertion succeeded.
s, ok := i.(string)
fmt.Println(s, ok)

This can help us test whether an interface value holds a specific type or not.

In a way, this is similar to how we read values from a map.

And If this is not the case then, ok will be false and the value will be the zero value of the type, and no panic will occur.

f, ok := i.(float64)
fmt.Println(f, ok)

But if the interface does not hold the type, the statement will trigger a panic.

f = i.(float64)
fmt.Println(f) // Panic!
$ go run main.go
hello
hello true
0 false
panic: interface conversion: interface {} is string, not float64

Type Switch

Here, a switch statement can be used to determine the type of a variable of type empty interface{}.

var t interface{}
t = "hello"

switch t := t.(type) {
case string:
	fmt.Printf("string: %s\n", t)
case bool:
	fmt.Printf("boolean: %v\n", t)
case int:
	fmt.Printf("integer: %d\n", t)
default:
	fmt.Printf("unexpected: %T\n", t)
}

And if we run this, we can verify that we have a string type.

$ go run main.go
string: hello

Properties

Let's discuss some properties of interfaces.

Zero value

The zero value of an interface is nil.

package main

import "fmt"

type MyInterface interface {
	Method()
}

func main() {
	var i MyInterface

	fmt.Println(i) // Output: <nil>
}

Embedding

We can embed interfaces like structs. For example:

type interface1 interface {
    Method1()
}

type interface2 interface {
    Method2()
}

type interface3 interface {
    interface1
    interface2
}

Values

Interface values are comparable.

package main

import "fmt"

type MyInterface interface {
	Method()
}

type MyType struct{}

func (MyType) Method() {}

func main() {
	t := MyType{}
	var i MyInterface = MyType{}

	fmt.Println(t == i)
}

Interface Values

Under the hood, an interface value can be thought of as a tuple consisting of a value and a concrete type.

package main

import "fmt"

type MyInterface interface {
	Method()
}

type MyType struct {
	property int
}

func (MyType) Method() {}

func main() {
	var i MyInterface

	i = MyType{10}

	fmt.Printf("(%v, %T)\n", i, i) // Output: ({10}, main.MyType)
}

With that, we covered interfaces in Go.

It's a really powerful feature, but remember, "Bigger the interface, the weaker the abstraction" - Rob Pike.

© 2024 Karan Pratap Singh