Skip to content

Commit

Permalink
Polishing "Failure" #bruce #time 50m
Browse files Browse the repository at this point in the history
  • Loading branch information
Bruce Eckel committed Jul 23, 2024
1 parent fcd4bd6 commit a3b505a
Show file tree
Hide file tree
Showing 2 changed files with 49 additions and 57 deletions.
94 changes: 43 additions & 51 deletions Chapters/06_Failure.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ People also tried returning improbable values from a function, but this required
The biggest problem was psychological: programmers tend to be more interested in the "happy path" where everything works right.
It was too easy to forget or ignore error information.

Exceptions were a big step forward, because they provide:
Exceptions were a big step forward:

- Unified error reporting: there's only one way to do it.
- They provide unified error reporting--there's only one way to do it.
- Errors cannot be ignored--they flow upward until caught or displayed on the console with program termination.
- A standardized way to correct problems so that an operation can recover and retry.
- They are a standardized way to correct problems, so an operation can recover and retry.
- Errors can be handled close to the origin, or generalized by catching them "further out" so that multiple error sources can be managed with a single handler.
- Exception hierarchies allow more general exception handlers to handle multiple exception subtypes.

Expand All @@ -21,13 +21,13 @@ The underlying issue was _composability_, which takes smaller parts and assemble

The main problem with exceptions is that they are not part of the type system.
When exception types are not part of a function signature, you can't know what exceptions you must handle when calling other functions (i.e.: composing).
You can track down explicitly thrown exceptions by searching for them in the source code.
Even then, built-in exceptions can occur without evidence in the code--for example, divide-by-zero.
You can track down explicitly-thrown exceptions by searching for them in the source code.
Even then, built-in exceptions can occur without evidence in the code--divide-by-zero, for example.

Suppose you're handling all exceptions from a library--or at least, the ones you found in the documentation.
Now a new version of that library comes out.
You upgrade, assuming it must be better.
Unbeknownst to you, the new version quietly added an exception.
Unbeknownst to you, the new version quietly adds an exception.
Because exceptions are not part of the type system, the compiler cannot detect the change.
Your code doesn't handle that exception.
Your code was working.
Expand All @@ -37,31 +37,27 @@ Worse, you only find out at runtime when your system fails.

Languages like C++ and Java tried to solve this problem by adding exception specifications.
This notation adds exception types that may be thrown as part of the function's type signature.
Although this appeared to be a solution, exception specifications are actually a second, shadow type system, independent of the main type system.
All attempts at using exception specifications have failed, and C++ has abandoned exception specifications and adopted the functional approach.

Object-oriented languages have exception hierarchies, which introduces another problem.
Exception hierarchies allow the library programmer to use an exception base type in the exception specification.
This obscures important details; if the exception specification only uses a base type, the compiler cannot enforce coverage of specific exceptions.
Although they appeared to be a solution, exception specifications are actually a second, shadow type system, independent of the main type system.
All attempts at using exception specifications failed, and C++ abandoned exception specifications and adopted the functional approach.

When errors are part of the type system, you see all possible errors by looking at the type information.
If a library component adds a new error, it must be reflected in the type signature.
You immediately know if your code no longer covers all error conditions.

## The Functional Solution

Instead of creating a complex implementation to report and handle errors, the functional approach creates a "return package."
Instead of creating a complex system to report and handle errors, the functional approach creates a "return package."
This is returned from the function, holding either the answer or error information.
This package is a new type that includes the types of all possible failures.
Now the compiler has enough information to tell you whether you've covered all failure possibilities.
This package is a new type that includes all possible failure types.
Now the compiler has enough information to tell whether you've covered all failure possibilities.

Effects encapsulate the unpredictable parts of a system, so they must be able to express failure.
Effects encapsulate the unpredictable parts of a system, so they must also be able to express failure.
How is success and failure information encoded into the function return type for an Effect System?
Well, this is what we've been doing whenever we've used `ZIO.succeed` and `ZIO.fail`.
The argument to `succeed` is the successful result value that you want to return.
`succeed` also provides the information that says, "This Effect is OK."
Calling `succeed` also provides the information that says, "This Effect is OK."
The argument to `fail` is the failure information.
The fact that you are calling `fail` provides the information that says, "Something went wrong in this Effect."
Calling `fail` also provides the information that says, "Something went wrong in this Effect."

## Failure Types

Expand All @@ -70,34 +66,34 @@ Although most examples in this book use a `String` argument to `fail`, you can g
```scala 3 mdoc:silent
import zio.*

case object ObjectX
case object FailObject

object ExceptionX extends Exception:
override def toString: String = "ExceptionX"
object FailException extends Exception:
override def toString: String = "FailException"

def failureTypes(n: Int) =
n match
case 0 =>
ZIO.fail("String fail")
case 1 =>
ZIO.fail(ObjectX)
ZIO.fail(FailObject)
case _ =>
ZIO.fail(ExceptionX)
ZIO.fail(FailException)
```

`failureTypes` fails in three different ways:

- `case 0` returns a failing Effect containing a `String`, as we do in most examples in the book.
- The `case 1` `fail` contains an object of type `ObjectX`, demonstrating that you can return any object as your failure information.
- The default case `fail` contains an `ExceptionX`.
- The `case 1` `fail` contains an object of type `FailObject`, demonstrating that you can return any object as your failure information.
- The default case `fail` contains an `FailException`.

Notice that `ExceptionX` is never thrown.
Notice that `FailException` is never thrown.
Placing an exception in a `fail` Effect only provides information about the failure.
This is typically more information than a `String` provides, because the exception is a type.
One reason to return an exception inside a `fail` is if you've caught that exception and want to incorporate the information in the returned Effect.

To make our code easier to read, we have avoided function type signatures in this book, and instead rely on type inference.
The inferred type signature for `failureTypes` includes all three types: `String`, `ObjectX` and `ExceptionX`.
To make the code easier to read, we have avoided function type signatures in this book, and instead rely on type inference.
The inferred type signature for `failureTypes` includes all three types: `String`, `FailObject` and `FailException`.
This way, the compiler can verify that all error conditions are handled.

We exercise all cases of `failureTypes`:
Expand All @@ -121,12 +117,12 @@ The `flip` operation takes a `ZIO.fail` and turns it into a `ZIO.succeed`, so we

## Short-Circuiting

An important benefit of handling errors with an Effect System is called _short-circuiting_.
This means that when a function encounters an error, it stops executing.
_Short-circuiting_ is an important benefit of handling errors with an Effect System.
It means that when a function encounters an error, that function stops executing.
Although stopping after you encounter an error seems obvious, in practice it can be hard to enforce.
An Effect System guarantees that you will not execute further code, regardless of how the error occurs.

To demonstrate, we use a function that fails if a value `n` is greater than or equal to a `limit` value:
To demonstrate, `testLimit` fails if a value `n` is greater than or equal to a `limit` value:

```scala 3 mdoc:silent
import zio.*
Expand Down Expand Up @@ -159,7 +155,7 @@ def shortCircuit(lim: Int) =
printLine(s"-> n: $lim, r3: $r3").run
```

All the printing allows us to trace the short-circuiting behavior during failure:
All the print calls allow us to trace short-circuiting behavior during failure:

```scala 3 mdoc:runzio
import zio.*
Expand All @@ -178,8 +174,8 @@ def run =
```

In `shortCircuit(0)`, the first `testLimit` fails right away.
When you look at the code for `shortCircuit`, it is simply a sequence of expressions, without testing code.
We would normally expect all the rest of the expressions to be executed.
When you look at the code for `shortCircuit`, it is simply a sequence of expressions, with no testing code.
We would normally expect all the expressions to be executed.
In the rest of the `shortCircuit` calls, expressions are only executed up to the point where a failure happens.

This is the brilliant thing about Effect Oriented error handling.
Expand Down Expand Up @@ -301,8 +297,8 @@ val getTemperature: ZIO[
```

Suppose an Effect `getTemperature` (implementation hidden) can fail when it makes a network request.
For the purpose of this exercise, `getTemperature` only fails by throwing exceptions.
It doesn't fail when running in the `happyPath`:
All `getTemperature` failures throw exceptions.
`getTemperature` doesn't fail when running in the `happyPath`:

```scala 3 mdoc:runzio
import zio.*
Expand All @@ -320,8 +316,6 @@ import zio.*

override val bootstrap = networkFailure

// TODO Reduce output here

def run =
getTemperature
```
Expand Down Expand Up @@ -353,8 +347,7 @@ override val bootstrap = networkFailure
val safeGetTemperature =
getTemperature.catchAll:
case e: Exception =>
ZIO.succeed:
"Could not get temperature"
ZIO.succeed("getTemperature failed")

def run =
defer:
Expand All @@ -378,8 +371,7 @@ val notExhaustive =
"Network Unavailable"
```

This produces a compiler warning because `catchAll` does not catch all possible failures.
We must also handle `GpsException`:
To fix it we must also handle `GpsException`:

```scala 3 mdoc:silent
import zio.*
Expand Down Expand Up @@ -428,10 +420,6 @@ An Effect can produce different types of failures, so we must manage all of them

Consider a `check` Effect (implementation hidden) that fails with a custom type `ClimateFailure`:

```scala 3 mdoc
case class ClimateFailure(message: String)
```

```scala 3 mdoc:invisible
import zio.*
import zio.direct.*
Expand All @@ -452,9 +440,12 @@ def check(t: Temperature) =
.run
```

```scala 3 mdoc:silent
case class ClimateFailure(message: String)
```

```scala 3 mdoc:runzio
import zio.*

def run =
check(Temperature(-20))
```
Expand Down Expand Up @@ -489,6 +480,7 @@ val weatherReport =
`getTemperature` produces two different types of `ZIO.fail`s.
However, their contained failure objects are both `Exception`s.
Thus, we can handle them both through `case exception`.
You can also write two cases, one for each exception type.

When the combined Effect runs under conditions that are too cold, we get a `ClimateFailure`:

Expand All @@ -505,8 +497,8 @@ We don't see the `ClimateFailure` error, we only get its `message` as produced b

## Handling Thrown Exceptions

So far our example Effects have _returned_ `Exception`s to indicate failure, but you may have legacy code or external libraries that _throw_ `Exception`s instead.
In these situations you can wrap the `Exception`-throwing code to achieve our preferred style of returning `Exception`s.
So far our example Effects have _returned_ `Exception`s to indicate failure, but you might call legacy code or external libraries that _throw_ `Exception`s instead.
In these situations you can wrap the `Exception`-throwing code to achieve our preferred style of returning `Exception`s inside Effects.

```scala 3 mdoc:invisible
import zio.*
Expand Down Expand Up @@ -535,8 +527,8 @@ def run =
ZIO.succeed:
getTemperatureOrThrow
```
Despite our claim that this Effect `succeed`s, it crashes with a defect.
When we call side-effecting code, the Effect System can't warn us about the potential failure.
Despite the claim made by `ZIO.succeed` that this Effect is successful, it crashes with a defect.
When we call side-effecting code, the Effect System can't warn us about potential failures.

The solution is to use `ZIO.attempt`, which turns thrown `Exception`s into Effects:

Expand Down
12 changes: 6 additions & 6 deletions src/main/scala/basic/FailureTypes.scala
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,19 @@ import zio.*
import zio.direct.*
import zio.Console.*

case object ObjectX
case class ObjectX()

object ExceptionX extends Exception:
override def toString: String = "ExceptionX"
class ExceptionX extends Exception:
override def toString: String = "ExceptionX()"

def failureTypes(n: Int): IO[Serializable, Nothing] =
def failureTypes(n: Int): IO[String | ObjectX | ExceptionX, Nothing] =
n match
case 0 =>
ZIO.fail("String fail")
case 1 =>
ZIO.fail(ObjectX)
ZIO.fail(ObjectX())
case _ =>
ZIO.fail(ExceptionX)
ZIO.fail(ExceptionX())

object FailureTypes extends ZIOAppDefault:
def run =
Expand Down

0 comments on commit a3b505a

Please sign in to comment.