Scala Design Patterns
上QQ阅读APP看书,第一时间看更新

Algebraic data types and class hierarchies

Algebraic data types and class hierarchies are other unifications in the Scala programming language. In other functional languages, there are special ways to create custom algebraic data types. In Scala, this is achieved using class hierarchies and namely case classes and objects. Let's see what an ADT actually is, what types there are, and how to define them.

ADTs

Algebraic data types are just composite types that combine other existing types or just represent some new ones. They have only data and do not contain any functionality on top of this data as normal classes would. Some examples can include the day of the week or a class that represents an RGB color—they have no extra functionality and they just carry information. The following few subsections will give a bit more insight on what ADTs are and what types are out there.

Sum ADTs

Sum algebraic data types are the ones in which we can simply enumerate all the possible values of a type and provide a separate constructor for each value. As an example, let's consider the months of the year. There are only 12 and they cannot change (hopefully):

sealed abstract trait Month 
case object January extends Month 
case object February extends Month 
case object March extends Month 
case object April extends Month 
case object May extends Month 
case object June extends Month 
case object July extends Month 
case object August extends Month 
case object September extends Month 
case object October extends Month 
case object November extends Month 
case object December extends Month 

object MonthDemo { 
  def main(args: Array[String]): Unit = { 
    val month: Month = February 
    System.out.println(s"The current month is: $month") 
  } 
}

Running this application will produce the following output:

The current month is: February
Note

The Month trait in the preceding code is sealed because we do not want it to be extended outside the current file. As you can see, we've defined the different months as objects, as there is no reason for them to be separate instances. The values are what they are and they do not change.

Product ADTs

In product algebraic data types, we cannot enumerate all the possible values. They are usually too many manually write them. We cannot provide a separate constructor for each separate value.

Let's think about colors. There are different color models, but one of the most famous ones is RGB. It combines the different values of the main colors (red, green, and blue) in order to represent other colors. If we say that each of these colors can have a value between 0 and 255, this would mean that to represent all possibilities, we would need to have 2563 different constructors. That's why we can use a product ADT:

sealed case class RGB(red: Int, green: Int, blue: Int) 

object RGBDemo { 
  def main(args: Array[String]): Unit = { 
    val magenta = RGB(255, 0, 255) 
    System.out.println(s"Magenta in RGB is: $magenta") 
  } 
}
Note

Now we can see that for the product ADTs, we have one constructor for different values.

Hybrid ADTs

Hybrid algebraic data types represent a combination of the sum and product ones we described previously. This means that we can have specific value constructors, but these value constructors also provide parameters in order to wrap other types.

Let's see an example. Imagine we are writing a drawing application:

sealed abstract trait Shape 
case class Circle(radius: Double) extends Shape 
case class Rectangle(height: Double, width: Double) extends Shape

We have different shapes. The preceding example shows a sum ADT because we have the Circle and Rectangle specific value constructors. Also, we have a product ADT because the constructors take extra parameters.

Let's expand our classes a bit. When drawing our shapes, we need to know their positions. This is why we can add a Point class that holds the x and y coordinates:

case class Point(x: Double, y: Double) 

sealed abstract trait Shape 
case class Circle(centre: Point, radius: Double) extends Shape 
case class Rectangle(topLeft: Point, height: Double, width: Double) extends Shape

This should hopefully clarify what are ADTs in Scala and how they can be used.

The unification

After all of the preceding examples, it is obvious that class hierarchies and ADTs are unified and look like the same thing. This adds a high level of flexibility in the language and makes modeling easier than in other functional programming languages.

Pattern matching

Pattern matching is often used with ADTs. It makes the code much clearer and more readable as well as easier to extend in comparison to using the if-else statements when trying to do something with ADTs based on their values. As you could imagine, these statements can get quite cumbersome in some cases, especially when there are many different possible values for a certain data type.

Pattern matching with values

In the month's example stated previously, we just have the month names. We might, however, want to also get their number, as the computer will not know this otherwise. Here is how to do this:

object Month { 
  def toInt(month: Month): Int = 
    month match { 
      case January => 1 
      case February => 2 
      case March => 3 
      case April => 4 
      case May => 5 
      case June => 6 
      case July => 7 
      case August => 8 
      case September => 9 
      case October => 10 
      case November => 11 
      case December => 12 
    } 
}

You can see how we match the different values and based on them, we return the correct values. Here is how this method can be used now:

System.out.println(s"The current month is: $month and it's number ${Month.toInt(month)}")

As expected, our application will produce the following:

The current month is: February and it's number 2

The fact that we have specified our base trait to be sealed guarantees that nobody else will extend it outside our code, and we will be able to have an exhaustive pattern match. Unexhaustive pattern matches are problematic. Just as an experiment, if we try to comment out the match rule for February and we compile, we will see the following warning:

Warning:(19, 5) match may not be exhaustive. 
It would fail on the following input: February 
    month match { 
    ^

Running the example this way proves that the warning is true and our code has failed when we use February as a parameter. For the sake of completeness, we can add a default case:

case _ => 0
Pattern matching for product ADTs

Pattern matching shows its real power when used for product and hybrid ADTs. In such cases, we can match the actual values of the data types. Let's see how we would implement a functionality to calculate the area of a shape, as defined previously:

object Shape { 
  def area(shape: Shape): Double = 
    shape match { 
      case Circle(Point(x, y), radius) => Math.PI * Math.pow(radius, 2) 
      case Rectangle(_, h, w) => h * w 
    } 
}

When matching, we can ignore values we don't care about. For the area, we don't really need the position information. In the preceding code, we just showed two different ways in which a matching is possible. The _ operator can be anywhere in the match statement, and it will just ignore the value it is put for. After this, using our example is straightforward:

object ShapeDemo { 
  def main(args: Array[String]): Unit = { 
    val circle = Circle(Point(1, 2), 2.5) 
    val rect = Rectangle(Point(6, 7), 5, 6) 
    
    System.out.println(s"The circle area is: ${Shape.area(circle)}") 
    System.out.println(s"The rectangle area is: ${Shape.area(rect)}") 
  } 
}

This will have an output similar to the following:

The circle area is: 19.634954084936208 
The rectangle area is: 30.0

We can even put constants instead of variables for the ADT constructor parameters during pattern matching. This makes the language quite powerful and allows us to achieve even more complex logic, which will still look quite nice. You can try experimenting with the preceding examples in order to get an idea of how pattern matching actually works.

Note

Pattern matching is often used with ADTs and it helps to achieve clean, extendible, and exhaustive code.