Create articles from any YouTube video or use our API to get YouTube transcriptions
Start for freeThe Debate: Composition vs Inheritance
In the world of software development, there's an ongoing debate about the best approach to building flexible and maintainable code structures. Many experienced developers advocate for favoring composition over inheritance. But what does this really mean, and why is it so important?
Understanding Inheritance
Inheritance is a fundamental concept in object-oriented programming (OOP). It allows a class to inherit properties and methods from another class, creating a parent-child relationship. This approach can seem intuitive at first, as it mirrors real-world hierarchies and relationships.
For example, if we're building a system to manage different types of vehicles, we might start with a base 'Vehicle' class and then create subclasses like 'Car', 'Truck', and 'Motorcycle' that inherit from it. This allows us to reuse common code and establish a clear hierarchy.
The Pitfalls of Inheritance
However, as our software grows more complex, inheritance can lead to several problems:
-
Tight Coupling: Subclasses are tightly bound to their parent classes, making it difficult to change one without affecting the other.
-
Inflexibility: As requirements change, the rigid structure of inheritance can make it challenging to adapt our code.
-
Complicated Hierarchies: Deep inheritance trees can become difficult to understand and maintain.
-
The 'Fragile Base Class' Problem: Changes to a base class can have unexpected and far-reaching effects on its subclasses.
Enter Composition
Composition offers an alternative approach. Instead of building objects through inheritance, we create them by combining simpler, reusable components. This is often described using the phrase "has-a" rather than "is-a".
To illustrate the difference:
- With inheritance, a truck "is-a" vehicle.
- With composition, a truck "has-a" engine, "has-a" set of wheels, "has-a" cargo area, etc.
This approach provides greater flexibility and can lead to more maintainable code structures.
Practical Examples: Kotlin vs Go
Let's explore these concepts through practical examples, comparing approaches in Kotlin (which supports traditional OOP with inheritance) and Go (which favors composition).
Kotlin: The Inheritance Approach
Kotlin, like Java, fully supports class-based inheritance. Let's consider a scenario where we're building an application for drawing shapes on a canvas.
open class Shape(var x: Int, var y: Int) {
open fun draw() {
// Draw the shape
}
open fun area(): Double {
return 0.0
}
}
class Rectangle(x: Int, y: Int, var width: Int, var height: Int) : Shape(x, y) {
override fun draw() {
// Draw rectangle
}
override fun area(): Double {
return width * height.toDouble()
}
}
class Circle(x: Int, y: Int, var radius: Int) : Shape(x, y) {
override fun draw() {
// Draw circle
}
override fun area(): Double {
return Math.PI * radius * radius
}
}
This structure seems logical at first. We have a base Shape
class with common properties (x
and y
coordinates) and methods (draw()
and area()
). Specific shapes like Rectangle
and Circle
inherit from Shape
and override the methods as needed.
The Problems Emerge
However, as we add more shapes and requirements, problems start to surface:
-
Irrelevant Properties: The
Circle
class doesn't needwidth
andheight
, but it inherits them fromShape
. -
Method Incompatibility: What if we add a
Line
shape? Lines don't have an area, so thearea()
method doesn't make sense for them. -
Angle Properties: If we need to support angled shapes, where do we add this property? In the base class (where it's irrelevant for circles) or in each subclass that needs it (leading to code duplication)?
-
New Behaviors: If we need to make some shapes clickable, do we add this to the base class or create a new inheritance chain?
These issues highlight the inflexibility of the inheritance approach as requirements evolve.
Go: The Composition Approach
Now, let's look at how we might approach this problem in Go, which doesn't support traditional inheritance and instead encourages composition.
type Drawable interface {
Draw()
}
type HasArea interface {
Area() float64
}
type Position struct {
X, Y int
}
type Rectangle struct {
Position
Width, Height int
}
func (r Rectangle) Draw() {
// Draw rectangle
}
func (r Rectangle) Area() float64 {
return float64(r.Width * r.Height)
}
type Circle struct {
Position
Radius int
}
func (c Circle) Draw() {
// Draw circle
}
func (c Circle) Area() float64 {
return math.Pi * float64(c.Radius * c.Radius)
}
type Line struct {
Start, End Position
}
func (l Line) Draw() {
// Draw line
}
In this Go example, we use composition and interfaces to create a more flexible structure:
- We define interfaces for common behaviors (
Drawable
andHasArea
). - We create a
Position
struct that can be embedded in other types. - Each shape is its own struct, embedding
Position
if needed. - Shapes implement only the methods that are relevant to them.
The Benefits of This Approach
- Flexibility: Each shape only includes the properties and methods it needs.
- Decoupling: Shapes are not tightly coupled to a base class.
- Interface Segregation: We can have separate interfaces for different behaviors.
- Easy Extension: Adding new shapes or behaviors doesn't require changing existing code.
Composition in Practice
Let's delve deeper into how composition works in practice and why it often leads to more maintainable code.
Building Blocks Approach
With composition, we think of our objects as collections of smaller, reusable components. This is akin to building with Lego blocks – we can create complex structures by combining simpler pieces.
For example, let's expand our shapes example to include more complex objects:
type Color struct {
R, G, B uint8
}
type Filled struct {
FillColor Color
}
type Outlined struct {
OutlineColor Color
OutlineWidth int
}
type FilledCircle struct {
Circle
Filled
}
type OutlinedRectangle struct {
Rectangle
Outlined
}
type FilledOutlinedTriangle struct {
Triangle
Filled
Outlined
}
In this structure:
- We can easily create new types of shapes by combining existing components.
- Each component (
Color
,Filled
,Outlined
) is independent and can be reused across different types. - We can add or remove features (like filling or outlining) without affecting the basic shape structures.
Behavior Through Interfaces
Interfaces in Go provide a powerful way to define behavior contracts without creating rigid hierarchies. Let's expand on this:
type Clickable interface {
OnClick()
}
type Draggable interface {
OnDragStart()
OnDragEnd()
}
type InteractiveCircle struct {
Circle
}
func (ic InteractiveCircle) OnClick() {
// Handle click
}
func (ic InteractiveCircle) OnDragStart() {
// Handle drag start
}
func (ic InteractiveCircle) OnDragEnd() {
// Handle drag end
}
With this approach:
- We can add new behaviors (like
Clickable
orDraggable
) without modifying existing types. - Types can implement multiple interfaces, providing a form of multiple inheritance without its complexities.
- We can create new types that combine different behaviors as needed.
Promoting Modularity
Composition naturally leads to more modular code. Each component or behavior can be developed, tested, and maintained independently. This modularity brings several benefits:
- Easier Testing: Smaller, focused components are easier to unit test.
- Improved Reusability: Well-defined components can be reused across different parts of the application or even in different projects.
- Simplified Maintenance: Changes to one component are less likely to affect others, making the codebase easier to maintain and evolve.
When to Use Inheritance
While composition offers many advantages, it's important to note that inheritance isn't always bad. There are scenarios where inheritance can be appropriate:
-
Is-A Relationships: When you have a true "is-a" relationship between classes, inheritance can be natural and intuitive.
-
Framework Requirements: Some frameworks or libraries may require inheritance for certain functionalities.
-
Simple, Stable Hierarchies: In cases where the class hierarchy is simple and unlikely to change, inheritance can be a straightforward solution.
However, even in these cases, it's worth considering if a composition-based approach might offer more flexibility in the long run.
Best Practices for Using Composition
To make the most of composition in your code:
-
Think in Terms of Behaviors: Instead of creating class hierarchies, focus on defining behaviors and combining them.
-
Keep Components Small and Focused: Each component should have a single responsibility.
-
Use Interfaces Liberally: Interfaces allow you to define behavior contracts without creating tight coupling.
-
Favor Embedding Over Inheritance: In languages like Go, use struct embedding to compose types.
-
Design for Change: Always consider how your design might need to evolve in the future.
Conclusion
The shift from inheritance to composition represents a broader trend in software development towards more flexible, modular architectures. While inheritance can seem intuitive, especially for developers with a strong OOP background, composition often leads to code that's easier to maintain, extend, and refactor.
By thinking in terms of small, reusable components and focusing on behavior rather than hierarchies, we can create software systems that are better equipped to handle changing requirements and growing complexity.
Remember, the goal is not to completely avoid inheritance, but to be mindful of its limitations and to consider composition as a powerful alternative. As with many aspects of software development, the key is to understand the trade-offs and choose the approach that best fits your specific needs and constraints.
By mastering both inheritance and composition, and understanding when to use each, you'll be better equipped to design flexible, maintainable software architectures that can stand the test of time and changing requirements.
Article created from: https://youtu.be/_hb_oRhASuw