CMSC 14100 — Lecture 5

There are different kinds of errors:

What is the error in our program? Here, our definition is incorrect—that is, our understanding of the problem was incorrect! In fact, leap years have more criteria than divisibility by 4. There is the additional constraint that the year is not divisible by 100 unless it is divisible by 400. Here are two possible ways to express this.


>>> year = 2025
>>> (year % 4 == 0 and not year % 100 == 0) or (year % 400 == 0)
False
>>> (year % 4 == 0) and (not year % 100 == 0 or year % 400 == 0)
False
    

Both of these expressions will give the same result, but they exhibit different short-circuting behaviour. For example, in the first expression, we test year % 4 == 0. If this is False, then we must still test year % 400 == 0. But it can't be the case that a number is not divisible by 4 while still being divisible by 400.

If we reorganize the expression slightly, as in the second expression, we can immediately stop evaluation of the expression once we've determined our year is not divisible by 4. This gives us the following final, correct definition of our function.


def is_leap_year(year):
    """
    Determines whether a given year, as an integer, is a leap year. 
    A year is a leap year if the year is divisible by 4 and not 
    divisible by 100 unless it is divisible by 400.

    Input:
        year (int): a year (A.D.)

    Output (bool): True if year is a leap year and False otherwise 

    Examples:
        >>> is_leap_year(2024)
        True
        >>> is_leap_year(2025)
        False
    """
    return (year % 4 == 0) and (not year % 100 != 0 or year % 400 == 0)
    

This definition is the reason why the Y2K bug was a big deal. At the end of the 20th century, it was common to use the last two digits of the year only to specify a year. This is still done and is not usually a problem—the context is usually pretty easy to figure out.

The problem was with leap years: if a computer system assumed that a two digit year was a year in the 1900s, then 00 referred to 1900. Ordinarily, this wouldn't matter except that 1900 was not a leap year but 2000 was. So the date 00-02-29 was valid for the year 2000 but not for the year 1900.

Control flow

What happens when a function is called? Recall our discussions about expressions: whenever a statement with an expression is executed, the expression will be evaluated. In the same way, for any statement that includes a function call, the function will be evaluated. This means that the control flow will change to evaluate the function in the exeuction of the statement.

Put the following code into a file leap.py and try running python3 leap.py.


def is_leap_year(year):
    """
    Determines whether a given year, as an integer, is a leap year. 
    A leap year if the year is divisible by 4.

    Input:
        year (int): a year (A.D.)

    Output (bool): True if year is a leap year and False otherwise 

    Examples:
        >>> is_leap_year(2024)
        True
        >>> is_leap_year(2025)
        False
    """
    print("    Start of is_leap_year")
    is_leap = (year % 4 == 0) and (not year % 100 == 0 or year % 400 == 0)
    print("    End of is_leap_year")
    return is_leap 

# All of this is not part of the function because of the indentation
y = 2025
print("Calling is_leap_year(y)...")
z = is_leap_year(y)
print("Returned from is_leap_year(y)...")
print("Value of z is", z)
    

We added print statements that will leave a trail of where the flow of control is. Also observe that they do not affect the result of the function—only the value specified by return is considered the output of the function. Running this from the command line, we get


% python3 leap.py              
Calling is_leap_year(y)...
    Start of is_leap_year
    End of is_leap_year
Returned from is_leap_year(y)...
Value of z is False
    

In the imperative programming paradigm, a program is a series of statements (imperatives!) that are executed in order. These are different from expressions—recall that expressions are evaluated into a value, while statements are instructions to the computer that. Some examples of statements we've seen are assignment statements (store a value in memory) and function calls (namely print, which displays a value).

So far, our programs have simply been a series of statements which are executed one after the other. We have been able to use such statements to manage data, but we don't yet have any mechanism to make decisions about which instructions to execute based on that information. We will now introduce control structures which are used to alter the control flow of the program, allowing us to skip or repeat statements.

Conditional statements

Conditional statements allow the program to execute different actions based on a condition, expressed as a boolean expression. For example, a program may behave differently depending on whether a particular value is even. We already know how to express this:


    n % 2 == 0
    

This expression evaluates to True if n is even and it evaluates to False if n is odd.

We can define a function to facilitate reuse and readability.


    def is_even(n):
        """
        Is n an even number?

        Input:
            n (int): number
        
        Output (bool): True if n is even, False otherwise.
        """
        return n % 2 == 0
    

We can use this function as follows.


    >>> is_even(315)
    False
    >>> is_even(2434)
    True
    

Now, we can use this as a condition to test elsewhere.

The first structure that we use is the conditional statement, which allows us to test a condition. If the condition is satisfied, we execute the indicated code. Otherwise, we do not. The template for a basic conditional statement is


    if <boolean expression>:
        <statements>
    

This statement can be read as "if the condition holds (i.e. it is true), then perform this set of actions, encoded by the given statements. As with the body of a function definition, the statements to be executed are indicated only by indentation.


    >>> n = 4
    >>> if is_even(n):
    ...    print(n, "is even")
    ... print("This always gets printed")
    4 is even
    'This always gets printed'
    >>> n = 57
    >>> if is_even(n):
    ...    print(n, "is even")
    ... print("This always gets printed")
    'This always gets printed'
    

The if statement is derived from the implication connective in propositional logic, $p \rightarrow q$. We can think of this as saying that if $p$ is true, then $q$ must also be true. In our case, $p$ is our condition that's being tested, expressed as a boolean expression, and $q$ can be thought of as the guarantee that the block of statements in that follow are executed.

Now, what if we wanted to execute some statements in the case that our condition wasn't satisfied? One could imagine that we could write something like


    def describe_parity(n):
        """
        Produce a message that says whether n is odd or even.

        Input:
            n (int): number

        Output (str): Statement of whether n is odd or even
        """
        if is_even(n):
            msg = " is even"
        if not n % 2 == 0: 
            msg = " is odd"
        return str(n) + msg
    

However, this is a common pattern, so there is a special statement for this.


    if <boolean expression>:
        <statements>
    else:
        <statements>
    

This statement can be read as "if the condition holds (i.e. it is true), then perform this set of actions; otherwise (i.e. or else), perform that set of actions".

In the original formulation with two separate if statements, we needed to test and evaluate two conditions. But these are really the same condition: it's just that we want one case to be true and the other to be false. The if-else statement tests the condition once and executes a branch depending on whether or not the condition was satisfied.

Then we can write the following,


    def describe_parity(n):
        """
        Produce a message that says whether n is odd or even.

        Input:
            n (int): number

        Output (str): Statement of whether n is odd or even
        """
        if is_even(n):
            msg = " is even"
        else:
            msg = " is odd"
        return str(n) + msg
    

The benefit of this is that it's clear that we have two mutually exclusive cases, based on the test of one condition.

Now, notice that within a branch, multiple statements can be executed. Since conditional statements are statements, this means we can put another series of conditionals inside of a conditional.

Often times, we will want to test for a more complex condition that can have multiple outcomes. For instance, suppose in addition to testing the parity of a number, we also want to determine whether it is negative or not. We can write


    def describe_number(n):
        """
        Produce a message that says something about n

        Input:
            n (int): number

        Output (str): Statement of about n.
        """
        if n < 0:
            msg = " is negative"
        else:
            if is_even(n):
                msg = " is nonnegative and even"
            else:
                msg = " is positive and odd"
        return str(n) + msg
    

Let's consider the conditions that allow us to enter each branch of this statement.

While this works, and it is useful to organize cases like this, if we have too many levels of nested conditional statements, it can be difficult to follow. If we know that we have mutually exclusive cases, it is often preferred to use elif to list all possible cases on one level.


    if n < 0:
        msg = " is negative"
    elif is_even(n):
        msg = " is nonnegative and even"
    else:
        msg = " is positive and odd"
    

This is a bit of a tradeoff: we need to know the combinations of conditions for each branch beforehand to correctly set this series of conditionals up.

It's important to keep in mind that only one case of a conditional statement is ever executed. A common error is to create a separate conditional statement for each case, such as in the following.


    if n < 0:
        msg = " is negative"
    if is_even(n):
        msg = " is nonnegative and even"
    else:
        msg = " is positive and odd"
    

In this case, we will never see the "is negative" message because msg will always be assigned another message in the following conditional statement, which tests for evenness.