Scala/FP Idiom: Update as You Copy, Don’t Mutate

In functional programming you don’t modify (mutate) existing objects, you create new objects with updated fields based on existing objects. For instance, last year my niece’s name was “Emily Means,” so I could have created a Person instance to represent her, like this:

val emily = Person("Emily", "Means")

Then she got married, and her last name became “Walls.” In an imperative programming language you would just change her last name, like this:

emily.setLastName("Walls")

But in FP you don’t do this, you don’t mutate existing objects. Instead, what you do is (a) you copy the existing object to a new object, and (b) during the copy process you update any fields you want to change by supplying their new values.

The way you do this in Scala/FP is with the copy method that comes with the Scala case class. This lesson shows a few examples of how to use copy, including how to use it with nested objects.

So you can follow along, the source code for this lesson is available at github.com/alvinj/FpUpdateAsYouCopy

When you’re working with a simple object it’s easy to use copy. Given a case class like this:

case class Person (firstName: String, lastName: String)

if you want to update a person’s last name, you just “update as you copy,” like this:

val emily1 = Person("Emily", "Means")
val emily2 = emily1.copy(lastName = "Walls")

As shown, in simple situations like this all you have to do to use copy is:

  • Make sure your class is a case class.

  • Create an initial object (emily1), as usual.

  • When a field in that object needs to be updated, use copy to create a new object (emily2) from the original object, and specify the name of the field to be changed, along with its new value.

When you’re updating one field, that’s all you have to do.

That’s also all you have to do to update multiple fields, as I’ll show shortly.

An important point to note about this is that the first instance remains unchanged. You can verify that by running a little App like this:

object CopyTest1 extends App {

    println("--- Before Copy ---")
    val emily1 = Person("Emily", "Means")
    println(s"emily1 = $emily1")

    // emily got married
    println("\n--- After Copy ---")
    val emily2 = emily1.copy(lastName = "Walls")
    println(s"emily1 = $emily1")
    println(s"emily2 = $emily2")

}

The output of CopyTest1 looks as follows, showing that the original emily1 instance is unchanged after the copy:

--- Before Copy ---
emily1 = Person(Emily,Means)

--- After Copy ---
emily1 = Person(Emily,Means)
emily2 = Person(Emily,Walls)

What happens in practice is that you discard the original object, so thinking about the old instance isn’t typically an issue; I just want to mention it. (You’ll see more examples of how this works as we go along.)

In practice you also won’t use intermediate variables with names like emily1, emily2, etc. We just need to do that now, until we learn a few more things.

It’s also easy to update multiple fields at one time using copy. For instance, had Person been defined like this:

case class Person (
    firstName: String,
    lastName: String,
    age: Int
)

you could create an instance like this:

val emily1 = Person("Emily", "Means", 25)

and then create a new instance by updating several parameters at once, like this:

// emily is married, and a year older
val emily2 = emily1.copy(lastName = "Walls", age = 26)

That’s all you have to do to update two or more fields in a simple case class.

As shown, using copy with simple case classes is straightforward. But when a case class contains other case classes, and those contain more case classes, things get more complicated and the required code gets more verbose.

For instance, let’s say that you have a case class hierarchy like this:

case class BillingInfo(
    creditCards: Seq[CreditCard]
)

case class Name(
    firstName: String, 
    mi: String, 
    lastName: String
)

case class User(
    id: Int, 
    name: Name, 
    billingInfo: BillingInfo, 
    phone: String, 
    email: String
)

case class CreditCard(
    name: Name, 
    number: String, 
    month: Int, 
    year: Int, 
    cvv: String
)

Visually the relationship between these classes looks like Figure [fig:umlRelationshipBetweenClasses].

The visual relationship between the classes

Notice a few things about this code:

  • User has fields of type Name and BillingInfo

  • CreditCard also has a field of the Name type

Despite a little complexity, creating an initial instance of User with this hierarchy is straightforward:

object NestedCopy1 extends App {

    val hannahsName = Name(
        firstName = "Hannah",
        mi = "C",
        lastName = "Jones"
    )

    // create a user
    val hannah1 = User(
        id = 1,
        name = hannahsName,
        phone = "907-555-1212",
        email = "hannah@hannahjones.com",
        billingInfo = BillingInfo(
            creditCards = Seq(
                CreditCard(
                    name = hannahsName,
                    number = "1111111111111111",
                    month = 3,
                    year = 2020,
                    cvv = "123"
                )
            )
        )
    )

}

So far, so good. Now let’s take a look at what you have to do when a few of the fields need to be updated.

First, let’s suppose that Hannah moves. I kept the address out of the model to keep things relatively simple, but let’s suppose that her phone number needs to be updated. Because the phone number is stored as a top-level field in User, this is a simple copy operation:

// hannah moved, update the phone number
val hannah2 = hannah1.copy(phone = "720-555-1212")

Next, suppose that a little while later Hannah gets married and we need to update her last name. In this case you need to reach down into the Name instance of the User object and update the lastName field. I’ll do this in a two-step process to keep it clear.

First, create a copy of the name field, changing lastName during the copy process:

// hannah got married, update her last name
val newName = hannah2.name.copy(lastName = "Smith")

If you print newName at this point, you’ll see that it is “Hannah C Smith.”

Now that you have this newName instance, the second step is to create a new “Hannah” instance with this new Name. You do that by (a) calling copy on the hannah2 instance to make a new hannah3 instance, and (b) within copy you bind the name field to newName:

val hannah3 = hannah2.copy(name = newName)

Suppose you also need to update the “Hannah” instance with new credit card information. To do this you follow the same pattern as before. First, you create a new CreditCard instance from the existing instance. Because the creditCards field inside the billingInfo instance is a Seq, you need to reference the first credit card instance while making the copy. That is, you reference creditCards(0):

val oldCC = hannah3.billingInfo.creditCards(0)
val newCC = oldCC.copy(name = newName)

Because (a) BillingInfo takes a Seq[CreditCard], and (b) there’s only one credit card, I make a new Seq[CreditCard] like this:

val newCCs = Seq(newCC)

With this new Seq[CreditCard] I create a new “Hannah” instance by copying hannah3 to hannah4, updating the BillingInfo during the copy process:

val hannah4 = hannah3.copy(billingInfo = BillingInfo(newCCs))

Put together, those lines of code look like this:

val oldCC = hannah3.billingInfo.creditCards(0)
val newCC = oldCC.copy(name = newName)
val newCCs = Seq(newCC)
val hannah4 = hannah3.copy(billingInfo = BillingInfo(newCCs))

You can shorten that code if you want, but I show the individual steps so it’s easier to read.

These examples show how the “update as you copy” process works with nested objects in Scala/FP. (More on this after the attribution.)

The examples I just showed are a simplification of the code and description found at these URLs:

As you saw, the “update as you copy” technique gets more complicated when you deal with real-world, nested objects, and the deeper the nesting gets, the more complicated the problem becomes. But fear not: there are Scala/FP libraries that make this easier. The general idea of these libraries is known as a “lens” (or “lenses”), and they make copying nested objects much simpler. I cover lenses in a lesson later in this book.

Here’s a summary of what I just covered:

  • Because functional programmers don’t mutate objects, when an object needs to be updated it’s necessary to follow a pattern which I describe as “update as you copy”.

  • The way you do this in Scala is with the copy method, which comes with Scala case classes.

  • As you can imagine, from here on out you’re going to be using case classes more than you’ll use the default Scala class. The copy method is just one reason for this, but it’s a good reason. (You’ll see even more reasons to use case classes as you go along.)

As mentioned, I write about lenses later in the book, when we get to a point where we have to “update as you copy” complicated objects.

But for now the next thing we need to dig into is for comprehensions. Once I cover those, you’ll be close to being able to write small, simple, functional applications with everything I’ve covered so far.

results matching ""

    No results matching ""