There are cases where we might want to add some extra functionality to a class in an application. This could be done via inheritance; however, we might not want to do this or affect all the other classes in our application. This is where the decorator design pattern is useful.
The decorator design pattern works by wrapping the decorated object and it can be applied during runtime. Decorators are extremely useful in the cases where there could be multiple extensions of a class and they could be combined in various ways. Instead of writing all the possible combinations, decorators can be created and they can stack the modifications on top of each other. The next few subsections will show how and when to use decorators in real-world situations.
As we saw previously with the adapter design pattern, its aim is to change an interface to a different one. The decorator, on the other hand, helps us to enhance an interface by adding extra functionality to methods. For the class diagram, we will use an example with data streams. Imagine that we have a basic stream. We might want to be able to encrypt it, compress it, replace its characters, and so on. Here is the class diagram:
In the preceding diagram, the AdvancedInputReader
provides a basic implementation of the InputReader
. It wraps a standard BufferedReader
. Then, we have an abstract InputReaderDecorator
class that extends the InputReader
and contains an instance of it. By extending the base decorator, we provide the possibility to have streams that capitalize, compress, or Base64
encode the input they get. We might want to have different streams in our application and they could be able to do one or more of the preceding operations in different orders. Our code will quickly become difficult to maintain and messy if we try and provide all possibilities, especially when the number of possible operations is even more. With decorators, it is nice and clean as we will see in the next section.
Let's now have a look at the actual code that describes the decorator design pattern shown in the previous diagram. First of all, we define our InputReader
interface using a trait:
trait InputReader { def readLines(): Stream[String] }
Then, we provide the basic implementation of the interface in the AdvancedInputReader
class:
class AdvancedInputReader(reader: BufferedReader) extends InputReader { override def readLines(): Stream[String] = reader.lines().iterator().asScala.toStream }
In order to apply the decorator design pattern, we have to create different decorators. We have a base decorator that looks like the following:
abstract class InputReaderDecorator(inputReader: InputReader) extends InputReader { override def readLines(): Stream[String] = inputReader.readLines() }
Then we have different implementations of our decorator. First, we implement a decorator that turns all text into upper case:
class CapitalizedInputReader(inputReader: InputReader) extends InputReaderDecorator(inputReader) { override def readLines(): Stream[String] = super.readLines().map(_.toUpperCase) }
Next, we implement a decorator that uses gzip
to compress each line of our input separately:
class CompressingInputReader(inputReader: InputReader) extends InputReaderDecorator(inputReader) with LazyLogging { override def readLines(): Stream[String] = super.readLines().map { case line => val text = line.getBytes(Charset.forName("UTF-8")) logger.info("Length before compression: {}", text.length.toString) val output = new ByteArrayOutputStream() val compressor = new GZIPOutputStream(output) try { compressor.write(text, 0, text.length) val outputByteArray = output.toByteArray logger.info("Length after compression: {}", outputByteArray.length.toString) new String(outputByteArray, Charset.forName("UTF-8")) } finally { compressor.close() output.close() } } }
Finally, a decorator that encodes each line to Base64
:
class Base64EncoderInputReader(inputReader: InputReader) extends InputReaderDecorator(inputReader) { override def readLines(): Stream[String] = super.readLines().map { case line => Base64.getEncoder.encodeToString(line.getBytes(Charset.forName("UTF-8"))) } }
We have demonstrated the decorator design pattern using an intermediate abstract class that all decorators extend. We could have achieved this design pattern even without the intermediate class and by just directly extending and wrapping InputReader. This implementation, however, adds a bit more structure to our code.
Now we can use these decorators in our application to add extra functionality to our input stream as needed. The usage is straightforward. Here is an example:
object DecoratorExample { def main(args: Array[String]): Unit = { val stream = new BufferedReader( new InputStreamReader( new BufferedInputStream(this.getClass.getResourceAsStream("data.txt")) ) ) try { val reader = new CapitalizedInputReader(new AdvancedInputReader(stream)) reader.readLines().foreach(println) } finally { stream.close() } } }
In the preceding example, we used the text file part of our classpath with the following contents:
this is a data file which contains lines and those lines will be manipulated by our stream reader.
As expected, the order in which we apply decorators will define the order in which their enhancements will be applied. The output of the preceding example will be the following:
Let's see another example, but this time we will apply all the decorators we have:
object DecoratorExampleBig { def main(args: Array[String]): Unit = { val stream = new BufferedReader( new InputStreamReader( new BufferedInputStream(this.getClass.getResourceAsStream("data.txt")) ) ) try { val reader = new CompressingInputReader( new Base64EncoderInputReader( new CapitalizedInputReader( new AdvancedInputReader(stream) ) ) ) reader.readLines().foreach(println) } finally { stream.close() } } }
This example will read the text, capitalize it, Base64
encode it, and finally compress it with GZIP. The following screenshot shows the output:
As you can see from the preceding screenshot, in the compressing decorator code, we are logging the size of the lines in bytes. The output is gzipped and this is the reason for the text showing up as unreadable characters. You can experiment and change the order of the application of the decorators or add new ones in order to see how things can differ.
As with the other design patterns, this one has an implementation that takes advantage of the richness of Scala and uses some of the concepts we looked at throughout the initial chapters of this book. The decorator design pattern in Scala is also called
stackable traits. Let's see how it looks like and how to use it. The InputReader
and AdvancedInputReader
code will remain exactly as shown in the previous section. We are actually reusing it in both examples.
Next, instead of defining an abstract decorator
class, we will just define the different reader modifications in new traits as follows:
trait CapitalizedInputReaderTrait extends InputReader { abstract override def readLines(): Stream[String] = super.readLines().map(_.toUpperCase) }
Then, we define the compressing input reader:
trait CompressingInputReaderTrait extends InputReader with LazyLogging { abstract override def readLines(): Stream[String] = super.readLines().map { case line => val text = line.getBytes(Charset.forName("UTF-8")) logger.info("Length before compression: {}", text.length.toString) val output = new ByteArrayOutputStream() val compressor = new GZIPOutputStream(output) try { compressor.write(text, 0, text.length) val outputByteArray = output.toByteArray logger.info("Length after compression: {}", outputByteArray.length.toString) new String(outputByteArray, Charset.forName("UTF-8")) } finally { compressor.close() output.close() } } }
Finally, the Base64
encoder reader:
trait Base64EncoderInputReaderTrait extends InputReader { abstract override def readLines(): Stream[String] = super.readLines().map { case line => Base64.getEncoder.encodeToString(line.getBytes(Charset.forName("UTF-8"))) } }
As you can see, the implementation here is not much different. Here, we used traits instead of classes, extended the base InputReader
trait, and used abstract override
.
Abstract override allows us to call super
for a method in a trait that is declared abstract. This is permissible for traits as long as the trait is mixed in after another trait or a class that implements the preceding method. The abstract override
tells the compiler that we are doing this on purpose and it will not fail our compilation—it will check later, when we use the trait, whether the requirements for using it are satisfied.
Above we presented two examples. We will now show how they look like with stackable traits. The first one that only capitalizes will look like the following:
object StackableTraitsExample { def main(args: Array[String]): Unit = { val stream = new BufferedReader( new InputStreamReader( new BufferedInputStream(this.getClass.getResourceAsStream("data.txt")) ) ) try { val reader = new AdvancedInputReader(stream) with CapitalizedInputReaderTrait reader.readLines().foreach(println) } finally { stream.close() } } }
The second example that capitalizes, Base64
encodes, and compresses the stream will look like the following:
object StackableTraitsBigExample { def main(args: Array[String]): Unit = { val stream = new BufferedReader( new InputStreamReader( new BufferedInputStream(this.getClass.getResourceAsStream("data.txt")) ) ) try { val reader = new AdvancedInputReader(stream) with CapitalizedInputReaderTrait with Base64EncoderInputReaderTrait with CompressingInputReaderTrait reader.readLines().foreach(println) } finally { stream.close() } } }
The output of both the examples will be exactly the same as in the original examples. Here, however, we are using mixin composition and things look somewhat cleaner. We also have one class less, as we don't need the abstract decorator
class. Understanding how modifications are applied is also easy—we just follow the order in which the stackable traits are mixed in.
Stacking traits follow the rules of linearization
The fact that in our current example the modifications are applied from left to right is deceiving. The reason this happens is because we push calls on the stack until we reach the basic implementation of readLines and then apply modifications in a reverse order.
We will see more in-depth examples of stackable traits that will showcase all of their specifics in the coming chapters of this book.
Decorators add a lot of flexibility to our applications. They don't change the original classes, hence don't introduce errors in the older code and can save on a lot of code writing and maintenance. Also, they could prevent us from forgetting or not foreseeing some use cases with the classes we create.
In the previous examples, we showed some static behavior modifications. However, it is also possible to dynamically decorate instances at runtime.
We have covered the positive aspects of using decorators; however, we should point out that overusing decorators could cause issues as well. We might end up with a high number of small classes and they could make our libraries much harder to use and more demanding in terms of requiring more domain knowledge. They also complicate the instantiation process, which would require other (creational) design patterns, for example, factories or builders.
3.137.41.205