Convenient updates for immutable objects in Scala

Published on 2010-04-30
Tagged: scala

In Scala (and pretty much every function language), modifying an object is frowned upon. Instead of mutating an object, we usually create a copy which is slightly different. For example, instead of adding an object to a list, we create a new list by combining the new object and the contents of the old list. In general, we tend to create new objects which are a combination of new and old values.

Unfortunately, this process is rarely convenient. Let's suppose we have a class Person:

case class Person(name: String,
                  age: Int,
                  phone: String,
                  email: String)

Now let's say we have a Person object, joe. If we want to change Joe's phone number, we would create a new object:

val newJoe = Person(joe.name, joe.age, "555-1234", joe.email)

This code is repetitive. In order to make an incremental change, we needed to re-specify all of the fields. Repetitive code is difficult to maintain: if the order of the fields changes, or if more are added to Person, we would need to update this client code.

We can save the client this hassle by adding a method specifically for changing phone numbers:

def changePhone(newPhone: String): Person = {
  Person(name, age, newPhone, email)
}

This moves the problem from the client into the class. We still need to write one of these methods for each field, and we still need to update code whenever the order or number of fields change.

Ideally, we would like a way to create a copy with a few changes without re-specifying all of the old values. One solution is to add a method like this:

def copyWith(name:  String = name,
             age:   Int    = age,
             phone: String = phone,
             email: String = email): Person =
{
  Person(name, age, phone, email)
}

The copyWith method takes advantage of Scala 2.8's default arguments. For each field in the class, it has a parameter which defaults to the current value. Whichever arguments aren't specified will take on their original values. We can call the method using named arguments (also in Scala 2.8).

val newJoe = joe.copyWith(phone = "555-1234")

This is just as convenient for the client, but writing all these copyWith methods is a pain, especially if we have to do it for a lot of classes with a lot of fields. Fortunately, we can write a single generic copyWith method using reflection which is almost as good. We will embed it in a trait with the following signature:

trait Copying[T] {
  def copyWith(changes: (String, AnyRef)*): T
}

We use the type parameter T for any class which mixes in this trait. Unfortunately, there is no way to directly say that copyWith will always return an object of the same class. The client will specify changes as a list of pairs: names of fields to be updated and new values. The function is variadic, so the client may specify as many changes as necessary.

The implementation of the new copyWith consists of three steps:

  1. Look up the constructor for the case class
  2. Fill in an argument list using a combination of values given by the client (specified by name) and values in the object (retrieved by reflection)
  3. Create a new object by invoking the constructor

Looking up the constructor is the easy part. Since Copying is intended for case classes, we assume that any class mixing it in will have only one constructor.

val clas = getClass
val constructor = clas.getDeclaredConstructors.head

Next, we fill in the arguments. The number of arguments is the same as the number of parameters to the constructor. We assume that the order of the arguments is the same as the order of the fields in the class. There may be more fields than arguments (if more are declared inside the class), but the first fields will correspond to the arguments of the constructor.

val argumentCount = constructor.getParameterTypes.size
val fields = clas.getDeclaredFields
val arguments = (0 until argumentCount) map { i =>
  val fieldName = fields(i).getName
  changes.find(_._1 == fieldName) match {
    case Some(change) => change._2
    case None => {
      val getter = clas.getMethod(fieldName)
      getter.invoke(this)
    }
  }
}

For each argument, we check whether a change was specified by the client. If a change was given, we use the new value. If not, we read the old value from the object. Unfortunately, we can't read the value directly using the Field object since fields in Scala are private. Instead, we obtain the getter method (of the same name) and invoke that.

Once we have the full argument list, we create a new object:

constructor.newInstance(arguments: _*).asInstanceOf[T]

The newInstance method of Constructor is variadic; normally you would invoke it by passing the arguments separately. We can call it with with a sequence by marking it with _*. Java allows you to do the same thing by passing an Object[] value as an argument.

Here is the code for the trait, all together:

trait Copying[T] {
  def copyWith(changes: (String, AnyRef)*): T = {
    val clas = getClass
    val constructor = clas.getDeclaredConstructors.head
    val argumentCount = constructor.getParameterTypes.size
    val fields = clas.getDeclaredFields
    val arguments = (0 until argumentCount) map { i =>
      val fieldName = fields(i).getName
      changes.find(_._1 == fieldName) match {
        case Some(change) => change._2
        case None => {
          val getter = clas.getMethod(fieldName)
          getter.invoke(this)
        }
      }
    }
    constructor.newInstance(arguments: _*).asInstanceOf[T]
  }
}

In order to use this, we need to mix Copying[Person] into the Person class. Afterward, we can call it like this:

val newJoe = joe.copyWith(("phone", "555-1234"))

This approach has a few disadvantages. Since we are using reflection, there is no way to type check calls to copyWith at compile time. We will get an exception if we pass a bad argument at run time, but not before then. We will also get no warning at all (even at runtime) if we misspell the name of a field or if a field is removed later. Finally, the performance will be a bit worse than the original copyWith method since reflection is slower than static code.

Despite these disadvantages, this approach provides a lot of convenience with a very low maintenance cost.