Not a Big Fan of Defer
Apologies in advance for what may be bikeshedding, but: why is a defer
keyword such a popular 1 2 3 choice in modern programming
languages?
The simplest answer is, “because it’s convenient”. Initializing
something usually implies a matching cleanup procedure that also has to
run. For the code to be correct, every init has to have a matching
cleanup (for example, fopen and fclose). Writing defer puts these
next to one another, which is ergonomic:
int MyFunction(void) {
File *f = fopen("mydata.bin", "rb");
defer if (f) fclose(f);
// Do some stuff with f...
// ...even if that stuff has an early return, fclose is guaranteed
// do be called.
}
That’s pretty nice. Otherwise, you end up having to manually check
that every return is preceded by an fclose(..). If the function
uses many files, or lots of things that need to be dealt with (maybe it
allocates memory that it has to free), then things get ugly fast.
But this also gets at the essential reason I don’t like defer: it
makes program flow needlessly non-linear. This is a pain when debugging,
especially if you do not have a debugger and are trying to determine the
program’s flow by sprinkling it with print statements.
Even if you’re using a debugger, which defer statements will run depends
on which blocks of code previously ran. This is bad news if there are
data dependencies between the deferred statements. It may be fairly hard
to determine, just by reading the code, whether or not all the right
destructors are guaranteed to run in the correct order. Also, most
people learn about a program’s flow by reading it, even if they do have
a debugger on hand; this is one of the many reasons we try to keep the
number of branching paths to a minimum, and we we try to avoid deep
delegation chains of functions calling functions calling functions. The
defer statement causes the same sort of “mental cache miss” by forcing
the reader to jump around in the code a lot.
One alternative to defer is to disallow the use of early return. If
you follow Single-Entry Single-Exit rules in your code, the only place
return is allowed is at the bottom of the function. In this case, you
can put all the cleanup code right above that return statement, since
you are guaranteed to get there.
Proponents of defer might argue that this separates the initialization
and cleanup by possibly hundreds of lines, depending how long the
function is. That’s true, but I don’t think it matters that much in
practice. Whether you use defer or not, the only way to verify that a
function is cleanup-correct – other than having written it recently –
is to go through it line by line looking for statements which allocate
or initialize something (you can’t just grep for defer because the
failure mode you’re searching for is an allocation missing a defer.)
If you follow SESE, you can also improve your life by making sure your
cleanup code goes in the opposite order as the initializations: first to
initialize should be last to clean up (defer implementations usually
obey this schedule automatically).
Usually SESE code ends up using an “early-exit” variable; when it is set, most of the important blocks get skipped. For example, suppose we needed to write a bunch of data to a file:
FILE *f = open("/path/to/file", "w");
bool ok = !!f;
if (ok) {
int result = fprintf(f, "data part 1\n");
if (result < 0) ok = false;
}
if (ok) {
int result = fprintf(f, "data part 2\n");
if (result < 0) ok = false;
}
if (f) fclose(f);
In C and other languages that have it, the humble goto tidies things
up here if you don’t like extra typing. This is the one place I allow
goto in my own code. It adds a footgun, namely that variables can be
in scope without being initialized, but you can avoid this in lots of
ways (the best is to declare all resource variables at the top of the
function, as you would do in strict C89).
int succeeded = false;
FILE *f = open("/path/to/file", "w");
if (!f) goto finish;
if (fprintf(f, "data part 1\n") < 0) goto finish;
if (fprintf(f, "data part 2\n") < 0) goto finish;
succeeded = true;
finish:
if (f) fclose(f);
// 'succeeded' now has the same meaning as 'ok' in the previous example
Of course, using this means you can’t use linters that prohibit goto.
If your programmers are already producing a lot of entropy, allowing
goto may have even more disastrous effects.
If you decide that bringing back goto is a bit much – that’s
reasonable. It’s a feature in C, which I use, and defer is not, so
that makes the choice simple for me. But for the reasons above: if I
switched to a language that had defer but no goto, I’d probably
avoid the defer where possible and stick to something like SESE.