Unlocking Flexibility and Extensibility in Your Programs with Go s
Published on Sep 25, 2023

From https://blog.theodo.com/2022/08/go-nil-interfaces/
In the ever-evolving software development landscape, adaptability and extensibility are key virtues. Whether you're a seasoned developer or just starting your coding journey, harnessing the power of Go's interfaces can significantly enhance your ability to build flexible and extensible programs. In this blog, we embark on a journey through the intricacies of Go's interface system, uncovering the secrets that enable you to create versatile and robust software solutions. Join us as we dig deep into the world of Go and discover how interfaces can be your secret weapon in crafting software that stands the test of time.
Introduction
Go is not an object-oriented language, that is known. Polymorphism is a feature of object-oriented languages, and Go does not have classes. However, Go does have interfaces, and interfaces are a way to achieve polymorphism in Go. An interface is a collection of method signatures that a type can implement. Interfaces are a way to define behaviour, and they are a powerful tool in Go. This means you can write code that is more flexible and adaptable by using interfaces.
What is Polymorphism?
Polymorphism is the ability of an object to take on many forms. In object-oriented programming, polymorphism is a feature that allows objects of different types to be treated as the same type. In other words, polymorphism allows you to define one interface and have multiple implementations. The word “poly” means many and “morphs” means forms, so polymorphism means many forms. This means that a single function or method can be used to do different things depending on the type of data that it is acting upon.
For example, you can have a Car
interface that has a
drive()
method. You can then have a lambo
and
chevy
type that both implement the Car
interface. The
drive()
method for each type can be different, but the interface is
the same.
In Go, we can achieve polymorphism by using interfaces. Interfaces allow us to define behaviour and then have types implement that behaviour. This means that we can have multiple implementations of the same behaviour. This is polymorphism.
Example:
package main
import "fmt"
// car is an interface that defines the methods that all cars must have
type car interface {
// drive method is used to move the car
drive()
// stop method is used to stop the car
stop()
}
type lambo struct {
Model string
}
type chevy struct {
Model string
}
var (
_ car = (*lambo)(nil)
_ car = (*chevy)(nil)
)
func (l *lambo) drive() {
fmt.Printf("Lambo of model %s on the move\n", l.Model)
}
func (l *lambo) stop() {
fmt.Printf("Lambo of model %s stopped\n", l.Model)
}
func (c *chevy) drive() {
fmt.Printf("Chevy of model %s on the move\n", c.Model)
}
func (c *chevy) stop() {
fmt.Printf("Chevy of model %s stopped\n", c.Model)
}
// driveCar is a function that takes a car interface and calls the drive method
func driveCar(c car) {
c.drive()
}
// stopCar is a function that takes a car interface and calls the stop method
func stopCar(c car) {
c.stop()
}
func main() {
var l = lambo{
Model: "Gallardo",
}
var c = chevy{
Model: "Camaro",
}
driveCar(&l)
driveCar(&c)
stopCar(&l)
stopCar(&c)
}
Output
go run interfaces/basic/main.go
Lambo of model Gallardo on the move
Chevy of model Camaro on the move
Lambo of model Gallardo stopped
Chevy of model Camaro stopped
In the above example, we have a car
interface that defines the
drive()
and stop()
methods. Any type that implements these
two methods can be considered a car
. We then have two types
lambo
and chevy
that both implement the car
interface. This means that both types have the same behaviour, but the
implementation of that behaviour is different. They both have a field called
Model which stores the model name of the car. We then have two functions
driveCar()
and stopCar()
that take a Car
interface as an argument. This means that we can pass in either a
lambo
or chevy
type to these functions. The main function
creates a lambo
and chevy
type and then calls the
driveCar()
and stopCar()
functions with each type. This
means that the same function is being used to drive and stop both types, even
though the implementation of the drive()
and stop()
methods
are different for each type. This is polymorphism.
Interfaces can be used to define common behaviour for different types of objects. In this case, all cars can drive and stop. This can be useful for writing code that works with cars without having to know the specific details of each type of car.
Why are Interfaces Important?
Interfaces are important in software design because they:
- Encapsulate abstraction. An interface defines the behavior of an object, but not its implementation. This allows the implementation to be changed without affecting the clients of the interface.
- Promote loose coupling. Loose coupling is a design principle that minimizes the dependencies between modules. This makes the code more flexible and easier to maintain.
- Allow polymorphism. Polymorphism is the ability to treat objects of different types similarly. This is made possible by interfaces, which define a common set of methods for different types of objects.
- Make code more readable and maintainable. Interfaces can make code more readable and maintainable by providing a clear definition of the expected behaviour of an object. This makes it easier to understand how different parts of the code interact.
Interfaces following the Software Design Principles
Software design principles are a set of guidelines that help developers create software that is easy to understand, maintain, and extend. It is not a specific implementation, but rather a description of the solution that can be used in many different ways. Design patterns can help you write more maintainable and extensible code, and they can also help you communicate your ideas to other developers.
Here are some of the most common design patterns in Go:
- Singleton pattern: This pattern ensures that there is only one instance of a particular class in a program.
- Factory pattern: This pattern provides a way to create objects without specifying their concrete class.
- Adapter pattern: This pattern allows two incompatible classes to work together.
- Builder pattern: This pattern allows you to construct complex objects step by step.
- Prototype pattern: This pattern allows you to create new objects by cloning existing ones.
These are just a few of the many available design patterns. If you are new to Go software design, I recommend that you learn about the most common patterns. There are many resources available online, including books, articles, and tutorials.
An example of using software design principles while implementing an interface:
package main
import "fmt"
// car is an interface that defines the methods that all cars must have
type car interface {
// addDoors method is used to add doors to the car
// This is an example of the Builder pattern
addDoors(doors uint8) car
// addEngine method is used to add an engine to the car
// This is an example of the Builder pattern
addEngine(engine string) car
// drive method is used to move the car
drive()
// stop method is used to stop the car
stop()
}
// lambo is a struct that represents a Lamborghini car
type lambo struct {
doors uint8
engine string
}
// newLambo is a function that returns a new lambo struct
// This implements the Factory pattern
func newLambo() car {
return &lambo{}
}
func (l *lambo) addDoors(doors uint8) car {
l.doors = doors
return l
}
func (l *lambo) addEngine(engine string) car {
l.engine = engine
return l
}
func (l *lambo) drive() {
fmt.Println("Lambo on the move")
}
func (l *lambo) stop() {
fmt.Println("Lambo stopped")
}
// chevy is a struct that represents a Chevrolet car
type chevy struct {
doors uint8
engine string
}
// newChevy is a function that returns a new chevy struct
// This implements the Factory pattern
func newChevy() car {
return &chevy{}
}
func (c *chevy) addDoors(doors uint8) car {
c.doors = doors
return c
}
func (c *chevy) addEngine(engine string) car {
c.engine = engine
return c
}
func (c *chevy) drive() {
fmt.Println("Chevy on the move")
}
func (c *chevy) stop() {
fmt.Println("Chevy stopped")
}
// factory is an interface that defines the methods that all factories must have
// This implements the Abstract Factory pattern
type factory interface {
makeCar(car car, doors uint8, engine string) car
}
type carFactory struct{}
func (cf *carFactory) makeCar(car car, doors uint8, engine string) car {
switch car.(type) {
case *lambo:
fmt.Println("Making a Lambo")
case *chevy:
fmt.Println("Making a Chevy")
}
return car.addDoors(doors).addEngine(engine)
}
// newCarFactory is a function that returns a new carFactory struct
// This implements the Factory pattern
func newCarFactory() factory {
return &carFactory{}
}
func main() {
var f = newCarFactory()
var l = newLambo()
var c = newChevy()
f.makeCar(l, 2, "V8")
f.makeCar(c, 4, "V6")
l.drive()
c.drive()
l.stop()
c.stop()
}
Output
go run interfaces/advanced/main.go
Making a Lambo
Making a Chevy
Lambo on the move
Chevy on the move
Lambo stopped
Chevy stopped
From the above example, we can see that we define two interfaces: car
and factory
. The car
interface defines the methods that all
cars must have, such as addDoors()
, addEngine()
,
drive()
, and stop()
. The factory
interface
defines the methods that all factories must have, such as makeCar()
.
The lambo
and chevy
structs implement the car
interface. They represent two different types of cars: a Lamborghini and a
Chevrolet.
The newLambo()
and newChevy()
functions return new
instances of the lambo
and chevy
structs, respectively.
The carFactory
struct implements the factory
interface. It
has a method called makeCar()
, which takes a car
, a number
of doors, and an engine as arguments. The makeCar()
method first
prints a message indicating what kind of car is being created. Then, it calls
the addDoors()
and addEngine()
methods on the car to add
the specified number of doors and engines.
The newCarFactory()
function returns a new instance of the
carFactory
struct.
The main()
function creates a new carFactory
object, a new
lambo
object, and a new chevy
object. It then calls the
makeCar()
method on the carFactory
object to create a
Lamborghini with 2 doors and a V8 engine. It also calls the makeCar()
method to create a Chevrolet with 4 doors and a V6 engine. Finally, it calls
the drive()
and stop()
methods on the Lamborghini and
Chevrolet objects.
Builder Pattern
The builder pattern is a design pattern that allows you to construct complex objects step by step. It is often used to create objects that have many optional parameters. The builder pattern is useful when you want to create an object that has many optional parameters. For example, you might want to create a car that has a certain number of doors and an engine of a certain type. You could use the builder pattern to create a car with 2 doors and a V8 engine or a car with 4 doors and a V6 engine.
func (cf *carFactory) makeCar(car car, doors uint8, engine string) car {
return car.addDoors(doors).addEngine(engine)
}
Abstract Factory Pattern
The abstract factory pattern is a design pattern that allows you to create families of related objects without specifying their concrete classes. It is often used to create objects that have many optional parameters. The abstract factory pattern is useful when you want to create a family of related objects. For example, you might want to create a family of cars that have a certain number of doors and an engine of a certain type. You could use the abstract factory pattern to create a family of cars with 2 doors and a V8 engine, or a family of cars with 4 doors and a V6 engine.
func (cf *carFactory) makeCar(car car, doors uint8, engine string) car {
return car.addDoors(doors).addEngine(engine)
}
func main() {
var f = newCarFactory()
var l = newLambo()
var c = newChevy()
f.makeCar(l, 2, "V8")
f.makeCar(c, 4, "V6")
}
Factory Pattern
The factory pattern is a design pattern that allows you to create objects without specifying their concrete classes. It is often used to create objects that have many optional parameters. The factory pattern is useful when you want to create an object without specifying its concrete class. For example, you might want to create a car with 2 doors and a V8 engine or a car with 4 doors and a V6 engine.
func newLambo() car {
return &lambo{}
}
func newChevy() car {
return &chevy{}
}
func main() {
var l = newLambo()
var c = newChevy()
l.addDoors(2).addEngine("V8")
c.addDoors(4).addEngine("V6")
}
Real-World Examples
Mainflux is a modern, scalable, secure open source and patent-free IoT cloud platform written in Go. It accepts user and thing connections over various network protocols (i.e. HTTP, MQTT, WebSocket, CoAP), thus making a seamless bridge between them. It is used as the IoT middleware for building complex IoT solutions.
In mainflux, there is a configurable message broker. That is, you can configure
mainflux to run using NATS
or RabbitMQ
as the message
broker. This is achieved by using the messaging
package. The package
contains a publisher
and subscriber
interface. The
publisher
interface defines the publish()
method and the
subscriber
interface defines the subscribe()
method. The
NATS
and RabbitMQ
packages implement the
publisher
and subscriber
interfaces. This means that you
can use either NATS
or RabbitMQ
as the message broker for
mainflux.
The interface
// Copyright (c) Mainflux
// SPDX-License-Identifier: Apache-2.0
package messaging
import "context"
// Publisher specifies message publishing API.
type Publisher interface {
// Publishes message to the stream.
Publish(ctx context.Context, topic string, msg *Message) error
// Close gracefully closes message publisher's connection.
Close() error
}
// MessageHandler represents Message handler for Subscriber.
type MessageHandler interface {
// Handle handles messages passed by underlying implementation.
Handle(msg *Message) error
// Cancel is used for cleanup during unsubscribing and it's optional.
Cancel() error
}
// Subscriber specifies message subscription API.
type Subscriber interface {
// Subscribe subscribes to the message stream and consumes messages.
Subscribe(ctx context.Context, id, topic string, handler MessageHandler) error
// Unsubscribe unsubscribes from the message stream and
// stops consuming messages.
Unsubscribe(ctx context.Context, id, topic string) error
// Close gracefully closes message subscriber's connection.
Close() error
}
// PubSub represents aggregation interface for publisher and subscriber.
type PubSub interface {
Publisher
Subscriber
}
The publisher
interface defines the publish()
method, which
takes a context, a topic, and a message as arguments. The subscriber
interface defines the subscribe()
method, which takes a context, an
id, a topic, and a message handler as arguments. The messageHandler
interface defines the handle()
method, which takes a message as an
argument.
Publisher
The NATS publisher implements the publisher
interface. It has a
conn
field that stores the NATS connection. The publish()
method takes a context, a topic, and a message as arguments. It then marshals
the message into a byte array and publishes it to the NATS connection.
func (pub *publisher) Publish(ctx context.Context, topic string, msg *messaging.Message) error {
if topic == "" {
return ErrEmptyTopic
}
data, err := proto.Marshal(msg)
if err != nil {
return err
}
subject := fmt.Sprintf("%s.%s", chansPrefix, topic)
if msg.Subtopic != "" {
subject = fmt.Sprintf("%s.%s", subject, msg.Subtopic)
}
return pub.conn.Publish(subject, data)
}
The RabbitMQ publisher implements the publisher
interface. It has a
ch
field that stores the RabbitMQ channel. The publish()
method takes a context, a topic, and a message as arguments. It then marshals
the message into a byte array and publishes it to the RabbitMQ channel.
func (pub *publisher) Publish(ctx context.Context, topic string, msg *messaging.Message) error {
if topic == "" {
return ErrEmptyTopic
}
data, err := proto.Marshal(msg)
if err != nil {
return err
}
subject := fmt.Sprintf("%s.%s", chansPrefix, topic)
if msg.Subtopic != "" {
subject = fmt.Sprintf("%s.%s", subject, msg.Subtopic)
}
subject = formatTopic(subject)
err = pub.ch.PublishWithContext(
ctx,
exchangeName,
subject,
false,
false,
amqp.Publishing{
Headers: amqp.Table{},
ContentType: "application/octet-stream",
AppId: "mainflux-publisher",
Body: data,
})
if err != nil {
return err
}
return nil
}
From the above examples, we can see that the NATS
and
RabbitMQ
publishers have different implementations, but they both
implement the publisher
interface. This means that you can use either
NATS
or RabbitMQ
as the message broker for mainflux.
Their respective factory methods are as follows:
func NewPublisher(url string) (messaging.Publisher, error) {
conn, err := nats.Connect(url, nats.MaxReconnects(maxReconnects))
if err != nil {
return nil, err
}
ret := &publisher{
conn: conn,
}
return ret, nil
}
The NATS publisher factory method takes a URL as an argument. It then creates a
new NATS connection and returns a new NATS publisher. The publisher
struct implements the publisher
interface, so it can be used as a
publisher for mainflux.
func NewPublisher(url string) (messaging.Publisher, error) {
conn, err := amqp.Dial(url)
if err != nil {
return nil, err
}
ch, err := conn.Channel()
if err != nil {
return nil, err
}
if err := ch.ExchangeDeclare(exchangeName, amqp.ExchangeTopic, true, false, false, false, nil); err != nil {
return nil, err
}
ret := &publisher{
conn: conn,
ch: ch,
}
return ret, nil
}
The RabbitMQ publisher factory method takes a URL as an argument. It then
creates a new rabbitmq connection and channel. It also declares a new RabbitMQ
exchange. Finally, it returns a new RabbitMQ publisher. The publisher
struct implements the publisher
interface, so it can be used as a
publisher for mainflux.
Mainflux uses build flags to determine which publisher to use. If the
NATS
build flag is set, then mainflux will use the NATS publisher. If
the RabbitMQ
build flag is set, then mainflux will use the RabbitMQ
publisher. If neither build flag is set, then mainflux will use the NATS
publisher by default. This is done by using 2 separate files:
brokers_nats.go
and brokers_rabbitmq.go
. The
brokers_nats.go
file contains the nats publisher factory method and
the brokers_rabbitmq.go
file contains the rabbitmq publisher factory
method.
// Copyright (c) Mainflux
// SPDX-License-Identifier: Apache-2.0
//go:build !rabbitmq
// +build !rabbitmq
package brokers
import (
"log"
mflog "github.com/mainflux/mainflux/logger"
"github.com/mainflux/mainflux/pkg/messaging"
"github.com/mainflux/mainflux/pkg/messaging/nats"
)
// SubjectAllChannels represents subject to subscribe for all the channels.
const SubjectAllChannels = "channels.>"
func init() {
log.Println("The binary was build using Nats as the message broker")
}
func NewPublisher(url string) (messaging.Publisher, error) {
pb, err := nats.NewPublisher(url)
if err != nil {
return nil, err
}
return pb, nil
}
func NewPubSub(url, queue string, logger mflog.Logger) (messaging.PubSub, error) {
pb, err := nats.NewPubSub(url, queue, logger)
if err != nil {
return nil, err
}
return pb, nil
}
//go:build rabbitmq
// +build rabbitmq
// Copyright (c) Mainflux
// SPDX-License-Identifier: Apache-2.0
package brokers
import (
"log"
mflog "github.com/mainflux/mainflux/logger"
"github.com/mainflux/mainflux/pkg/messaging"
"github.com/mainflux/mainflux/pkg/messaging/rabbitmq"
)
// SubjectAllChannels represents subject to subscribe for all the channels.
const SubjectAllChannels = "channels.#"
func init() {
log.Println("The binary was build using RabbitMQ as the message broker")
}
func NewPublisher(url string) (messaging.Publisher, error) {
pb, err := rabbitmq.NewPublisher(url)
if err != nil {
return nil, err
}
return pb, nil
}
func NewPubSub(url, queue string, logger mflog.Logger) (messaging.PubSub, error) {
pb, err := rabbitmq.NewPubSub(url, queue, logger)
if err != nil {
return nil, err
}
return pb, nil
}
Conclusion
Interfaces are a powerful tool in Go. They allow you to define behaviour and then have types implement that behaviour. This means that you can write code that is more flexible and adaptable by using interfaces. Interfaces are important in software design because they encapsulate abstraction, promote loose coupling, allow polymorphism, and make code more readable and maintainable.
References
- https://books.google.co.ke/books?id=9IFMCsQJyscC&pg=SA91-PA5&redir_esc=y
- http://lucacardelli.name/Papers/OnUnderstanding.A4.pdf
- https://www.worldcat.org/title/31171684
- https://www.enterpriseintegrationpatterns.com
- https://github.com/mainflux/mainflux
- https://github.com/mainflux/mainflux/blob/master/pkg/messaging
- https://github.com/0x6flab/blog-golang-interfaces
The bigger the interface, the weaker the abstraction. - Rob Pike
If you liked this article, click the👏 below so other people will see it here on Medium.
Let's be friends on Twitter. Happy Coding 😉
Stackademic
Thank you for reading until the end. Before you go:
- Please consider clapping and following the writer! 👏
- Follow us on Twitter(X), LinkedIn, and YouTube.
- Visit Stackademic.com to find out more about how we are democratizing free programming education around the world.
Subscribe to get future posts via email