OOP Patterns in Go: Methods, Interfaces and Type Embedding

OOP Patterns in Go: Methods, Interfaces and Type Embedding

In Go, there are no explicit definitions for Class, Object and Inheritance like in traditional OOP languages but it offers similar features with a unique take.

Methods, Interfaces and Type Embedding are the building blocks for the OOP-like structure in Golang. Let's have a look at how they work.

Methods

  • A method is simply a function with a receiver argument.

  • Methods are bound to the type. So, we can't call methods directly like functions. It should be done through an instance of the specific type.

  • We can attach multiple methods to the type.

  • Not just limited to just structs. Methods can be defined for any type except pointer or interface.

Methods allow us to add actions/behaviors to our type just like class, object and method.

type plane struct {
   // similar to Class attributes
   passengers int
}

// receiver p is of type 'plane'
// fly() has a value receiver
func (p plane) fly() {
  fmt.Println("air: flying with", h.passengers, "passengers")
}

func main() {
    // p is like a instance of plane
    p := plane{passengers: 100}
    // similar to class method
    p.fly()
}

Pointer vs Value Receivers in Method

Use a pointer receiver if you want to modify the original data of the receiver.

// Here the method is expecting a pointer receiver
func (p *plane) changeStatus() {
    // this will modify original value of p even outside this function
    p.status = "flying"
}

Interesting fact:

No matter whether the method is a pointer or a value receiver, we can use both value or pointer to call methods as shown below:

// ------- both will work -------
// using value
p := plane{passengers: 100}
p.fly()

// using pointer
p := &plane{passengers: 100}
p.fly()

So, we can have multiple methods in the same type, some being pointer receivers and some value receivers depending on their use case.


Interfaces

Interface in Go is a set of method signatures. It is very useful because it helps us to write loosely coupled, testable code.

Implementing an interface simply means implementing all of the methods defined by the interface. There is no other syntax to define the relationship. This is determined by Go implicitly.

In the example below:

  • flyer interface has a method called fly().
    Struct plane and heli are our custom types which have their implementation of the fly() function i.e. different behavior.

  • Function flyPassengers(f flyer) is expecting an interface of type flyer and it invokes the fly() method.

  • Since both plane and heli implement the interface, flyPassenger function will accept both instances of plane and heli structs passed as the parameter.
    Having said that, its behavior will depend on the concrete type it received and how they have implemented the fly() method. This is an example of polymorphism.

  • Due to this polymorphic nature, we can use interfaces in dependency injection and mocking methods for tests.

package main

import "fmt"

type flyer interface {
    fly()
}

type plane struct {
    passengers int
}

// type plane satisfied flyer interface with fly() fn
func (p plane) fly() {
    fmt.Println("plane: flying with", p.passengers, "passengers")
}

type heli struct {
    passengers int
}

// type heli also satisfies flyer interface with fly() fn
func (h heli) fly() {
    fmt.Println("heli: flying with", h.passengers, "passengers")
}

// this is an extra method which is not present in the interface 
//    but it will still satify the interface
func (h heli) hover() {
    fmt.Println("heli: hovering")
}

// flyPassengers accepts any type which implements the flyer interface
// example of polymorphic function
func flyPassengers(f flyer) {
    f.fly()
}

func main() {
    a := plane{passengers: 100}
    h := heli{passengers: 5}
    // plane and heli are acceptable types for flyPassengers()
    flyPassengers(a)
    flyPassengers(h)
}

Interface implementation: Value vs Pointer Receivers

  • A receiver's type is important in interface implementation.

  • If we use a pointer receiver, e.g. func (p *plane) fly(), only pointers will satisfy the interface. This means in the function which is expecting interface type, we will have to pass a pointer: flyPassengers(&p)

  • Similarly for value type, flyPassengers will expect a value rather than a pointer.

type plane struct {
    passengers int
}
func (p *plane) fly() {
    fmt.Println("plane: flying with", p.passengers, "passengers")
}
// function expects a pointer to interface
flyPassengers(&p)

If we have additional methods in our type, will it still satisfy the interface?

Yes, as long as you implement all the methods listed by the interface. In the first example, you can see that we have hover() method in heli struct. Since heli implements fly() method, it satisfies the interface, so the other additional methods in heli struct won't matter.


Type Embedding

type aircraft struct {
    company string
}

// plane is outer type
type plane struct {
    aircraft // inner type
    passengers int
}

// You can get a hint of parent-child class.
// Usage eg: 
p := plane{
   aircraft: aircraft{company: "Airbus"}
   passengers: 100
}
p.aircraft.company

Embedding is a way to combine methods from structs and interfaces.

If the embedded inner type satisfies the interface, the outer type will also indirectly satisfy the interface even if it does not implement all interface methods.

Interfaces can embed interfaces only.

Example:

package main

import "fmt"

type flyer interface {
    fly()
}

// satisfies the interface
type aircraft struct {
    company string
}

func (a aircraft) fly() {
    fmt.Println("aircraft: flying", a.company)
}

// plane is the 'parent' type
type plane struct {
    aircraft // embedded type
    passengers int
}

// it will still work if we comment out fly method from plane, 
// because the embedded type 'aircraft' will satisfy flyer interface
func (p plane) fly() {
    fmt.Println("plane: flying", p.aircraft.company)
}

func flyPassengers(f flyer) {
    // calls p.fly() if plane implements fly()
    // or calls p.aircraft.fly()
    f.fly()
}

func main() {
    p := plane{
        aircraft:   aircraft{company: "Airbus"},
        passengers: 100,
    }
    fmt.Println("directly accessing embedded type:", p.aircraft.company)
    flyPassengers(p)
}
// The parent/outer type 'plane' will overwrite fly() method 
// from embedded type 'aircarft' and the output will be: 
//    "plane: flying Airbus"

Effect on interface implementation:

As shown in the first example, if we comment out or delete fly() method from plane struct, fly() method from the embedded type aircraft will be called instead and the output will be:
aircraft: flying Airbus

This means the parent type plane will satisfy the interface indirectly if the embedded type implements the interface.


Constructors

In Go, there is a convention to create a constructor from a function named New or New{TYPE_NAME} .

For example, if you have a package name client, you will ideally have a code structure like below:

pacakge client

type Client struct {
    TimeoutSeconds int
}

// constructure returns empty initialized instance of Client
// the fn can be called as packageName.New() e.g. client.New()
func New()*Client {
   return &Client{}
}

But if you have the possibility of another object initialization in the same package, it is better to name the function as NewClient to avoid clashes.

References:

Did you find this article valuable?

Support Nirdosh Gautam by becoming a sponsor. Any amount is appreciated!