Explicit isn’t better than implicit
Posted by Michał ‘mina86’ Nazarewicz on 6th of June 2021
Continuing the new tradition of clickbaity titles, let’s talk about explicitness. It’s a subject that comes up when bike-shedding language and API designs. Pointing out that a construct or a function exhibits implicit behaviour is often taunted as an ultimate winning argument against it.
There are two problems with such line of reasoning. First of all, people claim to care about feature being explicit but came to accept a lot of implicit behaviour without batting an eye. Second of all, no one actually agrees what the terms mean.
In this article I’ll demonstrate those two issues and show that ‘explicit over implicit’ is the wrong value to uphold. It’s merely a proxy for a much more useful goal interfaces should strive for. By the end I’ll demonstrate what we should really look at instead.
Dispelling the myths
Let’s start by examining just how explicit Python and Rust are. After all, their communities often boast the virtue of explicitness in their respective languages. I’m sure it’s going to be completely uncontroversial to suggest that they may be much more implicit than some give them credit for.
The Zen of Python is a lie
The Zen of Python is a collection of aphorisms which represent Python’s guiding principles. It’s not clear, at least to me, whether they are listed in order of importance, but the second entry states that ‘explicit is better than implicit’. Despite that, there are multiple instances where this rule is broken. In particular, Python implicitly:
- creates new variables. One cannot even argue that the assignment statement defines a variable since that’s not the case as can be seen in the following toy example:
foo = 'foo' def func(): return foo foo = 'bar' print(func())
- propagates exceptions making any statement possible function exit point;
- converts values to booleans in conditions. For example,
if some_list:is a Pythonic way to check if a list is non-empty while
if not var:is a Pythonic way of checking whether a variable is
- converts between booleans, integers and floats in arithmetic operations. Depending on the operation, this happens even if both operands are of the same type;
- concatenates strings separated by white-space;
- constructs tuples from comma-separated values (without the need to type parenthesise);
- loads package’s
__init__.pyfile when importing a module; and
- implicitly returns
Nonefrom functions lacking explicit return.
It could even be argued that garbage collection is an implicit behaviour. After all, objects are never explicitly freed and all memory management is hidden from the user.
Rust philosophy of explicitness is not a thing
But let’s not dwell on scripting languages, change gears and go a level lower to a compiled and strictly-typed Rust. While it doesn’t have a formal list of guiding principles, explicitness is often cited as an important value. Yet, what often seems to be forgotten is that Rust implicitly:
- infers types,
- infers lifetime in function prototypes,
- shortens lifetimes of references,
selfby value or reference based on method prototype,
Copytypes when passed to functions by value,
Dropobjects when they go out of scope and
- converts error type when question mark operator is used.
- implicitly returns
()from functions lacking tail or return expression.
To be even more contrarian, I could claim
sort method implicitly uses natural ordering. Or that order of operations in
230 - 220 / 2 expression isn’t explicitly specified.
But the point here isn’t to demonstrate that Python or Rust aren’t ‘explicit’. Such position would be hard to defend. Rather, it is to show that even languages which pride themselves in championing explicitness are willing to compromise on that principle. This means that saying some new feature exhibits implicit behaviour is not a be-all and end-all argument for blocking such design.
‘You’ve typed it so it’s explicit’
Than again, maybe I’m completely off the mark? Perhaps all the aforementioned behaviour aren’t implicit after all? For example, Python documents quite clearly what values are interpreted as false and which as true. This means that there is nothing implicit in
if some_list: not executing the body if the list is empty, right?
Some of the examples I’ve enumerated are definitely less clear-cut than others, but I challenge anyone who thinks that none of them are valid to come up with justification and then present an example of a feature of any non-esoteric language which is implicit. One will quickly realise that either at least some of the aforementioned behaviours are implicit or there’s no such thing as an implicit behaviour and thus the whole discussion is moot.
Ultimately this leaves us with no commonly agreed definition of what it means for a feature or interface to be explicit. There’s not even a consensus on some vague understanding of the phrase. On one extreme, a not unreasonable argument that nothing is implicit could be made (after all a program behaves exactly according to language’s documentation), on the other, some C♯ programmers argue that
"" isn’t an explicit-enough way of specifying an empty string. Unfortunately, I don’t have a definition which would satisfy everyone and thus solve this particular problem. Instead, I’m side-stepping the entire discussion.
Explicit doesn’t matter
Because, you see, when people say ‘design X is bad because it’s not explicit’ they actually don’t care about the feature being implicit. Instead, they (potentially subconsciously) use the level of explicitness as a proxy to decide how easy it is to reason about the program.
To continue with Python’s boolean coercion example, it is well understood that Python considers zero values and empty containers to be false. Therefore there’s little issue with
if some_list: checking whether the list is empty. However, time of day is neither a collection nor a number so
if some_time: checking whether object represents midnight was an issue. It wasn’t because the check was implicit (this was also true when testing for empty container) but rather because it was an unexpected behaviour.
When Rust infers types, the compiler picks the only one sensible and obvious choice. If there’s any ambiguity, programmer has to explicitly specify the type. Contrast it with function overloading in C++. The rules are defined, but they are so convoluted only a handful of people understand them. Again, the issue isn’t whether the feature is implicit or not; the problem is how easy it is to reason about the code and how likely it is that the compiler does something unexpected.†
Principle of least astonishment
What actually matters is following the principle of least astonishment. Rather than wondering whether a particular design is explicit enough, the correct question is to ask how likely it is for a feature to lead to a surprising behaviour.
Bugs often emerge when the compiler does something programmer doesn’t expect. Python treating
True as one in arithmetic operations is the only sensible non-throwing interpretation which means that the principle is preserved. On the other hand C promoting integers can easily lead to astonishing results (such as unsigned object being less than negative value of a signed type) which violates the principle.
In conclusion, advocating explicitness for explicitness’s sake is not sound. Being explicit is a tool which, in some cases, helps minimise surprises in the code and makes it easier to reason about a program. If implicit behaviour does not make the source more confusing that it normally would be, there’s no reason to fight it.
But even then, all those things need to be weighted in context of other useful properties of a design. Ergonomics of a programming language matter and it may sometimes be worth sacrificing the principle of least astonishment if that means the code may be more beautiful (which coincidentally is another aphorism from The Zen of Python).
† As an aside, confusing a general idea of a feature with implementation of the feature in a specific language is another common fallacy. Function resolution in C++ may be convoluted but that doesn’t mean that function overloading in general needs to be confusing. For example, picking methods in Java is quite straightforward and allowing overloading on arity in Rust would leave no ambiguity in the code. Arguing against default parameters on the account of how mystifying C++ can get is therefore invalid. Similar comparison could be made with Python’s treatment of false values. Yes, it may sometimes lead to astonishing results (e.g. intending to test for
None but forgetting that non-
None values are tested as well), but if adapted to Rust, those surprising behaviours would not be an issue (thanks to strict typing).