In Scala, when working with objects, it’s common to feel the need—and sometimes have the ability—to modify or edit an intrinsic property or attribute of an object, or even the object itself after instantiation. This allows us to perform sequential transformations in a relatively simple and intuitive way.
However, while the need might feel natural, the ability to do this correctly isn’t as straightforward.
For predefined classes, Scala often provides a built-in mechanism to achieve this. But what about objects that are instances of our own classes? Do we have such a mechanism? And if so, does it modify what we want, the way we want? Or do we need to implement it ourselves? How can we do that?
Approaches #
We start reviewing many different point of views and approaches regarding to the common way of how people use the mutability in scala.
Mutable Variables #
The most obvious and natural way is to define a mutable property or attribute using a var
and then modify its value later.
This is the easiest approach for many programmers, especially newcomers, where you treat the variable as a checkpoint that can be updated throughout execution. However, in this blog, we’re allergic to mutability. Just the thought of using a var
gives us hives and makes our eyes twitch.
It’s not just about the usual pitfalls of imperative programming, such as global mutable variables causing unexpected bugs—like expecting a 4
and getting a -10
due to some unexpected process. Things get even worse when you introduce concurrency and reactive systems. At that point, you might as well just throw in the towel.
Immutability #
Yes, we need immutability.
Let’s be honest: What’s a bit more code and a few design tricks compared to the benefits of safety, correctness, and testability?
Let’s start by exploring a common technique: overriding a property during the creation of a new instance of a class.
Override #
One popular approach is to create a new instance and override a property or attribute at the moment of instantiation.
However, this can lead to boilerplate code whenever you need to modify a property. It gets even worse when you want to create a copy of an instance, modify several values, and retain specific attributes with non-default values.
You’d need to carefully select the attributes to keep, create a new instance, override each one to preserve their values, and finally override the specific attribute you wanted to change in the first place.
Use Case #
That’s why I’m writing this article: to share a way to tackle this problem.
In short, we’ll give our class the ability to copy itself with modified attributes. This concept of adding “capabilities” is fascinating and versatile. The same approach could be extended to add features like displaying (Show
), comparing (Eq
), printing, exporting, transforming into another format, and so on.
We’ll develop this idea in a modular way, avoiding complex patterns like Cake Pattern.
To illustrate, let’s say we want to manage Config
objects for configuration settings. These objects might extend into several specific subtypes for different environments.
sealed trait ConfigTypes {
lazy val config: Option[Config] = Option.empty[Config]
}
trait ConfigA extends ConfigTypes
trait ConfigB extends ConfigTypes
trait ConfigC extends ConfigTypes
With this setup, we can create as many copies of a ConfigC instance as we need by simply calling copy()
on the method.
Mutable by Immutable Way #
But our primary goal isn’t just copying objects—it’s creating new instances that retain the original attributes while modifying others in an immutable way. Essentially, we want to create new objects based on the original, with changes applied during instantiation.
We can redefine the copy()
method to include this functionality:
sealed trait ConfigTypes {
self: CanCopy[_] =>
lazy val config: Option[Config] = Option.empty[Config]
}
trait CanCopy[T <: CanCopy[T]] {
def copy(conf: Option[Config]): T
}
trait ConfigCopyable[T <: CanCopy[T]] extends ConfigTypes with CanCopy[T]
trait ConfigA extends ConfigCopyable[ConfigA] {
def copy(conf: Option[Config]): ConfigA =
new ConfigA { override lazy val config: Option[Config] = conf }
}
trait ConfigB extends ConfigCopyable[ConfigB] {
def copy(conf: Option[Config]): ConfigB =
new ConfigB { override lazy val config: Option[Config] = conf }
}
trait ConfigC extends ConfigCopyable[ConfigC] {
def copy(conf: Option[Config]): ConfigC =
new ConfigC { override lazy val config: Option[Config] = conf }
}
With this implementation, we can generate as many copies of our ConfigC
instances as we call the copy()
method.
val otherConfig: Config = ???
val anotherConfig: Config = ???
/** New ConfigC instance */
val configC: ConfigC = new ConfigC {}
/** Modify the config attribute to otherConfig */
val configCAlt: ConfigC = configC.copy(Some(otherConfig))
/** Modify it again to anotherConfig */
val configCAlt2: ConfigC = configCAlt.copy(Some(anotherConfig))
Conclusion #
This is a great introduction to mutability from an immutable perspective. This philosophy is especially common in concurrent and reactive systems. Functional programming encourages these techniques due to how languages like Scala or Haskell are designed—or maybe because we’re just purists.
While this approach works well, we’ve missed one target: modularity. The importance of modularity and how we’ll achieve it will be the topic of the second part of this post.