Pattern Matching - Becoming Functional (2014)

Becoming Functional (2014)

Chapter 8. Pattern Matching

Mostly when we, as programmers, think of pattern matching we think of regular expressions. But in the context of functional programming, this terminology takes on a new meaning. Instead of regular expression matching, we’re going to be looking at matching objects against other objects.

Using pattern matching, you can extract from objects, match on members of objects, and verify that objects are of specific types—all within a statement. Pattern matching allows for fewer lines of variable assignment and more lines of understandable code. With pattern matching, you can match on members of an object, which allows you to write more concise logic for when a specific segment of code should be executed.

Simple Matches

Now that the code has started shaping up, our boss has asked us to create a new function that will create a new Customer. The requirements are as follows:

§ name cannot be blank.

§ state cannot be blank.

§ domain cannot be blank.

§ enabled must be true to start with.

§ contract will be created based on today’s date.

§ contacts should be created as a blank list for now.

Our basic method, as shown in Example 8-1, uses a large if structure to return null in the event that an invalid value is passed in. We’re currently printing to the console, but we should also log the message being sent.

Example 8-1. Imperative createCustomer method using an if structure

if(name.isEmpty) {

println("Name cannot be blank")

null

} elseif(state.isEmpty) {

println("State cannot be blank")

null

} elseif(domain.isEmpty) {

println("Domain cannot be blank")

null

} else {

newCustomer(

0,

name,

state,

domain,

true,

newContract(Calendar.getInstance, true),

List()

)

}

This is a huge if structure that we do not want to maintain. Think back to Chapter 1, in which we were creating extractors from our Customer objects by using an if statement. We’re almost doing the same thing here, using a giant if structure to determine whether certain fields are blank. By the end of our examples, we’re going to see how this will become a much more manageable check.

For now, let’s perform a simple refactor by using a very basic pattern match in Example 8-2. Using a pattern match is fairly straightforward in this instance: we’re going to match each of our elements against a blank string, "".

The variable before the match keyword is what we’ll pattern-match against. Inside the match statement are all of the patterns that we’re going to test against, each defined with the case keyword. Right now, we just have "", which indicates a blank string, and the underscore _, which indicates anything.

Example 8-2. Very basic pattern match inside createCustomer

def createCustomer(name :String, state :String, domain :String) :Customer = {

name match {

case "" => {

println("Name cannot be blank")

null

}

case_=> state match {

case "" => {

println("State cannot be blank")

null

}

case_=> domain match {

case "" => {

println("Domain cannot be blank")

null

}

case_=>newCustomer(

0,

name,

state,

domain,

true,

newContract(Calendar.getInstance, true),

List()

)

}

}

}

}

Remember, we’re transitioning to a better pattern match, so our first step has been to re-create the if/else structure, but in a pattern-match style. At first it seems like this has created an even larger mess, but don’t worry: we’re going to reduce the complexity quite a bit in the next sections.

Simple Patterns

Let’s modify the pattern match that we created in createCustomer to be only one level deep. We can do this by creating a tuple (a group of elements) that we can then match against. Let’s see this refactor in Example 8-3.

We are now defining a tuple (name, state, domain) against which we’re going to match. What is so different here is that now we can match against each part of the tuple. We do this with case ("", _, _) which lets us say that this pattern should be a tuple with a blank string as the first value, and we don’t care what the other two are.

Example 8-3. Collapsed pattern match to handle input validations

def createCustomer(name :String, state :String, domain :String) :Customer = {

(name, state, domain) match {

case ("", _, _) => {

println("Name cannot be blank")

null

}

case (_, "", _) => {

println("State cannot be blank")

null

}

case (_, _, "") => {

println("Domain cannot be blank")

null

}

case_=>newCustomer(

0,

name,

state,

domain,

true,

newContract(Calendar.getInstance, true),

List()

)

}

}

Now that we have a way to convert if statements into pattern matches, let’s see if we can convert another large if/else structure in our code base. Let’s look at the original setContractForCustomerList method, shown in Example 8-4, which handles blank initialIds and idsparameters with a large if statement. Inside the else, we find the original Customer by id; if the customer is defined, we will execute our cls to update the Customer, putting it into a list. We then merge the list containing our updated Customer with the return of the recursive call.

Example 8-4. The original updateCustomerByIdList method

def updateCustomerByIdList(initialIds :List[Customer],

ids :List[Integer],

cls :Customer => Customer) :List[Customer] = {

if(ids.size <= 0) {

initialIds

} elseif(initialIds.size <= 0) {

initialIds

} else {

val precust = initialIds.find(cust => cust.customer_id == ids(0))

val cust =if(precust.isEmpty) { List() } else { List(cls(precust.get)) }

cust ::: updateCustomerByIdList(

initialIds.filter(cust => cust.customer_id == ids(0)),

ids.tail,

cls

)

}

}

We know how to handle this via pattern matching, so let’s wrap those two variables into a tuple and match against a blank list. Much like our blank string "", we can imitate the blank list with List(), as shown in Example 8-5.

Example 8-5. Converting the original if/else structure into a pattern match

def updateCustomerByIdList(initialIds :List[Customer],

ids :List[Integer],

cls :Customer => Customer) :List[Customer] = {

(initialIds, ids) match {

case (List(), _) => initialIds

case (_, List()) => initialIds

case_=> {

val precust = initialIds.find(cust => cust.customer_id == ids(0))

val cust =if(precust.isEmpty) { List() } else { List(cls(precust.get)) }

cust ::: updateCustomerByIdList(

initialIds.filter(cust => cust.customer_id == ids(0)),

ids.drop(1),

cls

)

}

}

}

But can we reduce the complexity of this method even further? Yes—by introducing extractors, specifically list extractors.

Extracting Lists

As their name implies, you can use extractors to pattern-match based on the object and extract members from the object itself. We’ll see how to extract elements out of objects in the next section, but right now let’s look at extracting from a list.

As you might recall, lists have a head and a tail, and we should be able to move through our list one item at a time by looking at the head and passing the tail to look at later. So let’s check out the list extraction in Example 8-6 to see how we can move through our ids variable.

The :: operator, when used in a case statement, tells Scala that a list is expected and that the list should be decomposed into its head element (to the left of the operator) and its tail element (to the right of the operator). The variables into which the items are extracted exist only during the specific pattern execution.

The case (_, id :: tailIds) pattern will extract the head of the ids variable into a new variable called id and the tail of the ids into a new variable called tailIds.

Example 8-6. Extracting the head and tail from a list

def updateCustomerByIdList(initialIds :List[Customer],

ids :List[Integer],

cls :Customer => Customer) :List[Customer] = {

(initialIds, ids) match {

case (List(), _) => initialIds

case (_, List()) => initialIds

case (_, id :: tailIds) => {

val precust = initialIds.find(cust => cust.customer_id == id)

val cust =if(precust.isEmpty) { List() } else { List(cls(precust.get)) }

cust ::: updateCustomerByIdList(

initialIds.filter(cust => cust.customer_id == id),

tailIds,

cls

)

}

}

}

We’re going to convert the find return into a list and then pattern-match against it. There are two possibilities here: either we will have a blank list, or we want the head element from the list itself. Let’s look at the code in Example 8-7, in which we are doing this match.

The find return is being converted into a list for us to match against. We then perform the match on that and determine whether the list is blank or has elements (in which case we take the first one).

Example 8-7. Extracting the found customer during the find call

def updateCustomerByIdList(initialIds :List[Customer],

ids :List[Integer],

cls :Customer => Customer) :List[Customer] = {

(initialIds, ids) match {

case (List(), _) => initialIds

case (_, List()) => initialIds

case (_, id :: tailIds) => {

val precust = initialIds.find(cust => cust.customer_id == id).toList

precust match {

caseList() => updateCustomerByIdList(initialIds, tailIds, cls)

case cust :: custs => updateCustomerByIdList(

initialIds.filter(cust => cust.customer_id == id),

tailIds,

cls

)

}

}

}

}

So, why are we converting the return of find to a list? Well, the find method returns an Option, which is a generic interface that has two implementing classes: Some or None. As you might have guessed, the Some class will actually contain the object, whereas the None object contains nothing. We can convert the Option object to a List, which we can then pattern-match against.

However, we can actually pattern-match against the Option interface and reduce the need to convert it to a list. We’ll get rid of our precust variable as well as the toList conversion. Instead, we’re just going to send the find result directly to our pattern match.

We will create two case statements: one to match on the None object, and the other to match on Some. Notice in Example 8-8 that when we match on Some, we can use the syntax Some(cust), which allows us to extract the member of Some into our own variable, cust.

Example 8-8. Using the pattern match

def updateCustomerByIdList(initialIds :List[Customer],

ids :List[Integer],

cls :Customer => Customer) :List[Customer] = {

(initialIds, ids) match {

case (List(), _) => initialIds

case (_, List()) => initialIds

case (_, id :: tailIds) => {

initialIds.find(cust => cust.customer_id == id) match {

caseNone=> updateCustomerByIdList(initialIds, tailIds, cls)

caseSome(cust) => updateCustomerByIdList(

initialIds.filter(cust => cust.customer_id == id),

tailIds,

cls

)

}

}

}

}

What is that Some class, and how is it that we are able to extract members of the objects into variables? The Some class is actually a case class, and as we’ll see in the next section, we can actually match and extract members of case classes.

Extracting Objects

Pattern matching includes the idea of matching on objects and extracting the fields from an object. As we’ve already seen in some of our examples, the Option pattern allows us to indicate either None or Some. With Some, we can encapsulate and get some value without having to write an ifstructure like the one shown in Example 8-9.

Example 8-9. How to handle the Option pattern in an if structure

var foo :Option[String] =Some("Bar")

if(obj.isDefined) {

obj.get

} else {

"" /* Not defined */

}

Instead, we can write a pattern match against Option and make it much more readable, as shown in Example 8-10.

Example 8-10. How to handle the Option pattern in a pattern match

var foo :Option[String] =Some("Bar")

obj match {

caseNone=> ""

caseSome(o) => o

}

We no longer have to write any if statements to compare types or isDefined calls. Instead, the pattern match handles the object comparison for us. We can do even more matches by looking inside the object, much as we did with the Option example. Let’s say we have a Some object with the contents Bar. We can use the case syntax of case Some("Bar") to match on the value inside the case object. Let’s see this in Example 8-11.

Example 8-11. How to handle a specific value inside a case object

var foo :Option[String] =Some("Bar")

obj match {

caseNone=> ""

caseSome("Bar") => "Foo"

caseSome(o) => o

}

What is really interesting about this Option pattern is that we can use it in our createCustomer method. Remember the function in Example 8-3? Well, we can actually improve it by returning a None object (which does extend Option) on error, and returning Some if successful. Let’s see this in Example 8-12.

Example 8-12. Returning the Option pattern

def createCustomer(name :String,

state :String,

domain :String) :Option[Customer] = {

(name, state, domain) match {

case ("", _, _) => {

println("Name cannot be blank")

None

}

case (_, "", _) => {

println("State cannot be blank")

None

}

case (_, _, "") => {

println("Domain cannot be blank")

None

}

case_=>newSome(newCustomer(

0,

name,

state,

domain,

true,

newContract(Calendar.getInstance, true),

List()

)

)

}

}

Here is the really interesting thing: we can actually make this more functional and encapsulate the print statement (logging) and return None because there is no reason to repeat ourselves. We can extract this into an error function that only needs to exist inside the createCustomerfunction. See the refactored code in Example 8-13.

Example 8-13. Extracting the logging of an error and returning of the option

def createCustomer(name :String,

state :String,

domain :String) :Option[Customer] = {

def error(message :String) :Option[Customer] = {

println(message)

None

}

(name, state, domain) match {

case ("", _, _) => error("Name cannot be blank")

case (_, "", _) => error("State cannot be blank")

case (_, _, "") => error("Domain cannot be blank")

case_=>newSome(newCustomer(

0,

name,

state,

domain,

true,

newContract(Calendar.getInstance, true),

List()

)

)

}

}

Converting to Pattern Matches

There’s another scenario in which converting from an if structure to a pattern match would actually increase readability. Let’s look at the original countEnabledCustomersWithNoEnabledContacts method shown in Example 8-14.

Example 8-14. The original countEnabledCustomersWithNoEnabledContacts

def countEnabledCustomersWithNoEnabledContacts(customers :List[Customer],

sum :Integer) :Integer = {

if(customers.isEmpty) {

sum

} else {

val addition =if(customers.head.enabled &&

customers.head.contacts.exists({ contact =>

contact.enabled

})) {

1

} else {

0

}

countEnabledCustomersWithNoEnabledContacts(customers.tail, addition + sum)

}

}

Now that we know how to extract from lists, we will try to rewrite this function. The first thing to do is define our Customer object as a case class, as shown in Example 8-15, by simply adding the case keyword to the class keyword.

Example 8-15. The Customer class defined as a case class

caseclassCustomer(val customer_id :Integer,

val name :String,

val state :String,

val domain :String,

val enabled :Boolean,

val contract :Contract,

val contacts :List[Contact]) {

}

Now let’s look at Example 8-16. Notice that we are going to handle the empty list first, then use the same type of syntax with the Some() object, except here we extract only the enabled and contacts of the Customer and ignore the rest.

For the enabled field, we want to match only if true is set for that field. We also want to pull out the Contact list into the cont variable.

Next, we have an if statement before our =>, which is called a guard. It allows us to match a pattern but only if a specific condition occurs. Finally, we call back into our function with the tail of our list and our sum + 1.

Example 8-16. A pattern match based on the customer being enabled

def countEnabledCustomersWithNoEnabledContacts(customers :List[Customer],

sum :Integer) :Integer = {

customers match {

caseList() => sum

caseCustomer(_,_,_,_,true,_,cont) :: custs

if cont.exists({ contact => contact.enabled}) =>

countEnabledCustomersWithNoEnabledContacts(custs, sum + 1)

case cust :: custs => countEnabledCustomersWithNoEnabledContacts(custs, sum)

}

}

Now we can make this more efficient fairly easily: we can add a pattern to skip over our Contact list for the customer if it is blank, as shown in Example 8-17.

Example 8-17. A pattern match based on the customer being enabled and adding a check for a blank Contact list

def countEnabledCustomersWithNoEnabledContacts(customers :List[Customer],

sum :Integer) :Integer = {

customers match {

caseList() => sum

caseCustomer(_,_,_,_,true,_,List()) :: custs =>

countEnabledCustomersWithNoEnabledContacts(custs, sum)

caseCustomer(_,_,_,_,true,_,cont) :: custs

if cont.exists({ contact => contact.enabled}) =>

countEnabledCustomersWithNoEnabledContacts(custs, sum + 1)

case cust :: custs => countEnabledCustomersWithNoEnabledContacts(custs, sum)

}

}

Conclusion

Throughout this chapter we’ve done quite a bit with pattern matching; we’ve actually converted our if structures into pattern matches. This has enabled us to perform simpler recursive loops over lists by using extractions from the lists. We have also been able to simplify our cases by matching on members inside the objects to reduce the amount of logic that we need to write.

We’ve also learned about the Option pattern, which allows us to get away from null objects by handling cases through pattern matching and either extracting the Some or handling a None case, as appropriate.