The strategy design pattern

It is quite a common thing in enterprise applications to have different implementations of specific algorithms and choosing one to use while the application is running. Some examples might include different sorting algorithms that would have different performance for different sizes or types of data, different parsers for various possible representations of data, and so on. The strategy design pattern enables us to:

Note

Define a family of algorithms and select a specific one at runtime.

The strategy design pattern helps with encapsulation as each algorithm can be separately defined and then injected into the classes that use it. The different implementations are also interchangeable.

Class diagram

For the class diagram, let's imagine that we are writing an application that needs to load some data from a file and then use this data somehow. Of course, the data could be represented in different formats (CSV or JSON, in this case), and depending on the file type, we will be using a different parsing strategy. The class diagram that represents our parser is shown in the following figure:

Class diagram

We basically have an interface that the different classes implement, and then depending on which one is needed, PersonApplication gets injected with the right one.

The preceding class diagram looks really similar to the one we saw earlier in the book for the bridge design pattern. Even though this is the case, both patterns have different purposes—the builder is concerned with structure, while here it's all about behavior. Also, the strategy design pattern looks somewhat more coupled.

Code example

In the previous section, we showed the class diagram of the example we will show here. As you can see, we used a model class called Person. It is just a case class with the following definition:

case class Person(name: String, age: Int, address: String)

Since there will be different formats available in our application, we have defined a common interface that all parsers will implement:

trait Parser[T] {
  def parse(file: String): List[T]
}

Let's now have a look at the implementations. First is the CSVParser:

class CSVParser extends Parser[Person] {
  override def parse(file: String): List[Person] =
    CSVReader.open(new InputStreamReader(this.getClass.getResourceAsStream(file))).all().map {
      case List(name, age, address) =>
        Person(name, age.toInt, address)
    }
}

It relies on a library called scala-csv (details in the pom.xml file), which reads each line as a list of strings. Then they are mapped to the Person objects.

Next is the code for the JsonParser:

class JsonParser extends Parser[Person] {
  implicit val formats = DefaultFormats
  
  override def parse(file: String): List[Person] = JsonMethods.parse(StreamInput(this.getClass.getResourceAsStream(file))).extract[List[Person]]
}

It reads a JSON file and parses it using the json4s library. As you can see, even though both implementations do the same thing, they are quite different. We cannot apply the CSV one when we have a JSON file and vice versa. The files also look really different. Here is the CSV file we are using in our example:

Ivan,26,London
Maria,23,Edinburgh
John,36,New York
Anna,24,Moscow

This is the JSON file:

[
  {
    "name": "Ivan",
    "age": 26,
    "address": "London"
  },
  {
    "name": "Maria",
    "age": 23,
    "address": "Edinburgh"
  },
  {
    "name": "John",
    "age": 36,
    "address": "New York"
  },
  {
    "name": "Anna",
    "age": 24,
    "address": "Moscow"
  }
]

Both the preceding datasets contain exactly the same data, but the formats make them look completely different and they require different approaches in parsing.

There is one extra thing we've done in our example. We've used a factory design pattern in order to pick the right implementation at runtime, depending on the file type:

object Parser {
  def apply(filename: String): Parser[Person] =
    filename match {
      case f if f.endsWith(".json") => new JsonParser
      case f if f.endsWith(".csv") => new CSVParser
      case f => throw new RuntimeException(s"Unknown format: $f")
    }
}

The preceding factory is just an example. It only checks the file extension and of course, could be made much more robust. Using this factory we can pick the right implementation of the parser for the application class to use, whose code is shown as follows:

class PersonApplication[T](parser: Parser[T]) {
  
  def write(file: String): Unit = {
    System.out.println(s"Got the following data ${parser.parse(file)}")
  }
}

The application class looks the same no matter what the implementation is. Different implementations could be plugged in, and as long as there are no errors, everything should run.

Let's now see how we can use our strategy design pattern in our example:

object ParserExample {
  def main(args: Array[String]): Unit = {
    val csvPeople = Parser("people.csv")
    val jsonPeople = Parser("people.json")
    
    val applicationCsv = new PersonApplication(csvPeople)
    val applicationJson = new PersonApplication(jsonPeople)
    
    System.out.println("Using the csv: ")
    applicationCsv.write("people.csv")
    
    System.out.println("Using the json: ")
    applicationJson.write("people.json")
  }
}

As you can see, it's pretty simple. The output of the preceding application is shown as follows:

Code example

In both cases, our application coped just fine with the different formats. Adding new implementations for new formats is also straightforward—just implement the Parser interface and make sure the factory knows about them.

The strategy design pattern the Scala way

In the preceding section, we showed the strategy design pattern using classes and traits. This is how it would look in a purely object-oriented language. However, Scala is also functional and provides more ways to achieve it by writing far less code. In this subsection, we will show the strategy design pattern by taking advantage of the fact that in Scala functions are first-class objects.

The first thing that will change is that we will not need to have an interface and classes that implement it. Instead, our Application class will look like the following:

class Application[T](strategy: (String) => List[T]) {
  def write(file: String): Unit = {
    System.out.println(s"Got the following data ${strategy(file)}")
  }
}

The most important thing to note here is that the strategy parameter is a function instead of a normal object. This instantly allows us to pass any function we want there without the need to implement specific classes, as long as it satisfies these requirements: one String parameter and returns a List[T]. If we have multiple methods in our strategy, we can use a case class or a tuple to group them.

For the example, we've decided to have the function implementations somewhere so that they are grouped with the factory, which will choose which one to use:

object StrategyFactory {
  implicit val formats = DefaultFormats
  
  def apply(filename: String): (String) => List[Person] =
    filename match {
      case f if f.endsWith(".json") => parseJson
      case f if f.endsWith(".csv") => parseCsv
      case f => throw new RuntimeException(s"Unknown format: $f")
    }
  
  def parseJson(file: String): List[Person] = JsonMethods.parse(StreamInput(this.getClass.getResourceAsStream(file))).extract[List[Person]]
  
  def parseCsv(file: String): List[Person] = CSVReader.open(new InputStreamReader(this.getClass.getResourceAsStream(file))).all().map {
      case List(name, age, address) => Person(name, age.toInt, address)
    }
}

The preceding code has the same factory as before, but this time it returns methods, which then can be called.

Finally, here is how to use the application:

object StrategyExample {
  def main(args: Array[String]): Unit = {
    val applicationCsv = new Application[Person](StrategyFactory("people.csv"))
    val applicationJson = new Application[Person](StrategyFactory("people.json"))
    
    System.out.println("Using the csv: ")
    applicationCsv.write("people.csv")

    System.out.println("Using the json: ")
    applicationJson.write("people.json")
  }
}

The output of the preceding example will be absolutely the same as before.

Note

The strategy design pattern helps us when we want to be able to change implementations at runtime.

Even though, in the long run, the strategy pattern that uses functions could save a lot on code, sometimes it affects readability and maintainability. The fact that the methods could be stored in an object, class, case class, trait, and so on, indicates the fact that different people could prefer different approaches, and this is not always good while working in a big team.

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

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