Read, don’t reason, about the code.
I recently read this post on software exceptions, and the ensuing debate between whether or not exceptions or return codes are the better way to handle errors. A common critique of exceptions – and nearly every high level programming construct since the introduction of the object – is that they are hard to ‘reason’ about. Their logic is hard to follow for a myriad of reasons, and this makes them hard to debug and dangerous in the hands of novices.
I couldn’t disagree more with this criticism. There are many different counterpoints to be made, but the main one is that we, as software engineers, need to begin to read, instead of reason about code. Ultimately, whether or not code is easy to reason about is less important than how easy that code is to read.
Reading, versus reasoning, about code can also be put another way – like our justice system, we should assume code is innocent until proven guilty. We need to be able to ‘trust’ code, even code that we ourselves did not write, as doing what it claims to be doing. Is this because most code is good code? Or because as designers, we’re inherently skeptical of anyone else’s output? Not at all – The main reason we need to ‘reason’ about as little code as possible is because we’re so damned bad at it. Human beings are good at reading, we’re good at interpreting, we’re good at working with fuzzy knowledge and coming to conclusions. Insofar as mechanical reasoning goes, though, we’re terrible at it.
The idea of ‘trusting’ code to be right until we have reason to believe it’s wrong will likely strike many of you as wrong headed. After all, we’ve all read absolutely TERRIBLE code, code we have no reason to trust. But that’s not the point – we don’t trust because we trust the original designer to be talented and experienced. We trust the code because we have to – we simply don’t have enough RAM in our own skulls to keep large portions of code in there at once. We have to trust code because if we don’t, we hinder our own ability to track down the portions of code that actually contain defects and shouldn’t be trusted.
This means that, to have any chance at all ‘reasoning’ about any code, say, in the case where the code is not doing what we expect it to, we need to reason about as little of the code as possible. We need to keep as many cross-cutting concerns out of sight and out of mind as possible to allow our puny meat brains a chance to actually understand the mechanical problem that’s staring us right in the face, but we aren’t seeing. Many modern programming ‘paradigms’ have this in mind, most notably object oriented and aspect oriented programming. Both attempt to put like logic with like logic and to split apart as many responsibilities as possible. When I zero in, as a programmer, on a single method on a single object, I should like it to do one thing and one thing only. That way, if there is a defect, it will a) jump out at me since there’s little code around it and b) need to only be fixed in one place and one place only.
These language constructs that OOP and AOP have introduced, as well as those introduced by functional programming, have all been an effort to raise the abstraction level such that we reason less and less and read more and more. “Reading” a well nested for loop in C, dereferencing pointers all over the place, is nigh impossible. Such things require us to reason about them (and reason poorly at that). Replacing such for loops with list comprehensions or progressive calls to map, filter and reduce all use higher level constructs that make certain guarantees to the user and they reduce the cognitive load on the reader. The less code there is, and the higher level it is, the less we need to worry about reasoning and the more we can worry about reading.
For instance, if we are worried about a defect in a for loop, we need to go through line by line, examining each pointer dereference and each operation. On the contrary, if we are looking at a few applications of map and filter, we are assured of where the defect is not – it is NOT in the looping logic, stored away in the map and filter higher level functions.
In the exception versus return code debate, the problems are similar – if I have a bug and I have narrowed it down to a certain section of code and realize that code involves checking return codes from other methods, I need to figure out how to interpret those return codes (which most likely are poorly documented), and then probably drill down further into the function calls that return those codes themselves to find out whats going on. I am forced more and more to reason about the code, rather than just read it. In fact, that I found this section of code buggy at all is a miracle, probably requiring a few hours of sprinkling in print statements or disciplined use of a debugger to find out what was going on.
In the case of the exception, any uncaught exception bubbles to the top of the program. Generally it not only gives a better description of what went wrong but also where it went wrong, immediately helping me figure out where the defect is. Furthermore, return codes must be reasoned about, while exceptions can be read. When I see a try catch block, I can read it to say “there might be an exception thrown in there”. I don’t necessarily have to ask what exception, although that’s usually self-documented at the catch block. I don’t need to ask why this exception might be thrown. If I read in code a statement that raises or throws an exception, the designer who wrote the statement has embedded semantic knowledge that the control flow that leads to that statement is, in fact, exceptional.
If I’m reading error codes, I’m having to lower my level of abstraction. In the world of exceptions, I have exceptions and I have return values – I can stick closer to the metaphor of a mathematical function, and stick closer to the idea of simply ‘reading’ what a function does from it’s name, not how it does it, from it’s actual implementation. When I have to check return codes, now I’ve completely lost the metaphor of the mathematical function – now I no longer know for sure what arguments a function might be modifying, and I have to go and do some investigative work on looking up what each ‘error’ condition actually means. In other words, when I’m forced to reason about a function, I’m forced to figure out everything to figure out anything. The ‘price’ of fixing that defect is higher since it imposes more cognitive load on me. When I can read a function, and I trust that it does what it implies it does, either through its name or something like a docstring, I only need to concentrating on understanding the one thing about the function I’d like to understand – rather than the entire function itself.
Both in high level list comprehensions and exception handling, the problem of just reading what the function is telling you “I throw an exception” or “I am a mapping from one collection to another” is easy and gives the reader some conventions that he or she can rely on – namely, the bug is not in the map function itself. Likewise, with exceptions, I know that the bug is not in the exception handling mechanism itself – it’s impossible to miss a return code check, or to misunderstand a code and return the wrong one in an exception handling mechanism. These constructs are, like everything else, “no silver bullet” but they do reduce potential errors and do reduce the cognitive load required by the reader by allowing the reader to simply ‘read’ them and move on, rather than trying to ‘reason’ about them and figure out what the hell the original designer was trying to do.
(A side note to this whole conversation is what is ‘readable’ and what is not ‘readable’. Certainly, misusing features and simple lack of talent can produce unreadable code. But likewise, more advanced idioms and techniques might appear ‘unreadable’ to someone not familiar with them. When something is ‘unreadable’, it means there is a miscommunication between the original designer of the code and the current reader of the code – but how do we separate and decide when the unreadable code is the originator’s fault – via a misunderstanding of concepts or techniques, or the current reader’s fault, via the same misunderstanding of concepts and techniques.
Some heavily templated C++ code is derided as unreadable by some. Is that because the originator has abused the templating features of the language? Or is it because the reader never understood those templating features in the first place? I bring this up not at all to imply that Joel doesn’t understand exception handling – he seems as talented a designer as any (many would say more so.) But he does keep odd company, as I would suspect many of the most vocal proponents of a return to error codes probably don’t understand exceptions in the first place.)
1 Comment »