logo
Published on

Favor Composition Over Inheritance: Insights from Go

Authors
covers2-3

Understanding the Advantages and How Go Embraces Composition

Whether you are new to programming or have 10 years or more of experience, you have probably heard this at least once in your career: "Favor composition over inheritance."

I don't remember the first time I heard about it, but I think it is related to an ex-colleague who was very passionate about functional programming. Later, I found this YouTube video made by one of my favorite tech YouTubers, and it really helped me to wrap my head around this concept.

So... although there are plenty of good blog posts, YouTube videos, cat memes, and conspiracy theories (the last two obviously unrelated), let's start by covering what inheritance and composition are. Because, well, I need it for the context, so please bear with me 😌

Inheritance

"is-a"

Inheritance is one of the four pillars of object-oriented programming (OOP), alongside abstraction, encapsulation, and polymorphism. It models an "is-a" relationship, where a child class (or subclass) inherits properties and behaviors (methods) from a parent class (or superclass).

Let's consider a file processor application in JavaScript that can process text and image files.

class FileProcessor {
  constructor(fileName) {
    this.fileName = fileName
  }

  processFile() {
    throw new Error('processFile() must be implemented in subclasses')
  }
}

class TextFileProcessor extends FileProcessor {
  constructor(fileName) {
    super(fileName)
  }

  processFile() {
    console.log(`Processing text file: ${this.fileName}`)
    // {text processing logic}
  }

  // example of a method specific to text file processing
  readText() {
    console.log(`Reading the text for file: ${this.fileName}`)
    // {word counting logic}
  }
}

class ImageFileProcessor extends FileProcessor {
  constructor(fileName) {
    super(fileName)
  }

  processFile() {
    console.log(`Processing image file: ${this.fileName}`)
    // {image processing logic}
  }

  // example of a method specific to image file processing
  imageRecognition(image) {
    console.log(`image recognition for file: ${this.fileName}`)
    // {image resizing logic}
  }
}

The code above defines a FileProcessor class with a fileName property and an abstract processFile method, which must be implemented by subclasses. The TextFileProcessor and ImageFileProcessor classes extend FileProcessor, each implementing the processFile method and adding specific methods (readText for text files and imageRecognition for image files).

But someting is missing. Do you see that?

programmers-meme

That's right, a meme processor! How could we overlook that?

Let's start by extending...

Hum...

Wait, what should we extend? The TextFileProcessor? The ImageFileProcessor? Maybe the FileProcessor and just copy the readText() and imageRecognition() methods?

The Limitations of Inheritance

In this scenario, using inheritance to add a MemeProcessor could lead to several issues:

  1. Uncertainty and Complexity: whether we choose to extend TextFileProcessor or ImageFileProcessor, the confusion, complexity, and tight coupling could lead to issues.
  2. Code Duplication: if you extend FileProcessor and copy methods from TextFileProcessor and ImageFileProcessor, you end up duplicating code.
  3. Open/Closed Principle Violation: consider adding a permission property to this hierarchy. According to the Open/Closed Principle, software entities should be open for extension but closed for modification. Adding a permission property to FileProcessor (or to any of our classes) would violate this principle.

Composition

"has-a"

Composition is a design principle that models a "has-a" relationship, meaning one object is composed of one or more objects with distinct functionalities. Instead of inheriting properties and behaviors from a parent class, a class achieves its functionalities by containing instances of other classes.

Here’s how we can refactor the file processor application using composition:

class TextProcessor {
  constructor(fileName) {
    this.fileName = fileName
  }

  readText() {
    console.log(`Reading text from file: ${this.fileName}`)
    // {text reading logic}
  }
}

class ImageProcessor {
  constructor(fileName) {
    this.fileName = fileName
  }

  imageRecognition() {
    console.log(`Image recognition for file: ${this.fileName}`)
    // {image recognition logic}
  }
}

class MemeProcessor {
  constructor(fileName) {
    this.fileName = fileName
    this.textProcessor = new TextProcessor(fileName)
    this.imageProcessor = new ImageProcessor(fileName)
  }

  analyzeContent() {
    console.log(`Processing meme file: ${this.fileName}`)
    this.textProcessor.readText()
    this.imageProcessor.imageRecognition()
    // {meme processing logic}
  }
}

We split the logic into small, focused pieces and compose them in the MemeProcessor class. This approach simplifies the design by ensuring each component handles a specific responsibility, leading to no code duplication and better maintainability. By using composition, we follow to the Open/Closed Principle, allowing our system to be open for extension but closed for modification.

With that in mind, adding a permissions component and integrating it into the MemeProcessor would look as follows:

class Permissions {
  constructor(permissionLevel) {
    this.permissionLevel = permissionLevel
  }

  checkPermission() {
    console.log(`Checking permission level: ${this.permissionLevel}`)
    // {permission checking logic}
  }
}

class MemeProcessor {
  constructor(fileName, permissionLevel) {
    this.fileName = fileName
    this.textProcessor = new TextProcessor(fileName)
    this.imageProcessor = new ImageProcessor(fileName)
    this.permissions = new Permissions(permissionLevel)
  }

  processMeme() {
    console.log(`Processing meme file: ${this.fileName}`)
    this.permissions.checkPermission()
    this.textProcessor.readText()
    this.imageProcessor.imageRecognition()
    // {meme processing logic}
  }
}

If you read my last blog post, you know that I started learning Go, so naturally, I had to include it in this discussion. Especially when talking about composition over inheritance, as Go takes a unique approach by fully embracing composition and leaving inheritance behind.

How Go Embrace Composition

Go is designed to prefer composition over inheritance. Instead of traditional class-based inheritance, Go uses interfaces and struct embedding to achieve code reuse and polymorphism.

Composition by embedding structs:

Struct embedding in Go enables a "has-a" relationship, allowing a struct to include the fields and methods of another struct without traditional inheritance.

In the following example, the SecureFile struct embeds the File struct, gaining its Read and Write methods. The SecureFile struct adds additional functionality to check permissions before allowing read or write operations. This approach allows for code reuse and extensibility without the need for inheritance.

package main

import "fmt"

type File struct {
	filename string
}

func (f File) Read() {
	fmt.Println("Reading from", f.filename)
}

func (f File) Write(data string) {
	fmt.Println("Writing to", f.filename)
}

type SecureFile struct {
	File        // embedding the File struct
	permissions map[string]bool
}

func (sf SecureFile) Read() {
	if sf.hasPermission("read") {
		sf.File.Read()
	} else {
		fmt.Println("Read permission denied")
	}
}

func (sf SecureFile) Write(data string) {
	if sf.hasPermission("write") {
		sf.File.Write(data)
	} else {
		fmt.Println("Write permission denied")
	}
}

func (sf SecureFile) hasPermission(permission string) bool {
	return sf.permissions[permission]
}

func main() {
	file := SecureFile{
		File:        File{filename: "example.txt"},
		permissions: map[string]bool{"read": true, "write": true},
	}

	file.Read()        // Reading from example.txt
	file.Write("data") // Writing to example.txt
}

While researching for this blog post, I found this definition on Wikipedia:

"Composition over inheritance (or composite reuse principle) in object-oriented programming (OOP) is the principle that classes should favor polymorphic behavior and code reuse by their composition (by containing instances of other classes that implement the desired functionality) over inheritance from a base or parent class".

Polymorphism, one of the four pillars of OOP, allows objects to be treated as instances of their parent class rather than their actual class. This means that a single function can operate on different kinds of objects as long as they follow the same interface.`

Interfaces

Go promotes polymorphism through its use of interfaces. An interface in Go is a type that specifies a set of method signatures. Any type that implements these methods is said to satisfy the interface, allowing different types to be used interchangeably. This approach decouples the code from specific implementations and promotes flexibility.

Continuing with our previous example, let's introduce Go's built-in io.Reader and io.Writer interfaces and demonstrate polymorphism:

package main

import (
	"fmt"
	"io"
)

type File struct {
	filename string
	data     string
}

func (f *File) Read(p []byte) (n int, err error) {
	n = copy(p, f.data)
	if n == 0 {
		err = io.EOF
	}
	return n, err
}

func (f *File) Write(p []byte) (n int, err error) {
	f.data = string(p)
	n = len(p)
	return n, nil
}

type SecureFile struct {
	*File
	permissions map[string]bool
}

func (sf *SecureFile) Read(p []byte) (n int, err error) {
	if sf.hasPermission("read") {
		return sf.File.Read(p)
	}
	return 0, fmt.Errorf("read permission denied")
}

func (sf *SecureFile) Write(p []byte) (n int, err error) {
	if sf.hasPermission("write") {
		return sf.File.Write(p)
	}
	return 0, fmt.Errorf("write permission denied")
}

func (sf *SecureFile) hasPermission(permission string) bool {
	return sf.permissions[permission]
}

func readFile(r io.Reader) {
	p := make([]byte, 1024)
	n, err := r.Read(p)
	if err != nil && err != io.EOF {
		fmt.Println("Error reading file:", err)
		return
	}
	fmt.Println("Read from file:", string(p[:n]))
}

func writeFile(w io.Writer, data string) {
	n, err := w.Write([]byte(data))
	if err != nil {
		fmt.Println("Error writing to file:", err)
		return
	}
	fmt.Println("Written to file:", n, "bytes")
}

func main() {
	file := &File{filename: "example.txt"}
	secureFile := &SecureFile{
		File:        file,
		permissions: map[string]bool{"read": true, "write": true},
	}

	writeFile(secureFile, "Hello, Gopher!") // Written to file: 14 bytes
	readFile(secureFile)                    // Read from file: Hello, Gopher!

In this example, the SecureFile struct not only embeds the File struct, but also implements the io.Reader and io.Writer interfaces. The readFile and writeFile functions accept any type that satisfies the io.Reader and io.Writer interfaces, respectively. This demonstrates how Go uses interfaces to achieve polymorphism, allowing different types to be used interchangeably as long as they implement the required methods.

summary

We've explored how inheritance models an "is-a" relationship, which can lead to complexity and code duplication. Composition, on the other hand, models a "has-a" relationship, promoting flexibility and maintainability by combining existing components.

For example, MemeProcessor combines text and image processing, and in Go, SecureFile embeds File and adds permissions. Go emphasizes composition over inheritance, using interfaces and struct embedding for polymorphism and code reuse.

I hope you enjoyed this exploration and found it helpful. And next time you think about extending a class, remember there might be a better way – maybe one that involves fewer headaches and more memes 😜