Preface
whether to design your software to emit exceptions is neither a best practice nor an anti-pattern. — the immortal wisdom of RayLuo (link)
The above is the best answer I've seen. My answer is intended to supplement RayLuo's with insights not yet covered; none of it is based in things I've seen in the wild - these are just thoughts I've been synthesizing over the years that I want to explore and share. Thanks go to RayLuo for encouraging me to add my thoughts! ♥️
Wishful thinking
The perversity of the Universe tends towards a maximum.
— Finagle's law
In programming, we often like to focus on the cases where things go right. When we're designing a new thing, we're asserting something that didn't exist before. It's natural to focus on what that thing is, and not in the uncountably many ways that it can go wrong.
It's also expensive to even think of all those ways that a system can fail, not to mention to encode a description of how to handle those cases when they arise. Our jobs are hard enough as it is, just describing this thing that didn't exist before!
As programmers, we attempt to optimize our code towards a description that matches what we want, and have it take effect by enscribing this description into reality. But reality is a messy place.
The geometry of failure
If you look at most of the access patterns of our programs, they probably look like
>99%
success < 1%
errors (hopefully?)
but in terms of possible success and failure states, it's probably
>99%
errors and < 1%
success
through this lens, the success case looks quite exceptional.
Developers don't always handle failure modes...
Let's say you import a module that you didn't write yourself, and your code execution encounters a KeyError
from within the foreign module. It doesn't say what it means; it's just a generic KeyError
. It could be because of how your code interacted with it, it could be from a misconfigured dependency or expectation of the foreign module, it could be anything.
You write a try/except
and start trying to figure things out, but then there's different areas where the KeyError
might be coming from, and it could mean something completely different.
...Or success modes...
Now let's look at a different scenario: you import a module that you didn't write yourself, and your code execution encounters a ValueFoundInDatabase
exception. Odd. Usually calling a function results in a return value. You need this module though, so you write a try/except
for it.
Then you encounter a ValueFoundInCache
exception. Huh. You dive deeper and you see that both of these exceptions are sub-classes of SuccessState
, which eventually subclasses Exception
. You start to trace the trees of exceptions, which is really just a tree of different states the program might terminate in, including a lot of exceptions that derive from FailureState
.
You've already written a try/except
, and handling the failure states seems easily within reach; it's another except
, just like handling the success states, and its implementation merely depends on what precision your application's current form could care to handle: FailureState
in general or more specifically: NotFound
, TimedOut
, InvalidKey
, SignatureMismatch
...
Suddenly you know you can handle specific edge cases with not much extra work. You don't have to think hard about what they are or how to detect them because the module author already put thought and care into it. Additionally, there's a few extra 'edge' SuccessState
s that you can handle with additional flair, because you have easy access to the specific descendants of SuccessState
. If you know a successful result came from the cache or the database, you can flow your control accordingly.
The result
Because you're already handling the results with a try/except
to handle the SuccessState
or its descendants, it's so easy to just add another except
for whatever level of other states you prefer to address. That module developer may have wanted to raise
your awareness to these other states, and have you handle those edge cases instead of writing a function call and going on your merry way.
The result is also: completely awful for quickly writing functioning code, because you need to try/except
anything that has this functionality, and you can't tell which "color" it is in the function coloring sense. In my eyes, it's ugly to look at. But textual code-writing is ugly to look at.
Caveat
Another form of the above is to (gasp) return an object that has more information about the state the process terminated in. The object itself could be of different types that represent these states, or more crudely it could have a property called state
that taps into something like those exception trees from before (side note: exceptions
don't need to be raised, they're just another object).
Long blast radius
There's also the side-benefit/drawback of RayLuo's mention of a "long blast radius". A calling function scope might not be the one to handle the resulting exception
; it could skip several layers, which could be a feature in, say, a function in a web server that wants to immediately raise
some kind of Http_Response
as an exception. This approach is, I suspect, completely terrible. Definitely different than what we're used to. But it might be a feature in the high-flying duck.quack()
EAFP example.
Enumeration of states
Sandi Metz has been a proponent of condition-less coding, and I can see why: every if/else is a branching of program state -- and often that results in multiplicative, or even exponential effects. This isn't exactly the same as what I'm showcasing here, but it rhymes.
I'm not saying exceptions are the answer, but I must admit: it's nice to dream of ways to enumerate and integrate all of the relevant states a program is designed to terminate in. This includes:
- variants of success states
- things went great "and by the way" states
- more nuanced "something went wrong, but it's kinda ok?" in-between states
- the huge tree of failure states
Sometimes return value
feels a little too simple.