2018-05-23

Exceptions will rule the world… and “all” goes lazy.

Python has an odd tolerance for try - except controlling the flow. They have also an ancronym to justify it: EAFP, which is Easier to ask for forgiveness than permission.

Surely it is often easier, but easier doesn't mean necessarily good, or better 〈positive adjective you prefer〉.

Now, despite what I've just written, I am fine with it — except when indeed I am not, and it happens especially because flocks of users feel allowed to use exceptions without any grain of wisdom.

Boring languages instead teach this: do not use exceptions to control the flow. Which is perfectly logical given the name exception.

If Python idiom really allows overusing exceptions, maybe they should have called them differently. For example, events, and instead of except they should have occurs (or occur), or anything but except.

StackOverflow's links on the subject:

Thoughts on “controlling the flow”

In Python, throwing exceptions for normal situations, and catching exceptions rather than preventing them, is more idiomatic than in other languages. For instance, an iterator's next method throws an exception to indicate when all elements have been visited.

Now, the next raising NotFound is an example which doesn't bother me. I am inside an interator, the caller is asking for more, and I raise the flag: hey, no more for you. The exception goes up to the caller which then will know there are no more elements. And since the caller is iterating over collection or whatever, this NotFound happens once after having run over N elements, then it is an exception, after all.

This can be “controlling the flow”, but I would rather classify it as signaling the caller about a situation.

When I think about “controlling the flow”, I am thinking more like this:

x = -10

try:
    if x < 0:
        raise ValueError
    print("positive")
except:
    print("negative")

Ok, this is extreme and nobody does it — but it must be pythonic, maybe just a little bit too much. A more serious example, taken from here, slightly modified.

alist = []
with open('e.txt') as e:
    for line in e:
        s = line.split()
        start = int(s[0])
        target = int(s[1])
        try:
            if s[2] == 'nope':
                continue
        except IndexError:
            alist.append([start, target])

Pythonic brain at work: try to access the index of an element you know it could not exist, and in case it doesn't exist, … forgive me, but this is the good case when you want to store start and target.1

What does it matter is that there's a try-except while reading from a file… how many lines have only two elements, and how many lines have three or more?

The try-except can be replaced by a simple if-else, which is at least as much as readable as the try-except.

        if len(s) > 2 and s[2] == 'nope':
            continue
        else:
            alist.append([start, target])

This is slightly different, though: if s[2] exists and it isn't nope, start and target are appended, and the same happens when s[2] doesn't exist.

When there are less than 2 elements, and when int(...) fails, exceptions are raised, and this is good because missing required elements, and non-integer elements, are more exceptional events than the difference allowed by the format of the file.

Even better, we can remove the continue:

        if len(s) <= 2 or s[2] != 'nope':
            alist.append([start, target])

Python idiom may allow to control the flow with exceptions, but doing so here (and in many other similar situations) would be just wasteful nonsense. Saying that Python can use exceptions to control the flow doesn't mean you must do so everytime.

So, Python is not pushing its users to think about it.

I'd go with this simple rule: you won't use exceptions, except for exceptional events and/or when a callee is giving back control to a caller with a special condition (like NotFound on a next request).

Pythonistas aren't Haskellers (these have their Church), nonetheless somebody downvoted me because I haven't aknowledged that Python has its path through control flow, and that path accepts exceptions… Except when it doesn't, i.e., everywhere it doesn't make sense, like here and in many other situations, again.

All not lazy

A solution looked like this:

        if all([len(words) > 2, words[2] == 'nope']):
            continue
        else:
            alist.append([int(words[0]), int(words[1])])

I don't think it is an obscure way of writing the A and B condition, but anyway it doesn't work because first the list is constructed — i.e., all the values are evaluated — then all() iterates over those computed values.

This is unfortunate, because it means that words[2] is evaluated, no matter if the result of len(words) > 2 is true or false. The all() short-circuits, but isn't lazy.2

Let's take a look at the implementation of all, and let's roll our “lazy” version:

def lazy_all(iterable):
    for el in iterable:
        if not el():
            return False
    return True

Now

        if lazy_all([lambda: len(words) > 2, lambda: words[2] == 'nope']):
            continue

works, but I agree that this is worthless. Isn't it?


  1. This seems buggy because when s[2] exists and it isn't nope, nothing happens anyway — but maybe it is what he wanted. Here it doesn't matter.

  2. In fact it is just a function receiving an “array”, so the common order of evaluation is used. To have lazyness, it should have been a special syntax to handle in a special way, evaluating B only if the evaluation of A gave true.

No comments:

Post a Comment