- Published on
Favor Composition Over Inheritance: Insights from Go
- Authors
- Name
- Amit Barletz
- @BarletzA52
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?
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:
- Uncertainty and Complexity: whether we choose to extend
TextFileProcessor
orImageFileProcessor
, the confusion, complexity, and tight coupling could lead to issues. - Code Duplication: if you extend
FileProcessor
and copy methods fromTextFileProcessor
andImageFileProcessor
, you end up duplicating code. - 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 😜