The decorator design pattern

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.

Note

The purpose of the decorator design pattern is to add functionality to objects without extending them and without affecting the behavior of other objects from the same class.

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.

Class diagram

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:

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.

Code example

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")))
  }
}

Note

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:

Code example

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:

Code example

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.

The decorator design pattern the Scala way

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.

Note

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.

Tip

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.

What is it good for?

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.

What is it not so good for?

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.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.137.41.205