Analog Moment

by James Cox-Morton (@th3james)

Inconvenience and dishonesty in programming

Recently, I found myself reading a function which made a network call, which can, of course, fail. However, the function handled this potential failure fairly bluntly:

try {
  … do network request …
} catch * {
  log “error making request #{url}”
  return nil
}

Unless it truly doesn't matter if the function succeeds, this is fairly obviously a bad design.

I've come to characterise code like this as both inconvenient, but also dishonest. Inconveniences can be tolerated, but dishonesty is a cause of programming bugs. So let's define these words.

A function or method which can fail is inconvenient. It inconveniences the caller by forcing them to deal with the fact that they may not get the thing they asked for and will need to decide how to handle that possibility. Functions with unintuitive or complex return values are also inconvenient.

Nobody feels good about inconveniencing their callers and I think this is why code like the example gets written. Perhaps, when we realise that our code can fail, or the reality of the task is more complicated that we thought, we try to avoid that uncomfortable reality leaking out of our code and into the callers' world. We think we're being polite by pretending to the caller that it's going to be fine and that life is simple.

Swallowing this complexity and pretending it doesn't exist is dishonest. Sure, for a little while our caller might feel good about not having to deal with this complexity, but eventually our function is going to fail or behave in a way that's unexpected, and when we do, it's going to be much harder for the caller to debug why this happened.

You might be thinking: "of course, I'd never write code that so egregiously catches an exception and discards it!". But dishonest code is something I see frequently (typically when debugging!). Perhaps the most common piece of dishonesty I regularly see is the silent possibility of nil.

An only-slightly-discursive rant about nil

Nil (or Null or None) is one of the biggest mistakes in computer science. The majority of programming languages handle nils extremely poorly, essentially as gaping holes in their type systems. Take for example an ORM 'find()’ method which returns a single record from the database. Like most interactions with databases, this call is necessarily inconvenient: there's a chance the record we're looking for doesn't exist. An honest function should communicate this to the caller, either by throwing an exception, or in languages with a more robust error handling systems, returning an Optional or Result type (thereby indicating the possibilities of presence or absence). However, some ORMs will return nil in the case that the record doesn't exist.

Nils are problematic for a few reasons. Perhaps the biggest issue with nils is that they don’t fail at the point they are created. The failure will occur when the the program attempts to use the nil. If the programmer attempting to debug it is lucky, this will be close to where the nil was first created, but it’s entirely possible that the stack trace they will find themselves looking at will be quite distant from where the nil was first introduced. This means having to retrace the steps though the code to where the nil first appeared.

The second big issue with nils as errors is that they provide no information about what condition caused them to exist instead of a value. In some cases it can be guessed with some accuracy (for example, ORM.find returning nil probably implies the record doesn’t exist), but it’s harder to be certain. Particularly egregious is when there is more than one reason nil could be returned. Nil is by definition the very smallest amount of information you can return about a failure, the programming equivalent of a ¯\_(ツ)_/¯. Not only does this lack of information make debugging failures harder, it also restricts the callers’ ability to handle failures effectively.

Consider reading code where the author has called a function, then checks if the return value is nil. There’s a lot of ambiguity there and understanding this code requires both knowledge and guesswork from the reader.

  • What caused the nil to be returned?
  • Did the author of the nil check know all the possible error cases that could return nil?
  • Is their handling of this nil case designed to address all nil-returning cases? Or are they just handling the ones that could think of?

There are certainly cases where guessing intent will be easier, particularly if you are knowledgeable of the call being made and if you have confidence in the knowledge of the author. But it’s still implicit.

Now consider a function call that is wrapped, catches a DoesNotExist exception and handles it. This is far more explicit: there is one specific error case the programmer has expected and handled. There are no other types of error handled: any other thrown errors will crash (unless caught further up the call stack). Additionally, the DoesNotExist exception might contain information the caller can uses to handle the error more intelligently.

Failing early

As such, I characterise the returning of nil as dishonest. It allows the program to keep running when an error has occurred, failing at an unpredictable point, and provides almost no information about what error actually occurred. Throwing exceptions is honest: either exceptions are handled explicitly at call time (opt-in), or they crash immediately with information about the failure. I believe it is the possibility that introducing a nil won't cause a crash or introduce a bug that tempts more inexperienced developers to return them. A more experienced programmer knows that it is better to fail early and explicitly.

We have known this since the days of C. In Peter van der Linden's "Expert C Programming", in answering "Why does NULL pointer crash printf?", he puts it succinctly:

[in passing NULL to printf] the programmer has coded something wrongly, the question is, “At what point do you wish to bring this to his or her attention?”

White lies

Honesty also applies in a broad range of areas, like naming. Sometimes, we will write short function or variable names which which appear more elegant, but which don’t really capture the truth of what’s happening. The name concatenate_array_or_nan_when_object for a function is pretty unpleasant to read and type, but it’s more honest than having a function called concatenate_array that can unexpectedly return NaN when given an Object (yes this is a dig at JavaScript). Of course, it is better never to write a function that exhibits such unintuitive behaviour, but sometimes constraints force us to write inconvenient code. It is forgivable to write inconvenient code, it is rarely forgivable to write dishonest code.

However, total honesty isn't always desirable. In the same way as the occasional white-lie may be excusable, sometimes the possibility of failure is so remote as to not be worth forcing the caller to be aware of a case. For example, it would be pretty annoying if every call to the database forced me to handle the possibility that the database was unreachable.

You might be thinking “Isn’t this just verbosity”? Certainly, functions that I would characterise as ‘honest’ are more verbose, but I would describe the difference between honesty and verbosity as this: Verbose suggests that lots of information will be provided, with little regard for what is truly useful. Honest functions are about being explicit about likely and noteworthy error cases and inconveniences, thereby helping the caller to know what they need to handle.

Honesty as a lens

I've found honesty and inconvenience to be useful ways to think about designing interfaces. It's interesting to be able to characterise languages by their honesty. Rust is without doubt the most honest language I've used, and it provides lots of tools and constructs to help users write honest code. This also means sometimes it can feel very fussy to write! JavaScript is at the other extreme, in that it will often return completely nonsensical values in preference to throwing any kind of error.

Every developer will find a level of comfort when it comes to trading honesty and convenience, and that level also varies dramatically by programming domain. I've found myself favouring honesty more as I've become more experienced.

Next time you find yourself debating how to handle a failure or inconvenience, ask yourself: is this honest? Next time you find a bug, ask yourself: was this caused by dishonesty? And did dishonestly make this harder to track down?