Hitting a Wall
Not everything succeeds. A file might not exist. A network might be down. A move might be illegal. Division by zero is undefined.
When an operation cannot complete, the system must respond. This chapter explores how systems handle failure---from crude crashes to elegant recoveries.
The Simplest Response: Crash
The most basic response to failure is to stop. Give up. Crash.
divide(a, b):
if b == 0:
CRASH: "Cannot divide by zero!"
return a / bThis is sometimes appropriate. If an invariant is violated---if something "impossible" happened---crashing might be the only honest response. The system is in an unknown state; continuing could make things worse.
But crashing is drastic. For ordinary failures---a user typo, a missing file---we want something gentler.
Returning Special Values
A step up from crashing is returning a special value that means "failure":
find_piece_at(board, square):
board[square] // might be a piece
OR null // might be "nothing"The caller must check for this special value:
piece = find_piece_at(board, e4)
if piece == null:
// handle missing piece
else:
// use pieceThe problem? It's easy to forget the check:
piece = find_piece_at(board, e4)
piece.color // CRASH if piece is null! The type system doesn't help us. piece looks like a normal Piece, but it might be null. We've mixed valid values with a special invalid one.
Exceptions: Jumping Out
Many languages provide exceptions---a way to "throw" an error that interrupts normal execution:
validate_move(state, move):
if not is_valid_move(state, move):
throw IllegalMoveError("Cannot move there")
return apply_move(state, move)When an exception is thrown, the program "jumps out" of the current function---and keeps jumping until something "catches" it:
try:
new_state = validate_move(state, move)
// ... continue if successful ...
catch IllegalMoveError as error:
// ... handle the failure ...
display("Invalid move: " + error.message)Exceptions have advantages:
- Failures can't be silently ignored
- Error handling code is separate from normal code
- Errors propagate automatically up the call stack
But exceptions have a cost: they create invisible control flow. Looking at code, you can't easily tell where exceptions might occur. Even when declared in signatures, they create an alternate path that bypasses normal return---a cross-cutting concern that affects many parts of the system.
Effects: Beyond Pure Computation
Exceptions reveal a deeper concept: effects. So far, our functions have been pure---they take inputs and produce outputs, nothing more.
But exceptions break this purity. A function that throws doesn't just produce a value; it can also produce an interruption. It affects the world beyond its return value.
// Pure function:
add(a, b):
return a + b // always returns, predictable
// Impure function (has effects):
divide(a, b):
if b == 0:
throw Error // might not return at all!
return a / b Effects make functions harder to compose. If f might throw and g might throw, does f(g(x)) throw? When? Which error?
A Thought Experiment
What if we could handle failure without effects? What if instead of throwing or returning null, we returned a value that explicitly represents possibility of failure?
\marginnote{This is the key insight of functional error handling: make failure a normal value, not a special case. It doesn't break the rules---it plays in the same field as every other value. All the ideas we've developed for values and functions apply to it: we can pass it, return it, compose it.
find_piece_at(board, square):
if board[square] is empty: Nothing
else: Just(board[square])The return type is now:
Maybe Piece:
Nothing OR
Just(piece: Piece)Now the type system forces us to handle both cases. We can't accidentally use a "nothing" as if it were a piece:
result = find_piece_at(board, e4)
// result.color // Error! result is Maybe Piece, not Piece
match result:
case Nothing:
"no piece found"
case Just(piece):
piece.color // Now we know piece existsThe compiler catches our mistakes. Failure becomes visible. No crashes, no surprises.
This approach---encoding possibility in the type---leads to a beautiful abstraction. In the next chapter, we explore containers of possibility: types that wrap values and represent what might be there.