From a3b505a5acf094270baabaf60c34949f5ed31f58 Mon Sep 17 00:00:00 2001 From: Bruce Eckel Date: Mon, 22 Jul 2024 20:12:28 -0600 Subject: [PATCH] Polishing "Failure" #bruce #time 50m --- Chapters/06_Failure.md | 94 +++++++++++-------------- src/main/scala/basic/FailureTypes.scala | 12 ++-- 2 files changed, 49 insertions(+), 57 deletions(-) diff --git a/Chapters/06_Failure.md b/Chapters/06_Failure.md index 177a9d15..4e0333e7 100644 --- a/Chapters/06_Failure.md +++ b/Chapters/06_Failure.md @@ -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. @@ -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. @@ -37,12 +37,8 @@ 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. @@ -50,18 +46,18 @@ 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 @@ -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`: @@ -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.* @@ -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.* @@ -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. @@ -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.* @@ -320,8 +316,6 @@ import zio.* override val bootstrap = networkFailure -// TODO Reduce output here - def run = getTemperature ``` @@ -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: @@ -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.* @@ -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.* @@ -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)) ``` @@ -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`: @@ -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.* @@ -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: diff --git a/src/main/scala/basic/FailureTypes.scala b/src/main/scala/basic/FailureTypes.scala index e9785e36..42e00282 100644 --- a/src/main/scala/basic/FailureTypes.scala +++ b/src/main/scala/basic/FailureTypes.scala @@ -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 =