Lesson 8. Logic and Control

2023-01-23

Truth Value

So far in these lessons we have seen a variety of data types: int, float, complex, list, array and string. The bool type can take only two possible values, True and False, and is useful whenever you want to execute a group of statements selectively, based on whether or not a condition is true.

Objectives

The aim of this lesson is to learn the basic logical operations that Python provides for bool variables, and use them in some simple control flow constructions. By the end of the lesson, you should know how to


Relations

The table below list the commonly-used relational operators in Python.

Relation Description Example
< less than 2 < 3 is True
<= less than for equal to 2 <= 3 is True
> greater than 2 > 3 is False
>= greater than or equal to 2 >= 3 is False
== equal to 2 == 3 is False
!= not equal to 2 != 3 is True

If a and b are arrays of the same shape, then the relational operators act elementwise. Thus,

a = array([[3,  7],
           [2, -8]])
b = array([[-6, 0],
           [ 4, 9]])
print(a > b)

produces

[[ True  True]
 [False False]]

You can also compare an array to a scalar:

print(a > 2)

produces

[[ True  True]
 [False False]]

Logical Operations

The following truth table defines the Boolean operator and, following the usual rules of logic.

A B A and B
True True True
True False False
False True False
False False False

Likewise, the operator or has its usual meaning.

A B A or B
True True True
True False True
False True True
False False False

Finally, the unary Boolean operator not is defined in the obvious way.

A not A
True False
False True

We can use these operators in combination with the comparison operators to build up logical expressions. The comparison operators have the highest priority, followed by not, with and and or having the lowest priority. The expressions are evaluated from left to right. In practice, it is best to use parentheses to make the meaning clear.

Exercise. Work out the truth value for each of the following expressions, and check your answers by typing them in the IPython shell. (Remember that m % n is the remainder when m is divided by n.)

(7 < 5) or not(6 > 2)
(2 > 1) and ( 16 % 2 == 0 )
( 1 + 1 == 2 ) and ( not( 9 % 4 == 1 ) or ( e < pi ) )

Both and and or are short-circuit operators, that is, the right operand is never evaluated if the truth value can be determined from only the left operand. Thus, if ‘A’ is False then ‘A and B’ must be False, regardless of whether ‘B’ is True or False, so there is no point in evaluating ‘B’. Similarly, if ‘A’ is True, then ‘A or B’ must be True regardless of whether ‘B’ is True or False, so again there is no point in evaluating ‘B’. For example, following the statements

x = -2
y = nan
(x > 0) and (y := log(x))

the value of y will be nan. Indeed, y will be nan if x has been assigned any value less than or equal to zero, whereas y will be log(x) if x has been assigned a positive value. Notice that we have to use the walrus operator := if an assignment is part of a larger expression.

If-Construct

In Python, the basic if-construct has the form

if <condition>:
    <statements>

where <condition> is a logical expression and <statements> consists of one or more lines of code, all indented by the same number of spaces. If <condition> evaluates to True, then the statements are executed. Otherwise, i.e., if <condition> evaluates to False, the statements are skipped and the thread of control passes to the next (unindented) line of code. Note that a colon is necessary following <condition>.

A longer form of the construct is

if <condition1>:
    <statements1>
elif <condition2>:
    <statements2>
else:
    <statements3>

In this case,

Multiple elif clauses are also permitted. Recall the construction involving the walrus operator := at the end of the last section: the same effect can be achieved by

if x > 0:
    y = log(x)
else:
    y = nan

In Lesson 4, we defined a function solve_quadratic. By using if-constructs, we can improve this function so that it handles the case when the discriminant turns out to be negative, leading to complex conjugate roots.

def solve_quadratic(a, b, c):
    """Solve the quadratic equation ax^2 + bx + c = 0.

    Assumes that the coefficients a, b, c are all real, 
    and that a is not zero."""
    dscr = b**2 - 4*a*c
    if dscr > 0: # Distinct real roots
        sqrt_dscr = sqrt(dscr)
        x_plus  = ( -b + sqrt_dscr ) / ( 2 * a )
        x_minus = ( -b - sqrt_dscr ) / ( 2 * a )
    elif dscr < 0: # Complex conjugate roots
        sqrt_abs_dscr = sqrt(-dscr)
        x_plus  = complex(-b,  sqrt_abs_dscr) / ( 2 * a )
        x_minus = complex(-b, -sqrt_abs_dscr) / ( 2 * a )
    else: # Equal real roots
        x_plus = x_minus = -b / ( 2 * a )
    return x_plus, x_minus

Notice that we have inserted comments in the source code to help document the code. The Python interpreter will ignore any text you insert following a ‘#’ character, up to the end of the current line.

Exercise. If \(|4ac|\) is very small compared to \(b^2\), then dscr will be approximately equal to \(|b|\), and so one of the two real roots can suffer from loss of precision due to cancellation of leading digits. How could you improve the code to avoid this problem? Hint: the product of the roots equals \(c/a\).

Exercise. The solve_quadratic function assumes that a is not 0, otherwise we do not have a quadratic equation but just a linear equation \(bx+c=0\) which has only one root \(x=-c/b\) (assuming \(b\) is not zero). Can you think of any other problematic cases? The usual way to handle an ‘unreasonable’ value of a function argument is to raise a ValueError. The syntax is as follows.

raise ValueError('Leading coefficient must be non-zero.')

For-Loop

In Python, a for-loop has the form

for k in <iterable>:
    <statements(k)>

Here, k is called the loop variable and <statments(k)> consists of one or more lines of code, each indented by the same amount and possibly depending on k. These statements are called the loop body. If <iterable> is a list, then <statements(k)> is executed with k replaced by each item of the list in order. For example,

for k in ['Verity', 'Gavin', 'Eunice']:
    print(k)

produces

Verity
Gavin
Eunice

Instead of a list, we can use an array.

for k in arange(4):
   print(f'The square of {k} is {k**2}')

produces

The square of 0 is 0
The square of 1 is 1
The square of 2 is 4
The square of 3 is 9

Python also provides a function range that has the same effect as arange in the for-loop but generates the values of the loop variable on-the-fly without creating a list or array. In this way, range(n) uses much less memory than arange(n) if n is large.

When the loop variable is a number, it is often called the loop counter.

Often, a for-loop can be replaced with a NumPy operation. For example, suppose that we wanted to sum \(1/k^2\) for \(k\) from \(1\) to \(1000\). We could do

s = 0.0
for k in range(1, 1001):
    s += 1 / k**2
print(f'The value of the sum is {s}')

or alternatively, using the sum method of an array,

terms = 1 / arange(1, 1001)**2
print(f'The value of the sum is {terms.sum()}')

Because the NumPy calculations are performed using native CPU instructions they will likely be one to two orders of magnitude faster than the Python for-loop, at the expense of the extra computer memory needed to store the vector terms.


Summary

In this lesson, we have seen how to

Further Reading

For a more complete treatment of the topics in this lesson, see the section 4. More Control Flow Tools from the Tutorial in the official Python documentation.

Back to All Lessons