What is Composition in Object Oriented Programming?
Composition is a strategy in object oriented programming where you build complex entities out of smaller, less complex entities.
For example, lets say we are building a service to track geolocated plant identifications. You might have a PlantObservation
class:
data class PlantObservation(
val genus: String,
val species: String,
val latitude: Double,
val longitude: Double,
val commonName: String? = null,
val heightInCm: Double? = null,
val isPerennial: Boolean = true
) { }
With this class we can record an observation and add a latitude
and logitude
to the observation so that we can map our observations.
But there is a challenge with this. Now PlantObservation
needs to know how to handle latitude
and longitude
. For example, what if we wanted to know if an observation is inside specific region on a map, each PlantObservation
would need to know how to make spatial comparisons and we might end up with code that looks like this:
data class PlantObservation(
val genus: String,
val species: String,
val latitude: Double,
val longitude: Double,
val commonName: String? = null,
val heightInCm: Double? = null,
val isPerennial: Boolean = true
) {
fun isInsidePolygon(polygon: List<Location>): Boolean {
// do calculations to decide if `latitude` and
// `longitude` are inside the polygon
}
}
The code above might look a bit contrived, but it is fairly common to come across code that evolves this way. Why does PlantObservation
know about polygon's data structure? It really doesn't need to, but someone at one point thought this was the right way to do it.
So let's refactor this so that PlantObservation
uses composition to rely on a Location
object to handle location information:
data class Location(
val latitude: Double,
val longitude: Double,
val name: String? = null
) {
fun isInsidePolygon(polygon: List<Location>): Boolean {
// do calculations to decide if `latitude` and
// `longitude` are inside the polygon
}
}
data class PlantObservation(
val genus: String,
val species: String,
val location: Location
val commonName: String? = null,
val heightInCm: Double? = null,
val isPerennial: Boolean = true
) {
// Delegate isInsidePolygon to the location object
fun isInsidePolygon(polygon: List<Location>): Boolean = location.isInsidePolygon(polygon)
}
Now our PlantObservation
is composed of its own data and a Location
.
Let's take this a step further and add additional data describing the plant:
data class PlantDescription(
val heightInCm: Double,
val leafShape: String,
val flowerColor: String? = null
val isPerennial: Boolean = true,
val genus: String,
val species: String,
val commonName: String? = null,
) {
fun isWithinHeight(min: Double, max: Double): Boolean {
return min < heightInCm && max > heightInCm
}
}
data class PlantObservation(
val location: Location,
val description: PlantDescription,
) {
// Delegate isInsidePolygon to the location object
fun isInsidePolygon(polygon: List<Location>): Boolean = location.isInsidePolygon(polygon)
// Delegate isWithinHeight to the description object
fun isWithinHeight(min: Double, max: Double): Boolean = description.isWithinHeight(min, max)
}
Now our PlantObservation
is composed of a location
and a description
and each component is responsible for its own logic. You might notice that PlantDescription
has a mixed responsibility too. For example, mapping genus
and species
onto existing names could be encapsulated into a ScientificName
class that understands the relationship between different genus and species.
Composition is a practice that allows developers to encapsulate behaviours in component parts. This prevents the parent class from growing in complexity and keeps discrete logic individual classes.
Testing and Composition
Composition is handy when you need to mock data in a test. Lets look at isPerennial
, which we had on Location
in the example above. A plant might be a perennial in one geographic area but only an annual in another.
If we create isPerennialInLocation
to PlantObservation
we can use isInsidePolygon
to determine if the plant is a perennial in that region.
data class PlantObservation(
val location: Location,
val description: PlantDescription,
) {
fun isInsidePolygon(polygon: List<Location>): Boolean = location.isInsidePolygon(polygon)
fun isPerennialInLocation: Boolean {
// check if observation is inside the polyon that maps where the plant is considered perenial
}
}
Testing this is now easy because we can pass custom Location
objects into PlantObservation
to test our isPerennialInLocation
function. Each location
we create can be configured to set up the correct test environment.
A very common example of this in testing is when you're working with randomly generated values. How do you test myVal > Math.random
for example? For this example, let's say you're reporting metrics on user behaviour for the plant observation app but you don't want to report all the metrics, you only want a sample.
import kotlin.random.Random
class UserBehaviourReport {
fun sendReport(): String? {
if (!Random.nextBoolean()) {
println("Report not sent")
return null
}
// Simulate report generation
val report = generateMockReport()
// Simulate sending the report
println("Sending report: $report")
// make HTTP calls to send report here
return report
}
private fun generateMockReport(): String {
// build report string here
}
}
If we were to test sendReport
we would have to try to mock Random
to control the flow of the tests. But if we use composition, we can pass a random generator into the report:
import kotlin.random.Random
class RandomBooleanGenerator(seed: Long? = null) : Random(seed) {
fun getRandomBoolean(): Boolean {
return nextBoolean()
}
}
class UserBehaviourReport(
private val randomGenerator: Random
) {
fun sendReport(): String? {
if (!randomGenerator.nextBoolean()) {
println("Report not sent")
return null
}
// Simulate report generation
val report = generateMockReport()
// Simulate sending the report
println("Sending report: $report")
// make HTTP calls to send report here
return report
}
private fun generateMockReport(): String {
// build report string here
}
}
Now in our test suite, we can pass in a seed so that our tests run in a deterministic fashion:
// Create a RandomBooleanGenerator with a seed for reproducibility
val randomGenerator = RandomBooleanGenerator(seed = 12345)
// Create a UserBehaviourReport with the RandomBooleanGenerator
val reporter = UserBehaviourReport(randomGenerator)