Chapter 4. Flow Control and Logic

Flow control is a high-level way of programming a computer to make decisions. These decisions can be simple or complicated, executed once or multiple times. The syntax for the different flow control mechanisms varies, but what they all share is that they determine an execution pathway for the program. Python has relatively few forms of flow control. They are conditionals, exceptions, and loops.

As someone primarily interested in physical reality, you might wonder why you should care about flow control and logic. In some ways, this is like asking why arithmetic is important. Logic presents rules that allow you to build up and represent more complex ideas. This enables the physics modeling you want to do by giving you a means to express the choices and behavior of your model to the computer. With basic flow control syntax, your models can make simple decisions. With more advanced flow control, your models can make more sophisticated choices more easily. In other situations, flow control allows you to reuse the same code many times. This makes the software model faster to write and easier to understand, because it has fewer total lines of code. Logic and flow control are indispensible to doing any significant amount of work with computers. So, without further delay, let’s jump into conditionals, our first bit of flow control.

Conditionals

Conditionals are the simplest form of flow control. In English, they follow the syntax “if x is true, then do something; otherwise, do something else.” The shortest conditional is when there is only an if statement on its own. The format for such a statement is as follows:

if <condition>:
    <if-block>

Here, the Python keyword if is followed by an expression, <condition>, which is itself followed by a colon (:). When the Boolean representation of the condition, bool(condition), is True, the code that is in the <if-block> is executed. If bool(condition) is False, then the code in the block is skipped. The condition may be composed of any of the comparison operators (or a combination of these operators) that were listed in Table 2-1. For convenience, just the comparison operators are shown again here in Table 4-1.

Table 4-1. Python logical operators useful for comparing the variables x, y, and z
Name Usage Returns

Unary operators

Negation

not x

Logical negation—True becomes False, and vice versa.

Bitwise invert

~x

Changes all zeros to ones and vice versa in x’s binary representation.

Binary operators

Logical and

x and y

True if bool(x) and bool(y) are True; False otherwise.

Logical or

x or y

x if bool(x) is True; otherwise the value of y.

Comparison binary operators

Equality

x == y

True or False.

Not equal

x != y

True or False.

Less than

x < y

True or False.

Less than or equal

x <= y

True or False.

Greater than

x > y

True or False.

Greater than or equal

x >= y

True or False.

Containment

x in y

True if x is an element of y.

Non-containment

x not in y

False if x is an element of y.

Identity test

x is y

True if x and y point to the same underlying value in memory.

Not identity test

x is not y

False if x and y point to the same underlying value in memory.

Ternary operators

Ternary compare

x < y < z

True or False, equivalent to (x < y) and (y < z). The < here may be replaced by >, <=, or >= in any permutation.

For example, if we wanted to test if Planck’s constant is equal to one and then change its value if it is, we could write the following:

h_bar = 1.0
if h_bar == 1.0:
    print("h-bar isn't really unity! Resetting...")
    h_bar = 1.05457173e-34

Here, since h_bar is 1.0 it is reset to its actual physical value (1.05457173e-34). If h_bar had been its original physical value, it would not have been reset.

A key Pythonism that is part of the if statement is that Python is whitespace separated. Unlike other languages, which use curly braces and semicolons, in Python the contents of the if block are determined by their indentation level. New statements must appear on their own lines. To exit the if block, the indentation level is returned back to its original column:

h_bar = 1
if h_bar == 1:
    print("h-bar isn't really unity! Resetting...")
    h_bar = 1.05457173e-34
h = h_bar * 2 * 3.14159

The last line here (the one that defines h) indicates that the if block has ended because its indentation level matches that of the if on the second line. The last line will always be executed, no matter what the conditional decides should be done for the if block.

While we are on the subject, it is important to bring up the distinction between the equality operator (==) and the identity operator (is). The equality operator tests if two values are equivalent. For example, 1 == 1.0 is True even though 1 is an integer and 1.0 is a float. On the other hand, the identity operator tests if two variable names are references to the same underlying value in memory. For example, 1 is 1.0 is False because the types are different, and therefore they cannot actually be references to the same value. is is much faster than ==, but also much more strict. In general, you want to use is for singletons like None and use the safer == in most other situations. The following examples show typical use cases and gotchas:

Code Output
1 == 1

1 == 1.0

1 is 1.0

1 is 1 1

10**10 == 10**10

10**10 is 10**10 2

None is None

0 is None 3

0 == None
1

To help with performance, Python only stores a single copy of small integers. So for small ints, every usage will be the same value in memory.

2

However, for big integers a new copy is computed each time.

3

Only None is None.

True

True

False

True

True

False

True

False

False

Before we move on, it is important to note that, by tradition, Python uses four spaces per level to indent all code blocks. Two spaces, eight spaces, or any other spacing is looked down upon. Tabs cause many more problems than they are worth. Most text editors have an option to automatically convert tabs to spaces, and enabling this can help prevent common errors. Some people find the whitespace syntax a little awkward to begin with, but it becomes easy and natural very quickly. The whitespace-aware aspect of Python is a codification of what is a best-practice coding style in other languages. It forces programmers to write more legible code.

if-else Statements

Every if statement may be followed by an optional else statement. This is the keyword else followed by a colon (:) at the same indentation level as the original if. The <else-block> lines following this are indented just like the if block. The code in the else block is executed when the condition is False:

if <condition>:
    <if-block>
else:
    <else-block>

For example, consider the expression sin(1/x). This function is computable everywhere except a x = 0. At this point, L’Hôpital’s rule shows that the result is also zero. This could be expressed with an if-else statement as follows:

if x == 0:
    y = 0
else:
    y = sin(1/x)

This is equivalent to negating the conditional and switching the if and else blocks:

if x != 0:
    y = sin(1/x)
else:
    y = 0

However, it is generally considered a good practice to use positive conditionals (==) rather than negative ones (!=). This is because humans tend to think about an expression being true rather than it being false. This is not a hard and fast rule, but it does help eliminate easy-to-miss logic bugs.

if-elif-else Statements

Python also allows multiple optional elif statements. The elif keyword is an abbreviation for “else if,” and such statements come after the if statement and before the else statement. The elif statements have much the same form as the if statement, and there may be as many of them as desired. The first conditional that evaluates to True determines the block that is entered, and no further conditionals or blocks are executed. The syntax is as follows:

if <condition0>:
    <if-block>
elif <condition1>:
    <elif-block1>
elif <condition2>:
    <elif-block2>
...
else:
    <else-block>

Suppose that you wanted to design a simple mid-band filter whose signal is 1 if the frequency is between 1 and 10 Hertz and 0 otherwise. This could be done with an if-elif-else statement:

if omega < 1.0:
  signal = 0.0
elif omega > 10.0:
  signal = 0.0
else:
  signal = 1.0

A more realistic example might include ramping on either side of the band:

if omega < 0.9:
  signal = 0.0
elif omega > 0.9 and omega < 1.0:
  signal = (omega - 0.9) / 0.1
elif omega > 10.0 and omega < 10.1:
  signal = (10.1 - omega) / 0.1
elif omega > 10.1:
  signal = 0.0
else:
  signal = 1.0

if-else Expression

The final syntax covered here is the ternary conditional operator. It allows simple if-else conditionals to be evaluated in a single expression. This has the following syntax:

x if <condition> else y

If the condition evaluates to True, then x is returned. Otherwise, y is returned. This turns out to be extraordinarily handy for variable assignment. Using this kind of expression, we can write the h_bar conditional example in one line:

h_bar = 1.05457173e-34 if h_bar == 1.0 else h_bar

Note that when using this format you must always include the else clause. This fills the same role as the condition?x:y operator that is available in other languages. Writing out if and else arguably makes the Python way much more readable, though also more verbose.

Exceptions

Python, like most modern programming languages, has a mechanism for exception handling. This is a language feature that allows the programmer to work around situations where the unexpected and catastrophic happen. Exception handling is for the truly exceptional cases: a user manually types in an impossible value, a file is deleted while it is being written, coffee spills on the laptop and fries the motherboard.

Warning

Exceptions are not meant for normal flow control and dealing with expected behavior! Use conditionals in cases where behavior is anticipated.

The syntax for handling exceptions is known as a try-except block. Both try and except are Python keywords. try-excepts look very similar to if-else statements, but without the condition:

try:
    <try-block>
except:
    <except-block>

The try block will attempt to execute its code. If there are no errors, then the program skips the except block and proceeds normally. If any error at all happens, then the except block is immediately entered, no matter how far into the try block Python has gone. For this reason, it is generally a good idea to keep the try block as small as possible. Single-line try blocks are strongly preferred.

As an example, say that a user manually inputs a value and then the program takes the inverse of this value. Normally this computes just fine, with the exception of when the user enters 0:

In [1]: val = 0.0

In [2]: 1.0 / val

ZeroDivisionError                         Traceback (most recent call last)
<ipython-input-2-3ac1864780ca> in <module>()
----> 1 1.0 / val

ZeroDivisionError: float division by zero

This error could be handled with a try-except, which would prevent the program from crashing:

try:
    inv = 1.0 / val
except:
    print("A bad value was submitted {0}, please try again".format(val))

The except statement also allows for the precise error that is anticipated to be caught. This allows for more specific behavior than the generic catch-all exception. The error name is placed right after the except keyword but before the colon. In the preceding example, we would catch a ZeroDivisionError by writing:

try:
    inv = 1.0 / val
except ZeroDivisionError:
    print("A zero value was submitted, please try again")

Multiple except blocks may be chained together, much like elif statements. The first exception that matches determines the except block that is executed. The previous two examples could therefore be combined as follows:

try:
    inv = 1.0 / val
except ZeroDivisionError:
    print("A zero value was submitted, please try again")
except:
    print("A bad value was submitted {0}, please try again".format(val))

Raising Exceptions

The other half of exception handling is raising them yourself. The raise keyword will throw an exception or error, which may then be caught by a try-except block elsewhere. This syntax provides a standard way for signaling that the program has run into an unallowed situation and can no longer continue executing.

raise statements may appear anywhere, but it is common to put them inside of conditionals so that they are not executed unless they need to be. Continuing with the inverse example, instead of letting Python raise a ZeroDivisionError we could check for a zero value and raise it ourselves:

if val == 0.0:
    raise ZeroDivisionError
inv = 1.0 / val

If val happens to be zero, then the inv = 1.0 / val line will never be run. If val is nonzero, then the error is never raised.

All errors can be called with a custom string message. The helps locate, identify, and squash bugs. Error messages should be as detailed as necessary while remaining concise and readable. A message that states “An error occurred here” does not help anyone! A better version of the preceding code is:

if val == 0.0:
    raise ZeroDivisionError("taking the inverse of zero is forbidden!")
inv = 1.0 / val

Python comes with 150+ error and exception types. (This is not as many as it seems at first glance—these exceptions are sufficient to cover the more than one million lines of code in Python itself!) Table 4-2 lists some of the most common ones you will see in computational physics.

Table 4-2. Common Python errors and exceptions
Exception Description

AssertionError

Used when the assert operator sees False.

AttributeError

Occurs when Python cannot find a variable that lives on another variable. Usually this results from a typo.

ImportError

Occurs when a package or module cannot be found. This is typically the result of either a typo or a dependency that hasn’t been installed.

IOError

Happens when Python cannot read or write to an external file.

KeyboardInterrupt

Automatically raised when an external user kills the running Python process with Ctrl-c.

KeyError

Raised when a key cannot be found in a dictionary.

MemoryError

Raised when your computer runs out of RAM.

NameError

Occurs when a local or global variable name cannot be found. Usually the result of a typo.

RuntimeError

Generic exception for when something, somewhere has gone wrong. The error message itself normally has more information.

SyntaxError

Raised when the program tries to run non-Python code. This is typically the result of a typo, such as a missing colon or closing bracket.

ZeroDivisionError

Occurs when Python has tried to divide by zero, and is not happy about it.

It is often tempting to create custom exceptions for specific cases. You’ll find more information on how to do this in Chapter 6. However, custom exception types are rarely necessary—99% of the time there is already a built-in error that covers the exceptional situation at hand. It is generally better to use message strings to customize existing error types rather than creating brand new ones.

Loops

While computers are not superb at synthesizing new tasks, they are very good at performing the same tasks over and over. So far in this chapter, we’ve been discussing the single execution of indented code blocks. Loops are how to execute the same block multiple times. Python has a few looping formats that are essential to know: while loops, for loops, and comprehensions.

while Loops

while loops are related to if statements because they continue to execute “while a condition is true.” They have nearly the same syntax, except the if is replaced with the while keyword. Thus, the syntax has the following format:

while <condition>:
    <while-block>

The condition here is evaluated right before every loop iteration. If the condition is or remains True, then the block is executed. If the condition is False, then the while block is skipped and the program continues. Here is a simple countdown timer:

Code Output
t = 3
while 0 < t:
    print("t-minus " + str(t))
    t = t - 1
print("blastoff!")
t-minus 3
t-minus 2
t-minus 1
blastoff!

If the condition evaluates to False, then the while block will never be entered. For example:

Code Output
while False:
    print("I am sorry, Dave.")
print("I can't print that for you.")
I can't print that for you.

On the other hand, if the condition always evaluates to True, the while block will continue to be executed no matter what. This is known as an infinite or nonterminating loop. Normally this is not the intended behavior. A slight modification to the countdown timer means it will never finish on its own:

Code Output
t = 3
while True:
    print("t-minus " + str(t))
    t = t - 1
print("blastoff!")
t-minus 3
t-minus 2
t-minus 1
t-minus 0
t-minus -1
t-minus -2
t-minus -3
t-minus -4
t-minus -5
...
# blastoff is never reached

Integers counting down to negative infinity is not correct behavior in most situations.

Note

Interestingly, it is impossible to predict whether a loop (or any program) will terminate without actually running it. This is known as the halting problem and was originally shown by Alan Turing. If you do happen to accidentally start an infinite loop, you can always hit Ctrl-c to exit the Python program.

The break statement is Python’s way of leaving a loop early. The keyword break simply appears on its own line, and the loop is immediately exited. Consider the following while loop, which computes successive elements of the Fibonacci series and adds them to the fib list. This loop will continue forever unless it finds an entry that is divisible by 12, at which point it will immediately leave the loop and not add the entry to the list:

Code Output
fib = [1, 1]
while True:
    x = fib[-2] + fib[-1]
    if x%12 == 0:
        break
    fib.append(x)
[1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89]

This loop does terminate, because 55 + 89 == 144 and 144 == 12**2. Also note that the if statement is part of the while block. This means that the break statement needs to be additionally indented. Additional levels of indentation allow for code blocks to be nested within one another. Nesting can be arbitrarily deep as long as the correct flow control is used.

for Loops

Though while loops are helpful for repeating statements, it is typically more useful to iterate over a container or other “iterable,” grabbing a single element each time through and exiting the loop when there are no more elements in the container. In Python, for loops fill this role. They use the for and in keywords and have the following syntax:

for <loop-var> in <iterable>:
    <for-block>

The <loop-var> is a variable name that is assigned to a new element of the iterable on each pass through the loop. The <iterable> is any Python object that can return elements. All containers (lists, tuples, sets, dictionaries) and strings are iterable. The for block is a series of statements whose execution is repeated. This is the same as what was seen for while blocks. Using a for loop, we could rewrite our countdown timer to loop over the list of integers [3, 2, 1] as follows:

for t in [3, 2, 1]:
    print("t-minus " + str(t))
print("blastoff!")

Again, the value of t changes on each iteration. Here, though, the t = t - 1 line is not needed because t is automatically reassigned to the next value in the list. Additionally, the 0 < t condition is not needed to stop the list; when there are no more elements in the list, the loop ends.

The break statement can be used with for loops just like with while loops. Additionally, the continue statement can be used with both for and while loops. This exits out of the current iteration of the loop only and continues on with the next iteration. It does not break out of the whole loop. Consider the case where we want to count down every t but want to skip reporting the even times:

Code Output
for t in [7, 6, 5, 4, 3, 2, 1]:
    if t%2 == 0:
        continue
    print("t-minus " + str(t))
print("blastoff!")
t-minus 7
t-minus 5
t-minus 3
t-minus 1
blastoff!

Note that containers choose how they are iterated over. For sequences (strings, lists, tuples), there is a natural iteration order. String iteration produces each letter in turn:

Code Output
for letter in "Gorgus":
    print(letter)
G
o
r
g
u
s

However, unordered data structures (sets, dictionaries) have an unpredictable iteration ordering. All elements are guaranteed to be iterated over, but when each element comes out is not predictable. The iteration order is not the order that the object was created with. The following is an example of set iteration:

Code Output
for x in {"Gorgus", 0, True}:
    print(x)
0
True
Gorgus

Dictionaries have further ambiguity in addition to being unordered. The loop variable could be the keys, the values, or both (the items). Python chooses to return the keys when looping over a dictionary. It is assumed that the values can be looked up normally. It is very common to use key or k as the loop variable name. For example:

Code Output
d = {"first": "Albert",
     "last": "Einstein",
     "birthday": [1879, 3, 14]}

for key in d:
    print(key)
    print(d[key])
    print("======")
birthday
[1879, 3, 14]
======
last
Einstein
======
first
Albert
======

Dictionaries may also be explicitly looped through their keys, values, or items using the keys(), values(), or items() methods:

Code Output
d = {"first": "Albert",
     "last": "Einstein",
     "birthday": [1879, 3, 14]}

print("Keys:")
for key in d.keys():
    print(key)

print("\n======\n")

print("Values:")
for value in d.values():
    print(value)

print("\n======\n")

print("Items:")
for key, value in d.items():
    print(key, value)
Keys:
birthday
last
first

======

Values:
[1879, 3, 14]
Einstein
Albert

======

Items:
('birthday', [1879, 3, 14])
('last', 'Einstein')
('first', 'Albert')

When iterating over items, the elements come back as key/value tuples. These can be unpacked into their own loop variables (called key and value here for consistency, though this is not mandatory). Alternatively, the items could remain packed, in which case the loop variable would still be a tuple:

Code Output
for item in d.items():
    print(item)
('birthday', [1879, 3, 14])
('last', 'Einstein')
('first', 'Albert')

It is a very strong idiom in Python that the loop variable name is a singular noun and the iterable is the corresponding plural noun. This makes the loop more natural to read. This pattern expressed in code is shown here:

for single in plural:
    ...

For example, looping through the set of quark names would be done as follows:

quarks = {'up', 'down', 'top', 'bottom', 'charm', 'strange'}
for quark in quarks:
    print(quark)

Comprehensions

for and while loops are fantastic, but they always take up at least two lines: one for the loop itself and another for the block. And often when you’re looping through a container the result of each loop iteration needs to be placed in a new corresponding list, set, dictionary, etc. This takes at least three lines. For example, converting the quarks set to a list of uppercase strings requires first setting up an empty list:

Code Output
upper_quarks = []
for quark in quarks:
    upper_quarks.append(quark.upper())
upper_quarks = ['BOTTOM', 'TOP',
               'UP', 'DOWN',
               'STRANGE', 'CHARM']

However, it seems as though this whole loop could be done in one line. This is because there is only one meaningful expression where work is performed: namely upper_quarks.append(quark.upper()). Enter comprehensions.

Comprehensions are a syntax for spelling out simple for loops in a single expression. List, set, and dictionary comprehensions exist, depending on the type of container that the expression should return. Since they are simple, the main limitation is that the for block may only be a single expression itself. The syntax for these is as follows:

# List comprehension
[<expr> for <loop-var> in <iterable>]

# Set comprehension
{<expr> for <loop-var> in <iterable>}

# Dictionary comprehension
{<key-expr>: <value-expr> for <loop-var> in <iterable>}

Note that these comprehensions retain as much of the original container syntax as possible. The list uses square brackets ([]), the set uses curly braces ({}), and the dictionary uses curly braces {} with keys and values separated by a colon (:). The upper_quarks loop in the previous example can be thus transformed into the following single line:

upper_quarks = [quark.upper() for quark in quarks]

Sometimes you might want to use a set comprehension instead of a list comprehension. This situation arises when the result should have unique entries but the expression may return duplicated values. For example, if users are allowed to enter data that you know ahead of time is categorical, then you can normalize the data inside of a set comprehension to find all unique entries. Consider that users might be asked to enter quark names, and lowercasing the entries will produce a common spelling. The following set comprehension will produce a set of just {'top', 'charm', 'strange'}, even though there are multiple spellings of the same quarks:

entries = ['top', 'CHARm', 'Top', 'sTraNGe', 'strangE', 'top']
quarks = {quark.lower() for quark in entries}

It is also sometimes useful to write dictionary comprehensions. This often comes up when you want to execute an expression over some data but also need to retain a mapping from the input to the result. For instance, suppose that we want to create a dictionary that maps numbers in an entries list to the results of x**2 + 42. This can be done with:

entries = [1, 10, 12.5, 65, 88]
results = {x: x**2 + 42 for x in entries}

Comprehensions may optionally include a filter. This is a conditional that comes after the iterable. If the condition evaluates to True, then the loop expression is evaluated and added to the list, set, or dictionary normally. If the condition is False, then the iteration is skipped. The syntax uses the if keyword, as follows:

# List comprehension with filter
[<expr> for <loop-var> in <iterable> if <condition>]

# Set comprehension with filter
{<expr> for <loop-var> in <iterable> if <condition>}

# Dictionary comprehension with filter
{<key-expr>: <value-expr> for <loop-var> in <iterable> if <condition>}

Thus, list comprehensions with a filter are effectively shorthand for the following code pattern:

new_list = []
for <loop-var> in <iterable>:
    if <condition>:
        new_list.append(<expr>)

Suppose you had a list of words, pm, that represented the entire text of Principia Mathematica by Isaac Newton and you wanted to find all of the words, in order, that started with the letter t. This operation could be performed in one line with the following list comprehension with a filter:

t_words = [word for word in pm if word.startswith('t')]

Alternatively, take the case where you want to compute the set of squares of Fibonacci numbers, but only where the Fibonacci number is divisible by five. Given a list of Fibonacci numbers fib, the desired set is computable via this set comprehension:

{x**2 for x in fib if x%5 == 0}

Lastly, dictionary comprehensions with filters are most often used to retain or remove items from another dictionary. This is often used when there also exists a set of “good” or “bad” keys. Suppose you have a dictionary that maps coordinate axes to indexes. From this dictionary, you only want to retain the polar coordinates. The corresponding dictionary comprehension would be implemented as follows:

coords = {'x': 1, 'y': 2, 'z': 3, 'r': 1, 'theta': 2, 'phi': 3}
polar_keys = {'r', 'theta', 'phi'}
polar = {key: value for key, value in coords.items() if key in polar_keys}

Comprehensions are incredibly powerful and expressive. The reasoning goes that if the operation cannot fit into a comprehension, then it should probably be split up into multiple lines in a normal for loop anyway. It is possible to nest comprehensions inside of one another, just like loops may be nested. However, this can become pretty convoluted to think about since two or more loops are on the same line. Python allows for simple looping situations to be dealt with simply, and encourages complex loops to be made readable.

Flow Control and Logic Wrap-up

Having reached the end of this chapter, you should be familiar with the following big ideas:

  • How to make decisions with if-else statements

  • Handling the worst situations with exceptions

  • Reusing code with loops

  • The for single in plural loop pattern

  • Using comprehensions to write concise loops

And now that you have seen the basics of decision making and code reuse, it is time to step those ideas up to the next level with functions in Chapter 5.

Get Effective Computation in Physics now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.