Lecture 14

Review

  1. What is a parameter? What is an argument?
  2. What is the result of the program below? def print_msg(msg):
        print('a')
        print(msg)

    print('b')
    print_msg('c')
    print('d')
    print_msg('e')
  3. What is the result of the program below? def print_msg2(msg):
        print('a')
        print(msg)
        return 'x'

    print('b')
    print(print_msg('c'))
    print('d')
    print_msg('e')

Functions (continued)

Functional Decomposition

Functional decomposition, also called functional abstraction, is the breaking down of a problem into smaller parts. Each part becomes a function. Functions should solve a specific problem -- this makes them more versatile. Ideally, you would decide on what functions to create before you write your program. Of course, sometimes it isn't until you're writing your program that you realize a better way to write the program, so sometimes you add functions as you're writing your program.

Good guidelines for deciding when to create functions are:

For the following programming prompt, what functions do you think would be useful?

Non-function program: sales_amounts.py

Sample data: Sales_Amounts.txt

Who is "The User"?

So far this semester, all code we wrote interacted with "the user", the person running our program. Now, "the user" may be another programmer. Just like we have been using code written by others (e.g. print, int, range), other programmers might use code that we write in their own programs. This means that we need to keep two things in mind:

  1. "The user" (i.e. the programmer using our functions, classes, and modules) may try doing the wrong thing with our code. We should do our best to still validate input to our code.
  2. We should tell "the user" how to use our code (functions, classes, and modules).

The problems with the example functions above stem from not doing those two things. In the next two sections, we will look at how to address the above two issues.

Raising Exceptions and Exception Propagation

Sometimes, the programmer calling our function passes in the wrong information. It could be wrong because:

In these cases, you need to alert the user of your function that bad information was given to the function. Since the user of your function may be another programmer and not the person running the program, you cannot just print an error to the screen. For example, this is the wrong way to alert the "user" that the wrong value was given:

def factorial(num):
    if num < 0:
        print('Bad value given')
        return
    
    product = 1
    for i in range(1, num+1):
        product *= i
    return product

It's primarily wrong because you're alerting the wrong person that num was wrong. You're alerting the person running the program, but in most cases the person running the program isn't capable of fixing the problem. A secondary reason this is wrong is because the function returns None on bad input and a number on good input. While this might not be a bad thing, it's possible for a sloppy programmer to not check whether factorial returned None.

The prefered way to deal with bad input to a function is with exceptions. You can raise an exception on bad input to a function by using the raise statement. The raise statement raises an exception. We've already seen how to catch these exceptions, now we're seeing how to raise (also called "throw") them.

def factorial(num):
    if num < 0:
        raise ValueError('The number must be positive')
    
    product = 1
    for i in range(1, num+1):
        product *= i
    return product

Now, if a negative value is passed into factorial, an exception is raised instead of None being returned. When an exception is raised, the function stops where it is and Python starts looking for the exception handling environment to catch this exception. The exception will propagate back up to the calling function and if there is no exception handling environment to catch it, it'll go to that function's calling function. This will continue until either an exception handling environment is found, or it reaches the top and the program crashes. Here's an example:

Code Output
def factorial(num):
    if num < 0:
        raise ValueError('The number must be positive')
    
    product = 1
    for i in range(1, num+1):
        product *= i
    return product

def user_input_factorial():
    val = int(input("Enter a positive integer: "))
    try:
        print("That number's factorial is:", factorial(val))
    except ValueError as ve:
        print(ve)

user_input_factorial()
user_input_factorial()
Enter a positive integer: 5
That number's factorial is: 120
Enter a positive integer: -1
The number must be positive
Code Output
def factorial(num):
    if num < 0:
        raise ValueError('The number must be positive')
    
    product = 1
    for i in range(1, num+1):
        product *= i
    return product

def get_user_factorial():
    val = int(input("Enter a positive integer: "))
    fact = factorial(val)
    print('Got the factorial')
    return fact

def print_user_factorial():
    user_fact = get_user_factorial()

    try
        print("The factorial of that number is:", user_fact)
    except ValueError:
        print('Error with computing factorial')

print_user_factorial()
print_user_factorial()
Enter a positive integer: 5
Got the factorial
The factorial of that number is: 120
Enter a positive integer: -1
Error with computing factorial

What's wrong with returning None on bad input? Why bother with exception handling environments? Exceptions allow us to do more than just indicate whether the function worked correctly or not. Our factorial function actually has two different kinds of bad arguments. The first is negative numbers, which are handled already. The second is that it only works on integers. If the programmer using our function tries to pass in a string or a float, the function will fail. To fix our problem, we need to do another kind of check at the start of the function. We need to check whether the function was given an integer. To do that, we can use the isinstance function. It takes a value and a data type and returns True if the value is an instance of that data type. For example:

>>> isinstance(15, int)
True
>>> isinstance(15, str)
False
>>> x = 15
>>> isinstance(x, float)
False

You can pass in a list/tuple of data types if you'd like. Maybe you're just curious if it's a number or not. You can easily do that with:

>>> isinstance(15, (int, float))
True
>>> isinstance('cat', (int, float))
False

In a function, we can use it to check whether the correct data type was given. For our factorial function, it only works on positive integers, so we must check whether an integer is given (we're already checking whether it's positive:

def factorial(num):
    if not isinstance(num, int):
        raise TypeError('The factorial is only defined for positive integers')
    if num < 0:
        raise ValueError('The number must be positive')
    
    product = 1
    for i in range(1, num+1):
        product *= i
    return product

Does it matter which check comes first? Can we check whether it's positive before we check whether it's an integer?

How do you know which kind of error to raise? Python provides a lot (and we can even create our own). For now, we'll primarily be using these errors:

The complete list of built-in exceptions can be found in Python's documentation.

<< Previous Notes Daily Schedule Next Notes >>