Concerns about program optimality may have taken a backseat these days, but we still cannot indiscriminately make calls and hope that the processor has enough resources to perform all the calculations. Occasionally, we must consider whether a particular calculation is necessary.
In the context of Scala, a programming language that incorporates functional programming principles, we encounter the concept of lazy evaluation. This concept allows us to delay computations until they are truly necessary, aligning with the principles of performance optimization and resource management.
Let's delve into lazy evaluations in Scala and understand why and when we might choose to use them.
Lazy val
Lazy vals in Scala offer a mechanism for deferred computations. We can define a value that won't be computed until it's called upon. Let's imagine we have a complex calculation that might cause an error:
lazy val longComputationResult: Int = {
println("complex computations and potential exception")
throw Exception("fail")
}
When this value is defined, nothing will be printed and no exception will be thrown. But when we attempt to use this value, it will be computed:
val result = longComputationResult + 1
// complex computations and potential exception
// the exception is thrown
println("This won't be printed")
If the calculation doesn't trigger an error, a second call will not restart the calculation (as the function would). The result will be written to the variable after the first call.
Such constructs are particularly useful in scenarios where we might not call the given calculation at all:
val weHaveToCalculate: Boolean = ???
if weHaveToCalculate then
if longCalculationResult < 0 then -longCalculationResult else longCalculationResult + 1
else
thereIsAShortWay() // other small computation
If weHaveToCalculate is false we can simply bypass the repercussions of a complex calculation, and the program will continue.
By-name parameters in Scala
Scala introduces the concept of by-name parameters, offering a flexible way to pass expressions that are lazily evaluated. Let's check the getOrElse prototype from the Option class:
def getOrElse[B >: A](default: => B): B
A is the type stored in Option, and B is the type that is either A or is parent to A. This typing allows for flexibility of use, but let's pay attention to the default parameter.
If default were evaluated eagerly – that is, immediately – we would compute the extra value even in cases where Option already has a value and we don't need something else. Adding => to the type makes it lazy, meaning the calculation will only be executed when needed. Let's look at an example:
val maybeConnectionPort: Option[Int] = getPortFromEnv()
val connectionPort: Int = maybeConnectionPort.getOrElse(getPortFromConfig(loadConfig()))
The configuration will only be loaded and the port extracted from it if it wasn't fetched from the environment variables. This way, we conserve resources without making the code excessively complex with checks and branches.
It is important to remember that a function accepting a lazy parameter should use it cautiously, as every call to such a parameter will cause it to be recalculated:
def print(theMeaningOfLife: => Int): String =
println(s"[DEBUG] the value is: $theMeaningOfLife") // calculated here
s"The meaning of life is: $theMeaningOfLife" // and calculated here
If we have a heavy function and pass it to the argument:
def askComputerToCalculateTheMeaningOfLife(): Int = 42
print(askComputerToCalculateTheMeaningOfLife())
The print function will essentially work like this:
def print(): String =
println(s"[DEBUG] the value is: ${askComputerToCalculateTheMeaningOfLife()}")
s"The meaning of life is: ${askComputerToCalculateTheMeaningOfLife()}"
If you need to use this value more than once, add a variable to store the calculated result:
def print(theMeaningOfLife: => Int): String =
val calculatedTheMeaningOfLife = theMeaningOfLife // calculated here
println(s"[DEBUG] the value is: $calculatedTheMeaningOfLife")
s"The meaning of life is: $calculatedTheMeaningOfLife"Laziness in Collections with LazyList
Scala provides the LazyList data structure, which enables lazy evaluations and is especially useful when dealing with infinite sequences or dynamically generated data. Let's look at an example using a LazyList of random numbers:
import scala.util.Random
val numbers: LazyList[Int] = LazyList.continually(Random.nextInt(10))
// Potential numbers: 7, 3, 3, 9, 8, ...
val squaredNumbers: LazyList[Int] = numbers.map(n => n * n)
// Potential squared numbers: 49, 9, 9, 81, 64, ...
println(squaredNumbers.take(4).toList)
// Output: 49, 9, 9, 81
In the above example, we create a LazyList of random numbers using LazyList.continually. The LazyList lets us generate an infinite sequence of these random numbers, but because it's evaluated lazily, we're merely describing the calculations without actually executing them.
Next, we define squaredNumbers by mapping each number in numbers.
Finally, we use take(4).toList to indicate to LazyList that we only need 4 values. All other values won't be calculated.
This example illustrates the power of laziness in collections. We can work with potentially infinite sequences or dynamically generated data structures without the need to generate all the elements upfront. Instead, we can produce elements as they're needed, optimizing memory usage and improving performance.
Conclusion
Lazy evaluations in Scala provide a mechanism for deferring computations until they are necessary. By using lazy vals, by-name parameters, and LazyList, we can enhance efficiency, optimize performance, and handle situations where upfront calculations are unnecessary or costly. Understanding and leveraging laziness can boost our programming abilities in Scala.