Showing how exceptions break referential transparency
This might not seem obvious, but when a function throws an exception, it breaks referential transparency. In this section, I am going to show you why. First, create a new Scala worksheet and type the following definitions:
case class Rectangle(width: Double, height: Double)
def area(r: Rectangle): Double =
if (r.width > 5 || r.height > 5)
throw new IllegalArgumentException("too big")
else
r.width * r.height
Then, we will call area with the following arguments:
val area1 = area(3, 2)
val area2 = area(4, 2)
val total = try {
area1 + area2
} catch {
case e: IllegalArgumentException => 0
}
We get total: Double = 14.0. In the preceding code, the area1 and area2 expressions are referentially transparent. We can indeed substitute them with their value without changing the program's behavior. In IntelliJ, select the area1 variable inside the try block and hit Ctrl + Alt + N (inline variable), as shown in the following code. Do the same for area2:
val total = try {
area(3, 2) + area(4, 2)
} catch {
case e: IllegalArgumentException => 0
}
The total is the same as before. The program's behavior did not change, hence area1 and area2 are referentially transparent. However, let's see what happens if we define area1 in the following way:
val area1 = area(6, 2)
val area2 = area(4, 2)
val total = try {
area1 + area2
} catch {
case e: IllegalArgumentException => 0
}
In this case, we get java.lang.IllegalArgumentException: too big, because our area(...) function throws an exception when the width is greater than five. Now let's see what happens if we inline area1 and area2 as before, as shown in the following code:
val total = try {
area(6, 2) + area(4, 2)
} catch {
case e: IllegalArgumentException => 0
}
In this case, we get total: Double = 0.0. The program's behavior changed when substituting area1 with its value, hence area1 is not referentially transparent.
We demonstrated that exception handling breaks referential transparency, and hence functions that throw exceptions are impure. It makes a program more difficult to understand because you have to take into account where a variable is defined to understand how the program will behave. The behavior will change depending on whether a variable is defined inside or outside a try block. This might not seem to be a big deal in a trivial example, but when there are multiple chained function calls with try blocks along the line, matching different types of exceptions, it can become daunting.
Another disadvantage when you use exceptions is that the signature of the function does not indicate that it can throw an exception. When you call a function that can throw exceptions, you have to look at its implementation to figure out what type of exception it can throw, and under what circumstances. If the function calls other functions, it compounds the problem. You can accommodate this by adding comments or an @throws annotation to indicate what exception types can be thrown, but these can become outdated when the code is refactored. When we call a function, we should only have to consider its signature. A signature is a bit like a contract—given these arguments, I will return you a result. If you have to look at the implementation to know what exceptions are thrown, it means that the contract is not completed: some information is hidden.
We now know how to throw and catch exceptions, and why we should use them with caution. The best practice is to do the following:
- Catch recoverable exceptions as early as possible, and indicate the possibility of failure with a specific return type.
- Not catch exceptions that cannot be recovered, such as disk full, out of memory, or some other catastrophic failure. This will make your program crash whenever such exceptions happen, and you should then have a manual or automatic recovery process outside the program.
In the rest of this chapter, I will show you how to use the Option, Either, and Validated classes to model the possibility of a failure.