When writing a program, one of the fundamental questions developers face is how to structure their code for maximum flexibility and maintainability.
Historically, object-oriented programming (OOP) encouraged the use of inheritance as a primary means of code reuse. However, in recent years, the concept of functional programming has re-gained popularity as a more robust alternative. In this post, we'll explore why composition can be a better approach and how to implement it effectively in JavaScript (for this example).
What Is Inheritance?
Inheritance is a feature of OOP where one class (child class) inherits properties and methods from another class (parent class). This allows for code reuse, but it can lead to some problems:
- Tight Coupling: Child classes are tightly coupled to their parent classes, making changes in the parent class risky.
- Fragile Base Class: Changes in the base class can inadvertently break the behavior of child classes.
- Hierarchy Issues: Deep inheritance hierarchies can become difficult to manage and understand.
What Is Composition?
Composition, on the other hand, involves building complex objects by combining simpler ones. Instead of relying on inheritance, composition focuses on creating objects with specific, reusable functionalities and then assembling them together.
The mantra of composition is "favor object composition over class inheritance." Let's see how this works in practice.
Example: Inheritance vs. Composition
Inheritance Approach
Let's say we're building a system for different types of vehicles.
class Vehicle {
constructor(name, speed) {
this.name = name;
this.speed = speed;
}
drive() {
console.log(`${this.name} is driving at ${this.speed} km/h.`);
}
}
class Car extends Vehicle {
constructor(name, speed, doors) {
super(name, speed);
this.doors = doors;
}
honk() {
console.log(`${this.name} is honking.`);
}
}
const myCar = new Car('Toyota', 120, 4);
myCar.drive(); // Toyota is driving at 120 km/h.
myCar.honk(); // Toyota is honking.
While this works, adding new functionality (like flying for an airplane) would involve either creating more subclasses or overloading existing ones.
Composition Approach
Now, let's look at how composition might handle this scenario.
const canDrive = (state) => ({
drive: () => console.log(`${state.name} is driving at ${state.speed} km/h.`),
});
const canHonk = (state) => ({
honk: () => console.log(`${state.name} is honking.`),
});
const createCar = (name, speed, doors) => {
const state = { name, speed, doors };
return { ...state, ...canDrive(state), ...canHonk(state) };
};
const myCar = createCar('Toyota', 120, 4);
myCar.drive(); // Toyota is driving at 120 km/h.
myCar.honk(); // Toyota is honking.
Benefits of Composition
Flexibility: With composition, you can easily add or remove functionality without altering existing code. New behaviors can be added by composing objects differently.
Reusability: Components (like
canDrive
,canHonk
, andcanFly
) can be reused across different objects, reducing redundancy.Avoiding Hierarchy Pitfalls: Composition avoids the problems of deep inheritance hierarchies, making the code easier to understand and maintain.
When to Use Composition Over Inheritance
While composition is powerful, it's not always the right choice. Use it when:
- You Need Flexibility: If you anticipate the need for frequent changes or extensions to your code, composition provides a more adaptable structure.
- You're Dealing with Multiple Behaviors: Composition shines when your objects need to exhibit various behaviors that don't fit neatly into an inheritance hierarchy.
Conclusion
Inheritance has its place, but composition often leads to more flexible and maintainable code, especially in complex JavaScript (or other languages) applications.
By focusing on assembling behaviors rather than extending classes, you can create code that's easier to reason about and extend. The next time you're tempted to reach for inheritance, consider if composition might be a better fit for your needs.