alpaca0984.log

Composition over inheritance

alpaca0984

Have you ever heard of “composition over inheritance”? It's one of my favorite concepts in software design. According to Wikipedia, it is:

Classes should achieve polymorphic behavior and code reuse by their composition (by containing instances of other classes that implement the desired functionality) rather than inheritance from a base or parent class

Does it make sense to you? Even if not, don’t worry. I will show what it looks like and why it is good.


For instance, let’s suppose that you are an Engineering Manager having some Java or Kotlin repositories. As a manager, you work with both developers.

class EngineeringManager {
 
    fun workWithJavaDeveloper(dev: JavaDeveloper) {
        dev.writeJava()
    }
 
    fun workWithKotlinDeveloper(dev: KotlinDeveloper) {
        dev.writeKotlin()
    }
}

Since Kotlin is a newer language that is 100% compatible with Java, you may assume Kotlin developers can write also Java. You can assign a Kotlin developer to a Java project. In this case, how do you define JavaDeveloper and KotlinDeveloper classes?

fun main() {
    val manager = EngineeringManager()
 
    val javaDeveloper = JavaDeveloper()
    val kotlinDeveloper = KotlinDeveloper()
    val kotlinDeveloper2 = KotlinDeveloper()
 
    // For project 1
    //  - assign a Java developer to a Java project
    //  - assign a Kotlin developer to a Kotlin project
    manager.workWithJavaDeveloper(javaDeveloper)
    manager.workWithKotlinDeveloper(kotlinDeveloper)
 
    // For project 2
    //  - assign Kotlin developer to a Java project
    manager.workWithJavaDeveloper(kotlinDeveloper2)
}

What does code look like using inheritance?

First, let's try using classical inheritance. Simply you will make KotlinDeveloper inherit JavaDeveloper.

open class JavaDeveloper {
 
    fun writeJava() {
        // Do something
    }
}
 
class KotlinDeveloper : JavaDeveloper() {
 
    fun writeKotlin() {
        // Do something
    }
}

Here, you have to declare JavaDeveloper with open keyword because, in Kotlin, classes are final by default. It looks like Kotlin doesn't recommend us using inheritance, does it? You might hear of the SOLID principle. They are mostly about good practices using inheritances. Things are more and more complicated when the inheritance becomes deeper and deeper.

The question is, how do we add functionalities without using inheritance? Here is where we introduce composition.

What does code look like using composition (delegation)?

The title of this blog is composition-over-inheritance. The composition there means that adding functionalities to a class should be achieved by implementing interfaces and delegation, rather than inheritance. Let's see an example.

interface JavaDeveloper {
 
    fun writeJava()
}
 
class JavaDeveloperImpl : JavaDeveloper {
 
    override fun writeJava() {
        // Do something with Java
    }
}
 
class KotlinDeveloper(
    private val javaDeveloper: JavaDeveloper,
) : JavaDeveloper {
 
    override fun writeJava() {
        javaDeveloper.writeJava()
    }
 
    fun writeKotlin() {
        // Do something with Kotlin
    }
}
 
fun main() {
    val manager = EngineeringManager()
 
    val javaDeveloper = JavaDeveloperImpl()
    val kotlinDeveloper = KotlinDeveloper(javaDeveloper)
    val kotlinDeveloper2 = KotlinDeveloper(javaDeveloper)
 
    // For project 1
    //  - assign a Java developer to a Java project
    //  - assign a Kotlin developer to a Kotlin project
    manager.workWithJavaDeveloper(javaDeveloper)
    manager.workWithKotlinDeveloper(kotlinDeveloper)
 
    // For project 2
    //  - assign Kotlin developer to a Java project
    manager.workWithJavaDeveloper(kotlinDeveloper2)
}

It might look awkward that a developer receives another developer and ask them to work but, it is just an example. You can assume that a Kotlin developer is injected with the capability of working as a JavaDeveloper. The point is that now Kotlin developers can also act as JavaDeveloper without inheritance. Correspondingly, the JavaDeveloper class doesn't have to have open modifier anymore.

It will minimize software design issues caused by inheritance. It is cool, isn't it?

But it has some drawbacks such as:

  1. We have to inject dependencies
  2. We have to write tons of override functions that just delegate messages to dependencies.

Let's see how we address them.

Address drawbacks of composition

The first one — injecting dependencies — can be handled by dependency inject frameworks such as Dagger, Kodein, Koin etc... If you work on Android apps, probably you use Hilt.

The second one — overriding functions — is managed by Kotlin's built-in delegation mechanism. That's one of my favorite language specs in Kotlin. You can simply use by keyword for interfaces and delegate implementation to dependencies.

class KotlinDeveloper(
    private val javaDeveloper: JavaDeveloper,
) : JavaDeveloper by javaDeveloper {
 
    fun writeKotlin() {
        // Do something with Kotlin
    }
}

We successfully removed the override fun writeJava that doesn't do anything in KotlinDeveloper class.

Why is composition better than inheritance?

We can rephrase the question to “Why is shallow inheritance good?” since composition doesn’t require class hierarchy. And it is good because, less and less inheritance we have, less and less maintenance cost we pay. Let’s suppose that we have deep inheritance, when we want to change behaviors at some level, we have to traverse parents to establish consistency. The below article is not really about composition but it shows quite well what kind of problem we will have with deep inheritance.

When do we use inheritance, not composition?

In many cases, composition is preferred over inheritance but it doesn’t mean we don’t use inheritance at all. The link put below is an Android app sample published by an official Android team.

In the link, they define AddEditTaskFragment extending Fragment. It totally makes sense because:

  • AddEditTaskFragment is a Fragment indeed (Inheritance is IS-A representation, whereas composition is HAS-A representation)
  • Fragment itself has a bunch of default behavior and for each Fragment we just want to change some of them for different use cases