1. Introduction
Design patterns constitute a set of solutions that have been tried and tested against recurring problems in software design. One common design pattern is the Composite pattern. This pattern helps us deal with complex structures both piece by piece or in totality.
In this tutorial, we’ll dive deeper into the Composite pattern. We’ll look at its definition, what problem it solves, demonstrate a simple implementation, and conclude with its pros and cons.
2. The Composite Pattern at a Glance
Now, let’s dive into the theory of the Composite pattern.
2.1. Definition
The Composite pattern is a structural pattern that allows us to treat a group of objects uniformly. This pattern comes in handy when we have a hierarchical structure of objects such as a tree and wish to perform operations on the entire structure as if it were a single object.
The key idea behind this pattern is that we have different types of objects: leaf or individual objects and composite objects. These different object types need to implement a common supertype, such as an interface or an abstract class. The common interface or component defines the operations that can be performed on the different types of objects in our object hierarchy.
The leaf object represents individual objects that contain no children, while the composite object can hold any number of child components. Therefore, the composite object can hold leaf objects as well as other composite objects.
2.2. Problem It Solves
Simply put, the Composite pattern allows us to design a client that can interact with any part of our hierarchy, regardless of the concrete type, through the component interface. It enables us to work with individual objects and groups of objects consistently. Without the use of this pattern, attempting to perform certain operations on our object hierarchy could compel us to nested loops or if-else statements, making our code cumbersome and difficult to manage.
In the next section, we’ll see how to use this pattern to build a simple movie player application.
3. Demonstration: Simple Movie Player Application
Our movie player application allows users to create playlists of their favorite movies. Each playlist can contain individual movies as well as other playlists. Therefore, the Playlist and Movie classes constitute our object hierarchy.
First, we’ll define our MovieComponent interface that represents the component we’ll implement for all object types:
interface MovieComponent {
fun play(): String
fun stop(): String
}
Next, we’ll implement the Movie class, which represents an individual leaf in the pattern:
class Movie(private val name: String) : MovieComponent {
override fun play(): String {
return "Playing movie: $name\n"
}
override fun stop(): String {
return "Stopping movie: $name\n"
}
}
Next, we need to define the Playlist class, which represents our composite component:
class Playlist(private val name: String) : MovieComponent {
private val movieComponents = mutableListOf<MovieComponent>()
fun add(movieComponent: MovieComponent) {
movieComponents.add(movieComponent)
}
override fun play(): String {
val result = StringBuilder()
result.append("Playing playlist: $name\n")
for (movieComponent in movieComponents) {
result.append(movieComponent.play())
}
return result.toString()
}
override fun stop(): String {
val result = StringBuilder()
result.append("Stopping playlist: $name\n")
for (movieComponent in movieComponents) {
result.append(movieComponent.stop())
}
return result.toString()
}
}
The Playlist class maintains a list of MovieComponent objects to hold movies or other playlists. It implements the play() method, which iterates the list of MovieComponent objects, and iteratively calls the play() method on each object. It does the same with the stop() method.
3.1. Testing
In this section, we’re going to look at what a possible client for our application looks like, as well as unit test our system for correctness:
fun main() {
val actionMoviesPlayList = Playlist("Action Movies")
actionMoviesPlayList.add(Movie("The Matrix"))
actionMoviesPlayList.add(Movie("Die Hard"))
val comicMoviesPlayList = Playlist("Comic Movies")
comicMoviesPlayList.add(Movie("The Hangover"))
comicMoviesPlayList.add(Movie("Bridesmaids"))
val allPlaylists = Playlist("All Playlists")
allPlaylists.add(actionMoviesPlayList)
allPlaylists.add(comicMoviesPlayList)
val playResult = allPlaylists.play()
val stopResult = allPlaylists.stop()
}
In this client, we create three playlists and add movies to them. Next, we can either play a movie/playlist using the play() method or stop playing it using the stop() method.
Notice that both the play() and stop() methods yield a result, and we’re going to investigate whether they are correct in a unit test:
class MovieApplicationUnitTest {
@Test
fun `should play movie`() {
val movie = Movie("The Matrix")
val result = movie.play()
assertEquals("Playing movie: The Matrix\n", result)
}
@Test
fun `should stop movie`() {
val movie = Movie("Die Hard")
val result = movie.stop()
assertEquals("Stopping movie: Die Hard\n", result)
}
@Test
fun `should play and stop playlist`() {
val playResult = allPlaylists.play()
val stopResult = allPlaylists.stop()
assertEquals("Playing playlist: All Playlists\n" +
"Playing playlist: Action Movies\n" +
"Playing movie: The Matrix\n" +
"Playing movie: Die Hard\n" +
"Playing playlist: Comic Movies\n" +
"Playing movie: The Hangover\n" +
"Playing movie: Bridesmaids\n", playResult)
assertEquals("Stopping playlist: All Playlists\n" +
"Stopping playlist: Action Movies\n" +
"Stopping movie: The Matrix\n" +
"Stopping movie: Die Hard\n" +
"Stopping playlist: Comic Movies\n" +
"Stopping movie: The Hangover\n" +
"Stopping movie: Bridesmaids\n", stopResult)
}
}
Specifically, we have two test cases: one that ensures that our Movie class correctly plays a movie and another that creates a playlist of playlists and recursively plays all the movies in each sub-playlist.
4. Pros and Cons
As with every other design pattern, the Composite pattern has several benefits and pitfalls when used to design our software systems.
Some benefits of these patterns are:
- It allows us to treat both the leaf and composite objects uniformly. We’ll make our code simpler and cleaner as we do not need to handle each object type differently.
- It provides a flexible way to represent hierarchical structures.
- It improves the scalability of our system by presenting a way to easily add new types of objects (leaves or composites) to the hierarchy without modifying our existing code.
On the other hand, this pattern also comes with some limitations:
- This pattern can be challenging to implement in some cases, especially when dealing with large hierarchies
- Limited flexibility in modifying individual components within the hierarchy, since it treats composite and leaf objects uniformly.
5. Conclusion
In this article, we’ve explored the concept behind the Composite pattern and how we can implement it in Kotlin. By treating leaf and composite objects in the same way, we can simplify client code and create more flexible systems. However, it is important to weigh the pros and cons before deciding to use this pattern in our project.
As always, the code samples and relevant test cases for this article can be found over on GitHub.