Baeldung Pro – Scala – NPI EA (cat = Baeldung on Scala)
announcement - icon

Learn through the super-clean Baeldung Pro experience:

>> Membership and Baeldung Pro.

No ads, dark-mode and 6 months free of IntelliJ Idea Ultimate to start with.

1. Overview

In this tutorial, we’ll compare the usage of vals and defs within trait definitions. Using a val rather than defs within traits can appear like a small difference on the surface, but can become problematic when extending the trait. We’ll cover the concept of a trait and explore some concrete examples of what can go wrong when extending a trait containing a val.

2. Abstraction in Traits

A trait, similar to an interface in Java, should be used to define an abstract blueprint, which other abstract classes or a concrete class definition can extend. As such, we don’t want a trait to contain any implementation details that the extending class should provide. This includes providing functionality that restricts the extending class.

For example, if we provided a SqlConnection field for a trait that provides access to a database. When we come to create an implementation for an in-memory or NoSql database, we can’t because we’re locked into providing a SqlConnection. This implementation detail should only be provided within the extending class.

The same applies to the variable type of a field. By defining a field as a val, we enforce that the the field is instantiated at the point the class is created. This could be inappropriate for particular implementations of the trait. To ensure we’re not restricting the implementation of an extending class, we should use the most open variable type of def.

3. Restricting Fields

Now we understand the concept of a trait and why we should favor defs fields over vals from a conceptual point of view. Let’s look at some concrete examples to see the problems in practice:

trait CurrentTimePrinter {
  val currentTime: LocalDateTime
  def printCurrentTime(): Unit
}

object TimePrinterImpl extends CurrentTimePrinter {
  override val currentTime: LocalDateTime = LocalDateTime.now()
  override def printCurrentTime(): Unit = println(currentTime)
}

In this example, we define a trait called CurrentTimePrinter with a field called currentTime. Then, a function called printCurrentTime() needs to be implemented in the extending class. Below the trait, we create an object that implements CurrentTimePrinter and overrides the fields defined in the trait. If we run this code and call printCurrentTime, the same LocalDateTime value will be printed each time as currentTime is a val.

Let’s try and change it to a def, so LocalDateTime.now() is called each time to get an up-to-date value:

object TimePrinterImpl extends CurrentTimePrinter {
  override def currentTime: LocalDateTime = LocalDateTime.now()
  override def printCurrentTime(): Unit = println(currentTime)
}

If we try to compile this code now, we’re given an error:

overriding value currentTime in trait CurrentTimePrinter of type java.time.LocalDateTime;
method currentTime needs to be a stable, immutable value

This occurs because our trait defines currentTime as an immutable value by setting it as a val. Therefore, we have to provide an immutable value when writing its implementation. This forces the implementing class to use a val for currentTime. Which, in our use case, isn’t appropriate. We can fix this by setting currentTime to a def in CurrentTimePrinter:

trait CurrentTimePrinterWithDef {
  def currentTime: LocalDateTime
  def printCurrentTime(): Unit
}

object TimePrinterWithDefImpl extends CurrentTimePrinterWithDef {
  override def currentTime: LocalDateTime = LocalDateTime.now()
  override def printCurrentTime(): Unit = println(currentTime)
}

This time, we can call printCurrentTime() and it will print a newly calculated LocalDateTime for each call.

By setting currentTime as a def, we’ve allowed the implementing class the flexibility to choose the field type. We could override it as a def, val lazy val, or even a var if we wanted to. Using a def in the trait makes the field as open as possible for the overriding class to choose what the implementation should do.

4. Initialization Order

The next issue we can encounter when using vals in traits comes when using multiple inheritance. Let’s create some traits and classes to explore this issue:

trait TraitA {
  val multiplier: Int
  val result: Int = multiplier * 7
}

trait TraitB {
  val multiplier: Int = 10
}

object AB extends TraitA with TraitB
object BA extends TraitB with TraitA

Here, we’ve created two traits. TraitA has an abstract field of multiplier and then a concrete field of result, which multiplies the multiplier by 7. Next, we have TraitB, which has a concrete member of multiplier, initialized to 10. Finally, we have two object definitions, AB, which extends TraitA with TraitB. Then, BA which does the opposite – TraitB with TraitA.

Let’s call the result on both these objects and see what happens:

AB.result // AB.result = 0
BA.result // BA.result = 70

As we can see, these two results are very different and unexpected for just reversing the order in which we extend the traits. This happens because traits are initialized in the order they’re listed. So, in AB, when the result is calculated in TraitA, the multiplier hasn’t been set to 10, as TraitB hasn’t been initialized yet. So, the multiplier defaults to zero, and the result is zero. Whereas, in BA, TraitB is initialized first, so when result is calculated in TraitA, multiplier has been set to 10, therefore we get 70.

As we can see, this effect could produce unexpected results in our codebases. Fortunately, we can mitigate against this by setting result as a def in TraitA:

trait TraitA {
  val multiplier: Int
  def result: Int = multiplier * 7
}

trait TraitB {
  val multiplier: Int = 10
}

object AB extends TraitA with TraitB
object BA extends TraitB with TraitA

Now, let’s call result on both objects again:

AB.result // AB.result = 70
BA.result // BA.result = 70

This time, both results returned 70. This is because result is now a def, which is calculated each time we call it. Meaning that the object and all the traits it extends have been initialized at the point we call the result.

5. Conclusion

In this article, we’ve discussed the good practice of separating implementation details from the abstractions we set within trait definitions. Next, we explored the issues that can occur when we extend a trait containing a val using the example of printing the currentTime. Finally, we saw the unexpected results that can occur in our codebase caused by the initialization order in multiple inheritance using traits with vals.

The code backing this article is available on GitHub. Once you're logged in as a Baeldung Pro Member, start learning and coding on the project.