LBYL versus EAFP


In some other languages, one can not recover from an error, or it is difficult to recover from an error, so one tests input before doing something that could provoke the error. This technique is called Look Before You Leap (LBYL)

For example, one must avoid dividing by zero.

Below is code that divides by numbers. When it gets the zero, it crashes.

In [1]:
numbers = (3, 1, 0, -1, -2)
In [2]:
def foo(x):
    return 10 // x

for x in numbers:
    y = foo(x)
    print(f'foo({x}) --> {y}')
foo(3) --> 3
foo(1) --> 10
---------------------------------------------------------------------------
ZeroDivisionError                         Traceback (most recent call last)
<ipython-input-2-4c039b97d2ba> in <module>()
      3 
      4 for x in numbers:
----> 5     y = foo(x)
      6     print(f'foo({x}) --> {y}')

<ipython-input-2-4c039b97d2ba> in foo(x)
      1 def foo(x):
----> 2     return 10 // x
      3 
      4 for x in numbers:
      5     y = foo(x)

ZeroDivisionError: integer division or modulo by zero

So one checks before dividing as shown below.

Checking before doing something is called "Look Before You Leap" (LBYL).

In [3]:
def foo(x):
    if x == 0:
        y = 0
    else:
        y = 10 // x
    return y

for x in numbers:
    y = foo(x)
    print(f'foo({x}) --> {y}')
foo(3) --> 3
foo(1) --> 10
foo(0) --> 0
foo(-1) --> -10
foo(-2) --> -5

Another technique is to just try stuff, and if it blows up, do something else.

This technique is called Easier to Ask Forgiveness than Permission (EAFP).

Python makes it very easy to do something else when something blows up.

In [4]:
def foo(x):
    try:
        y = 10 // x
    except ZeroDivisionError:
        y = 0
    return y

for x in numbers:
    y = foo(x)
    print(f'foo({x}) --> {y}')
foo(3) --> 3
foo(1) --> 10
foo(0) --> 0
foo(-1) --> -10
foo(-2) --> -5

For that simple example, EAFP does not have much if any benefit over LBYL. For that simple example, there is not much benefit in the size or readability of the code. However, for more complicated problems, EAFP lets one write much simpler and readable code.

We will use the example of determining if a string is a valid float for Python.

See 2.4.6. Floating point literals for what constitutes a valid float.

floatnumber   ::=  pointfloat | exponentfloat
pointfloat    ::=  [digitpart] fraction | digitpart "."
exponentfloat ::=  (digitpart | pointfloat) exponent
digitpart     ::=  digit (["_"] digit)*
fraction      ::=  "." digitpart
exponent      ::=  ("e" | "E") ["+" | "-"] digitpart

Some code for that follows.

In [5]:
import re

def is_float(s, debug=False):
    digit = f'([0-9])'
    digitpart = f'({digit}(_?{digit})*)'                           # digit (["_"] digit)*
    fraction = f'([.]{digitpart})'                                 # "." digitpart
    pointfloat = f'(({digitpart}?{fraction}) | ({digitpart}[.]))'  # [digitpart] fraction | digitpart "."
    exponent = f'([eE][-+]?{digitpart})'                           # ("e" | "E") ["+" | "-"] digitpart
    exponentfloat = f'(({digitpart} | {pointfloat}) {exponent})'   # (digitpart | pointfloat) exponent
    floatnumber = f'^({pointfloat} | {exponentfloat})$'            # pointfloat | exponentfloat
    floatnumber = f'^[-+]?({pointfloat} | {exponentfloat} | {digitpart})$'  # allow signs and ints
    
    if debug:
        regular_expressions = (
            digit,
            digitpart,
            fraction,
            pointfloat,
            exponent,
            exponentfloat,
            floatnumber,
        )
        for s in regular_expressions:
            print(repr(s))
            # print(str(s))
            
    float_pattern = re.compile(floatnumber, re.VERBOSE)

    return re.match(float_pattern, s)
In [6]:
floats = '''
    2
    0
    -1
    +17.
    .
    -.17
    17e-3
    -19.e-3
    hello
'''.split()
floats
Out[6]:
['2', '0', '-1', '+17.', '.', '-.17', '17e-3', '-19.e-3', 'hello']
In [7]:
is_float('', debug=True)
'([0-9])'
'(([0-9])(_?([0-9]))*)'
'([.](([0-9])(_?([0-9]))*))'
'(((([0-9])(_?([0-9]))*)?([.](([0-9])(_?([0-9]))*))) | ((([0-9])(_?([0-9]))*)[.]))'
'([eE][-+]?(([0-9])(_?([0-9]))*))'
'(((([0-9])(_?([0-9]))*) | (((([0-9])(_?([0-9]))*)?([.](([0-9])(_?([0-9]))*))) | ((([0-9])(_?([0-9]))*)[.]))) ([eE][-+]?(([0-9])(_?([0-9]))*)))'
'^[-+]?((((([0-9])(_?([0-9]))*)?([.](([0-9])(_?([0-9]))*))) | ((([0-9])(_?([0-9]))*)[.])) | (((([0-9])(_?([0-9]))*) | (((([0-9])(_?([0-9]))*)?([.](([0-9])(_?([0-9]))*))) | ((([0-9])(_?([0-9]))*)[.]))) ([eE][-+]?(([0-9])(_?([0-9]))*))) | (([0-9])(_?([0-9]))*))$'
In [8]:
for s in floats:
    print(f'{s!r} -> {bool(is_float(s))}')
'2' -> True
'0' -> True
'-1' -> True
'+17.' -> True
'.' -> False
'-.17' -> True
'17e-3' -> True
'-19.e-3' -> True
'hello' -> False
In [9]:
import re

def is_float(s):
    digit = f'([0-9])'
    digitpart = f'({digit}(_?{digit})*)'                           # digit (["_"] digit)*
    fraction = f'([.]{digitpart})'                                 # "." digitpart
    pointfloat = f'(({digitpart}?{fraction}) | ({digitpart}[.]))'  # [digitpart] fraction | digitpart "."
    exponent = f'([eE][-+]?{digitpart})'                           # ("e" | "E") ["+" | "-"] digitpart
    exponentfloat = f'(({digitpart} | {pointfloat}) {exponent})'   # (digitpart | pointfloat) exponent
    floatnumber = f'^({pointfloat} | {exponentfloat})$'            # pointfloat | exponentfloat
    floatnumber = f'^[-+]?({pointfloat} | {exponentfloat} | {digitpart})$'  # allow signs and ints
    
    float_pattern = re.compile(floatnumber, re.VERBOSE)

    return re.match(float_pattern, s)
    
def safe_float(s, default=0.):
    if is_float(s):
        x = float(s)
    else:
        x = default
    return x

def main(lines):
    total = sum(safe_float(line) for line in lines)
    print(f'total is {total}')
        
main(floats)
total is 17.828

Now we try EAFP technique below.

In [10]:
def safe_float(s, default=0.):
    try:
        x = float(s)
    except ValueError:
        x = default
    return x

def main(lines):
    total = sum(safe_float(line) for line in lines)
    print(f'total is {total}')
        
main(floats)
total is 17.828

The EAFP code is much much simpler.

The LBYL version was very complicated. If there was a bug in the LBYL version, how would you find it? If you fixed it, how much confidence would you have that your fix is correct? How hard would it be to have test cases that covered all the edge cases? How much confidence would you have that the test cases were comprehensive?

EAFP makes code easier to read, simpler, and more reliable.

This is what makes try/except one of Python's superpowers!!!


try/except best practices

Should:

  • always specify at least one exception
  • put as little as possible in the try clause

Because the 20170424-except-*.py programs can lock up Jupyter notebook, run them outside of the notebook. The cell below shows what they look like, but does not run them.

In [11]:
from glob import glob
import os

for filename in sorted(glob('20170424-cohpy-except-*.py')):
    print(79 * '#')
    print(filename)
    print()
    with open(filename) as f:
        print(f.read())
    print()
###############################################################################
20170424-cohpy-except-0-bare.py

#!/usr/bin/env python3

'''This program has two try/except sins:
1. too much stuff in try: clause
2. bare except
See https://www.python.org/dev/peps/pep-0008/#programming-recommendations.

Because of the bare except, one can not get out of the program by typing ^C.

For iter(partial(input, prompt), sentinel) idiom,
see https://mail.python.org/pipermail/centraloh/2016-July/002895.html
and http://nbviewer.jupyter.org/github/james-prior/cohpy/blob/master/20160708-dojo-user-input-loop-with-iter-partial-input-prompt-sentinel.ipynb
.
'''

from functools import partial

prompt = 'Please enter an integer: '
while True:
    try:
        for i in map(int, iter(partial(input, prompt), None)):
            print(f'Got {i}. That is a valid integer.')
    except: # This catches all exceptions, including KeyboardInterrupt.
        print('ERROR: That was not an integer. Try again.')


###############################################################################
20170424-cohpy-except-1-valueerror.py

#!/usr/bin/env python3

'''This program has one try/except sin:
1. too much stuff in try: clause
See https://www.python.org/dev/peps/pep-0008/#programming-recommendations.

Because the except is not bare and does not specify KeyboardInterrupt,
one can get out of the program by typing ^C.
'''

from functools import partial

prompt = 'Please enter an integer: '
while True:
    try:
        for i in map(int, iter(partial(input, prompt), None)):
            print(f'Got {i}. That is a valid integer.')
    except ValueError:
        print('ERROR: That was not an integer. Try again.')


###############################################################################
20170424-cohpy-except-3-valueerror-min-try.py

#!/usr/bin/env python3

'''This program has no try/except sins.

Because the except is not bare and does not specify KeyboardInterrupt,
one can get out of the program by typing ^C.
'''

from functools import partial

prompt = 'Please enter an integer: '
for s in iter(partial(input, prompt), None):
    try:
        # This try clause has minimal code. That is good!
        i = int(s)
    except ValueError:
        print('ERROR: That was not an integer. Try again.')
    else:
        print(f'Got {i}. That is a valid integer.')