Embracing Exceptions: Focus on Recovery, Not Discovery

As programming languages evolve, one would expect methodologies and best practices to advance. But when it comes to error handling, I feel like we're moving in reverse. If you've been following recent trends, you'll likely have observed a waning enthusiasm for exceptions, with a noticeable pivot toward favoring returned errors as the go-to approach. This paradigm shift is evident in modern languages like Rust and Go.

Why is this happening?

I believe much of this can be traced back to most programmers learning exceptions primarily from Java. Checked exceptions, the core error handling feature in Java, have left many programmers with a sour taste. On paper, checked exceptions appear like a rigorous way to ensure robust error handling. But in practice, they are much more cumbersome and less flexible than simply returning errors in a language with a more robust type system. It's no wonder that a lot developers come to conclusion that exceptions, in general, are not worth that hassle.

However, I believe that unchecked exceptions, as implemented in languages like Python, JavaScript, Ruby, C#, Kotlin, and Swift, actually offer a better approach. They allow shifting the focus from the burdensome task of error discovery to a more meaningful emphasis on strategizing for error recovery. The result is software that is more flexible, more modular, and better able to handle change.

What Exactly Are Errors?

Before we delve deeper, it's crucial to define what we mean by "errors." In the context of this discussion, an error occurs when a function can't accomplish its intended task and must return to its caller without providing any meaningful results. The reasons for errors can vary widely—from programming mistakes and invalid inputs to network failures or even a cosmic particle impact.

The notion of an error is also highly context-dependent. Consider two functions: Parse and TryParse. In the case of Parse, an input that can't be processed would constitute an error. However, for TryParse, such an outcome is anticipated, and the function might return a Boolean value to indicate success or failure. Both functions serve distinct purposes and can coexist within the same problem space. For instance, you might use Parse when working with a well-known file type, while reserving TryParse for handling unpredictable user input.

Errors have a natural tendency to propagate through the call stack. When a function depends on another that fails, it's most often the case that the dependent function can't continue its operation either. This pattern illustrates why only a few select locations exist within most software systems where meaningful recovery from an error is possible.

The Shift from Discovery to Recovery

Returning errors as values necessitate explicit handling or propagation of errors at almost every layer of your application. While this might seem like a robust approach on the surface, it adds a layer of complexity that is burdensome for developers. The focus on immediate error-handling manifests as boilerplate code that diverts attention away from the core logic of your application. And more often than not, the point where the error is discovered is rarely the right place to handle it.

This focus on the error source fails to offer solutions for meaningful recovery. Consider a multi-step operation where a network call fails because of a temporary outage. If you handle that error right at the point of failure, it's unlikely that the local scope has enough context to make an informed decision—should the entire operation be aborted or should a retry be attempted? Such crucial decisions are often better made further up the call stack where the overall flow of operations is coordinated.

My approach is to place exception handlers at strategically chosen locations, such as within processing loops or at pivotal points for data validation. The decision about where to place these handlers is largely independent of where an error or exception may originate within the call stack. Error handling becomes more about recovery and less about an exhaustive cataloging of every potential failure point. Many of my applications only have a handful of try/catch blocks. In fact, I have a GUI application with a single exception handler around the event loop and it is surprisingly robust to every kind of potential failure.

Additionally, the exceptions you're equipped to handle often don't directly correlate with the range of exceptions that might be thrown elsewhere in the call stack. While some awareness of potential exceptions is necessary, that knowledge usually isn't significant for determining which exceptions can be realistically managed at points where recovery is feasible.

Unified Resource Management and Exception Handling

What truly elevates the utility of exceptions is their seamless integration with resource management, a feature common in most modern programming languages. While the specific implementation may differ—C++ uses RAII, C# employs the 'using' statement, and Java offers 'try-with-resources'—the underlying concept remains consistent: unify the resource cleanup process so that both successful executions and exceptions traverse the same exit path.

The significance of this integration is that it eliminates the need for bifurcated logic to handle errors and normal 'happy path' scenarios. In essence, it frees you from the cognitive load of contemplating error handling in your routine tasks, allowing you to concentrate on error recovery at strategically chosen locations, disconnected from your immediate code logic.

The Encapsulation Dilemma

One often overlooked issue with typed errors and checked exceptions is that they can violate two fundamental principles of good software design: encapsulation and polymorphism. Both principles advocate for hiding the inner workings of a function, class, or module, exposing only what's absolutely necessary to the outside world.

When a function declares that it throws specific checked exceptions or errors, it reveals a great deal about its internal implementation. It's like the function is shouting, "Hey, these are the specific things that can go wrong inside me!" This detailed exposure inherently ties the function's contract to its implementation, thereby reducing its ability to evolve over time. What if the function later needs to change its underlying behavior or implementation? The explicitly declared exceptions and error types will likely have to change as well, breaking the contract it has with its callers.

This explicitness in error handling also makes it challenging to uphold the principle of polymorphism. In a well-designed system, one could easily swap out one module or function for another so long as they adhere to the same general contract. But if each function declares its own specific set of errors, this interchangeability becomes increasingly difficult. Each time you wish to replace one implementation with another, you'll need to consider how the error handling differs between the two, leading to more brittle and tightly-coupled systems.

Checked exceptions also disrupt functional programming by adding complexity when using functions as arguments or returns. This rigidity undermines seamless function composition, diminishing a core advantage of functional paradigms and resulting in less adaptable code.

Utilizing a broad, abstract error type can offer some adherence to encapsulation and polymorphism, but often at the expense of informative error reporting. For instance, if your file-reading function throws a generic IOException, the lack of specificity hampers effective recovery—after all, 'file not found' and 'network timeout' require vastly different handling strategies.

By contrast, unchecked exceptions strike a more balanced approach. They preserve the principles of encapsulation and polymorphism by keeping the inner workings of a function or module concealed. At the same time, they offer the flexibility to include detailed and specific information within the exceptions themselves.

Addressing Common Criticisms

"Unchecked Exceptions Are Unpredictable"

One prevalent criticism of unchecked exceptions is that they create a shroud of unpredictability—you never know when a function might fail. Some developers argue that this lack of visibility adds to their cognitive load. I disagree. I believe it is far simpler to operate under the assumption that every function can fail. Even if a function practically cannot fail today, chances are it will be modified to have a potential failure point in the future.

"Exceptions Cause Non-Local Control Flow"

Another argument is that exceptions introduce non-local control flow, essentially serving as an elaborate 'goto' statement that jumps from the point of failure to the nearest catch block. However, this characterization misses the mark. In reality, an exception propagates through the system, navigating through each layer and leveraging unified resource management for cleanup. This process is not fundamentally different from manually propagating errors by returning them from each function; it's a controlled flow that respects the call stack.

"Lack of Type-Declared Exceptions"

The last criticism, and one that I concede has merit, is that because unchecked exceptions aren't declared through the type system, it's challenging to know what exceptions a method might throw. While this point is valid, I argue that neither checked exceptions nor typed error returns offer a perfect solution either. In most non-trivial scenarios, listing all potential exceptions becomes impractical, if not downright unmanageable. What often happens is that error types are either broadly categorized under parent classes or encapsulated (untyped) within wrapper exceptions.

Conclusion: The Constant of Change

In software development, the only constant is change. Unchecked exceptions align well with this reality. They eliminate the need for exhaustive error declarations and superfluous error-propagation boilerplate, freeing us to focus on effective error recovery strategies. In essence, unchecked exceptions free us from the obligation to foresee every possible error, enabling us to focus more precisely on which errors we can recover from and where to place that recovery logic, thereby making our code more adaptable to the inevitable changes that come with software development.

That said, unchecked exceptions aren't a universal solution. While effective in many contexts, they're not ideal for every programming situation or language. For example, Rust, tailored for systems programming, may not be well-suited for an exception-based model. In software development, every choice involves trade-offs, and awareness of these trade-offs is crucial, whether you're building an application or designing a programming language.