Composition Over Inheritance
I mean, sure… You probably heard all the inheritance pain points: tight coupling, complicated nested hierarchies, and an overall inflexibility that leads to difficulties in maintenance. But what does this mean in practice?
To visualize this, imagine that with inheritance, if you want to build a truck, you would need to build a car, and a van first. With composition, you take all the building blocks from the engine to the wheels and compose them into the final truck.
It is important to understand the pitfalls of inheritance in practice, so let’s look at some code examples, and see what are the alternatives to build more reliable software moving forward.
Inheritance in Kotlin
We’ll start by looking at inheritance in Kotlin, since Kotlin, and especially Java are known for the hardcore use of OOP principles. Then we’ll spend some time looking at the benefits offered by Go, which encourages composition over inheritance.
Class-Based Inheritance vs Prototype-Based Inheritance
One of the big challenges in software development is code reusability. In OOP languages, this is solved through inheritance, where subclasses are extending the state and behavior of parent classes. Quick side note, this is known as class-based inheritance. In languages like JavaScript objects can inherit directly from other objects in a more flexible process called prototype-based inheritance. So let’s imagine we have to build a small app that allows people to draw shapes on a canvas.
By the way, you are following ”The Snippet”, the series where we are analyzing code snippets from various programming languages to better understand useful software concepts.
We start with squares and rectangles, so the structure is pretty straight forward. Since quite a lot of our code is duplicated, we naturally decide to extract it in a base class called Shape.
open class Shape(
val position: Position,
val width: Double,
val height: Double
) {
open fun area() = width * height
}
class Rectangle(
position: Position,
width: Double,
height: Double
) : Shape(position, width, height)
class Square(
position: Position,
side: Double,
) : Shape(position, side, side)
It all looks great on paper, but there is a major caveat. Sooner or later a new requirement will come in, and we’ll be asked to add support for more shapes.
Let’s take them one by one. The Circle doesn’t have neither a width nor a height, so these properties are completely useless here.
class Circle(
position: Position,
val radius: Double,
) : Shape(position, ?, ?) {
override fun area() =
Math.PI * radius * radius
}
There are two options: We either put the width and the height back in the subclasses; Or we decide we don’t really care, and we store all the dimensions in the base class. Since option two already starts to look like we are building a “God Class” which is a pretty terrible antipattern, let’s go with option 1.
This is the new structure, but here is the kicker.
open class Shape(
val position: Position
) {
open fun area() = 0.0
}
class Rectangle(
position: Position,
val width: Double,
val height: Double
) : Shape(position) {
override fun area() = width * height
}
class Square(
position: Position,
val side: Double
) : Shape(position) {
override fun area() = side * side
}
class Circle(
position: Position,
val radius: Double
) : Shape(position) {
override fun area() = Math.PI * radius * radius
}
The Line Shape introduces two new problems:
- Lines don’t have an Area, so the contract imposed by the Shape class doesn’t apply in this scenario;
- And lines can be drawn at an angle. And, when you think about it, squares and rectangles can also be drawn at an angle, while this doesn’t apply for circles. So we either put the angle property in the base class, and Shape will have a dead property, or we put it in the child classes, and then wonder why the hell are we inheriting from Shape in the first place.
Oh, and guess what? A new requirement just came in saying that some of the shapes should be clickable. Of course, we’ll use an interface to define a behavior contract, but then we are faced with the same type of dilemma.
interface Clickable {
fun onClick()
}
Since only some types of shapes can be clickable, who should implement the contract? The base or the child classes?
I honestly don’t know what the best answer is here because it all depends on future requirements and various other constraints. You’ll even find people suggesting that a new ClickableShape base class should be defined, which leads to yet another set of problems caused by complex inheritance chains.
open class ClickableShape : Clickable {
override open fun onClick() {
println("Click!")
}
}
Not to mention we are lucky enough that Kotlin doesn’t support multiple inheritance which is associated with the notorious “diamond problem” issue.
Inheritance in Go
So let’s switch gears to Go, which doesn’t support traditional inheritance or classes at all in an attempt to create a simpler, more maintainable system for code reusability.
These limitations might feel weird at first, especially if your background is in a strong OOP language like Java or C++.
However, you’ll soon realize that these missing components are actually a blessing in disguise.
We can use structures to group data together, methods with receivers to add behavior to the data, interfaces to define behavior contracts, and embeddings to compose types.
type Drivable interface {
Drive()
}
type Engine struct {
Power int
}
type Car struct {
Engine
Make string
}
func (s Car) Drive() {
fmt.Println("Drive!")
}
Note that compared to Kotlin, in Go we don’t need to explicitly declare that types implement interfaces. The association is implicit as long as the type defines the method required by an interface. This helps with decoupling and modularity since the two entities don’t need to know about each other.
Back to composition, we can now identify the building blocks for our various shapes, and compose them together in order to build the entity we need. We’ll call the type that is embedded an inner type of the new outer type.
type Square struct {
Position
Dimensons
Appearance
}
func (s Square) Draw() {
fmt.Print("Draw")
}
func (s Square) Click() {
fmt.Print("Click")
}
func main() {
s := Square{}
fmt.Print(s.X)
}
There is one important aspect here. Through a process called inner type promotion, identifiers from the inner type are promoted up to the outer type. These promoted identifiers become part of the outer type so you can access them directly in your code.
So, while in classical inheritance entities are built from parent entities through a direct relationship, with composition, entities are formed from independent building blocks through a more flexible and scalable process.
Note that composition is not a “one size fits all” solution and that inheritance isn’t inherently “bad”. However, understanding its limitations helps build cleaner, more modular, and maintainable software.
If you feel like you learned something, you should watch some of my youtube videos or subscribe to the newsletter.
Until next time, thank you for reading!