In Python, we structure programs as a sequence of statements.
When you execute a Python program, the interpreter executes each statement until there are no statements to execute or it encounter an error while executing an instruction. We can test this out by writing a simple python script.

print("This statement will be executed")
(1,2).append(1)
print("This statement will not be executed")
file1.py

We can execute the script using the command python3 file1.py.

Guess what happens when we execute the script?

Python encounters the following error while executing the script.

In the above code listing, Python executes the first print statement and encounters an error while executing the second.

Why do you think that is the case?

In the second statement, we try to append a number to a tuple(). Tuples don't have a method to append an element, and therefore Python raises AttributeError.

What about the third statement? Do you think it is going to be executed?

As I mentioned before, Python executes a program or script as a series of statements. If it encounters an error at a statement, the next statements are not executed by Python.

When executing the script file1.py, we will get the following output.

This statement will be executed
Traceback (most recent call last):
  File "file1.py", line 2, in <module>
    (1,2).append(1)
AttributeError: 'tuple' object has no attribute 'append'
Output file1.py output

In the above code listing, Python executes the first print() function and encounters an error while executing the second. Therefore, it skips the third one.

So far, we saw that each statement occupied a single line. We can write multiple statements in a single line by separating them with a ; semicolon. For instance, a = 10; b = 10

A statement can be written in a separate line or written over multiple lines in a Python program. For example, in the following script, we create a list containing days spanning multiple lines.

days_names = [ "Monday", "Tuesday",
              "Wednesday", "Thursday",
              "Friday", "Saturday",
              "Sunday"]

Even though the script's statements above span multiple lines, Python considers the assignment operation a single line or specifically a single logical line. The multiple lines you and I see in the above code are called physical lines.

How many logical lines and physical lines are present in the code below?

>>> person = {
    "name": "Luffy", "friends": ["Zorro", "Sanji"],
    "position": "Captain"
}
  1. Logical Lines: 4, Physical Lines: 4
  2. Logical Lines: 4, Physical Lines: 1
  3. Logical Lines: 1, Physical Lines: 4
  4. Logical Lines: 1, Physical Lines: 1

Let's understand more about how Python interprets the program using logical lines in the next section.

Logical Lines

Python interpreter breaks down the script or program into a stream of tokens, and this process is called tokenizing. Using the tokens generated, Python breaks down the program in different logical lines. A logical line is what Python sees as a single statement. For instance,

i = 10
print(i)

In the above code, we can see two statements spread over two lines. Each statement above is a logical line. However, we can also write the two statements above in a single line.

i = 10; print(i);

In this case, there are two statements in a single line, but Python sees two logical lines. The line which you see when you write the program is called a physical line.

Fig 1: Four physical lines and one logical line

A physical line is a sequence of characters terminated by an End-of-Line (EOL) sequence. End-of-Line (EOL) sequence moves the cursor both down to the next line and to the beginning of that line. Earlier, when we wanted to print strings spanning multiple lines, we used the sequence \n to do so.

>>> print("This spans \nthree lines. \nHurray ")
This spans
three lines.
Hurray

As we mentioned earlier, the Python interpreter converts the script into stream of tokens. In the generated tokens, Python denotes the end of a logical using token NEWLINE.

Let's write a program to understand this more.

animal = [ "cat",
			"dog"]
print("My favorite animal is a {}".format(animal[1]))
file2.py

The output of the script file2.py is shown below.

My favorite animal is a dog.
Output of file2.py

How many logical and physical lines are in the script file2.py?

animal = [ "cat",
			"dog"]
print("My pet animal is a {}".format(animal[1]))
  1. Logical lines: 1, Physical lines: 3
  2. Logical lines: 2, Physical lines: 3
  3. Logical lines: 3, Physical lines: 3
  4. Logical lines: 1, Physical lines: 1

We can also confirm that the above exercise is correct by verifying the tokens generated. Python provides a way to check the tokens generated by a source program using the tokenize module.

You can directly run the tokenize module on a python script from the command prompt using the -m flag, as shown below.

python -m tokenize [-e] [filename.py]

It is tokenizing the source program of file2.py results in the following output.

0,0-0,0:            ENCODING       'utf-8'
1,0-1,6:            NAME           'animal'
1,7-1,8:            EQUAL          '='
1,9-1,10:           LSQB           '['
1,11-1,16:          STRING         '"cat"'
1,16-1,17:          COMMA          ','
1,17-1,18:          NL             '\n'
2,3-2,8:            STRING         '"dog"'
2,8-2,9:            RSQB           ']'
2,10-2,11:          NEWLINE        '\n'
3,0-3,5:            NAME           'print'
3,5-3,6:            LPAR           '('
3,6-3,32:           STRING         '"My favorite animal is {}"'
3,32-3,33:          DOT            '.'
3,33-3,39:          NAME           'format'
3,39-3,40:          LPAR           '('
3,40-3,46:          NAME           'animal'
3,46-3,47:          LSQB           '['
3,47-3,48:          NUMBER         '1'
3,48-3,49:          RSQB           ']'
3,49-3,50:          RPAR           ')'
3,50-3,51:          RPAR           ')'
3,51-3,52:          NEWLINE        '\n'
4,0-4,0:            ENDMARKER      ''
Tokens generated from file2.py

In the output, the first column represents the line and column number of the token. The second column provides the token name. At the same time, the last shows the value of the token.

In the tokens, we can see that there are only two NEWLINE tokens even though there are three lines in the source code. The token NL is used to indicate a non-terminating newline.

Python generates NL tokens when a logical line of code spans over multiple physical lines.

Python considers the assignment statement to the name animal that spans two physical lines as a single logical line. The NL token type represents newline characters (\n or \r\n) that do not end logical code lines. Newlines that do end logical lines of Python code are indicated using the NEWLINE token.

Python encourages the use of a single statement per line, which makes the code more readable. Therefore, write a single logical line in a single physical line.

If you want to specify more than one logical line on a single physical line, you have to specify this using a semicolon explicitly (;) , indicating the end of a logical line/statement.

Note: The token generated by ; semicolon is OP.

Earlier, we wrote two logical lines on a single physical line using a semicolon (;), indicating the end of a logical line/statement. Python implicitly assumes that each physical line is a logical line unless we use a semicolon (;).

Use more than a single physical line only if the logical line is long.

Avoid semicolon (;)

Python generates all the logical lines from one or more physical lines.

a = [1,
    2]
b = [3, 4]
c = b + c

How many NL tokens are generated from the script below?

a = [1,
    2]
b = [3, 4]
c = b + c
  1. 1
  2. 2
  3. 3
  4. 4

Often two or more physical lines are joined together to form a logical. Let's see understand how line-joining works in Python.

Explicit Line Joining

We can join two or more physical lines into a logical line using backslash characters (\), also called the line continuation character. For instance, we can write the statement 45 + 5 + 50 over multiple lines in the following way.

>>> 45 \
... + 5 \
... + 50
100

When a physical line ends with a backslash, which is not a part of a string literal or comment, Python joins the line to form a single logical statement. Joining two or more physical lines using the line continuation character is called explicit line joining. We are explicitly telling Python to join these lines together. In explicit line joining, you cannot add comments after the backslash character (\) .

For instance, this code expression is valid.

>>> a = 45 \
... + 5
>>> a
50

While the following code is invalid,

>>> a = 45 \	# naming 45 + 5 as `a`
... + 5
 File "<stdin>", line 1
    a = 45 \	# naming 45 + 5 as `a`
                                  ^
SyntaxError: unexpected character after line continuation character

Adding anything after the backslash character is prohibited in Python.

Will this piece of code correctly execute in Python?

>>> list_of_countries = ["India", "Bhutan" ] + \ ["Sri Lanka"] \
... + ["Japan", "Russia"]
  1. Yes
  2. No

Python raises SyntaxError when if you put character(s) after the line continuation character. The other form of lines joining in Python is done implicitly, meaning in a way that is not directly expressed. Let's have a look.

Implicit Line Joining

Python allows the splitting of certain expressions over multiple physical lines. These expressions don't require a backslash character (\) at the end of their line to form a single logical line. The following are such expressions:

  • expressions within a parenthesis (), brackets [] or braces {}
  • function arguments
  • triple-quoted string literal

For instance.

>>> (45 +								# Parenthesis
... 5
... + 50)
100
>>> [40, 								# Brackets
... 50,
... 60,
... 70]
[40, 50, 60, 70]
>>> {									# Braces
... "name" : "John Doe",
... "age": 26
... }
{'name': 'John Doe', 'age': 26}
>>> """This string
... spans
... multiple
... lines"""
'This string\nspans \nmultiple \nlines'# Triple-quoted literals

In any of the above formats, expressions can span over multiple physical lines. Python joins multiples physical lines to a single logical line. The automatic joining of numerous physical lines by Python is called implicit line joining.

Essential characteristics of the implicitly joined lines are as follows:

  • can carry comments, except for triple-quoted strings
  • indentation of continuation lines is not important
  • blank lines are allowed

The ability to add comments is really useful sometimes to increase code-readability.

>>> guests = [
    "Tyrion", # Imp
    "Harry",  # Wizard
	"Luffy",  # Pirate
]
>>> guests
['Tyrion', 'Harry', 'Luffy']

Adding comments to function arguments is useful to understand the high-level overview of the function quickly.

>>> def my_func(arg1, # A Comment
...             arg2, # Another Comment
...			    arg3  # Yet another comment
...                ):
...		pass
As you can see, we can add comments to the function arguments. How do you think it's helpful in programming?

Adding comments to function arguments is useful to understand the high-level overview of the function quickly.

Earlier, we mentioned the Python executes every statement in order until

  • either there are no more statements to execute
  • or Python encounters an error while executing a statement

Apart from that, there are particular statement(s) or code block whose execution is dependent on some conditions. When Python executes a code depending on some condition, it is called conditional execution.

We can control the usual order of execution of statements by using some particular Python keywords. In Python, statements using the keywords if-else, while, and for implement control flow constructs. Let's learn more control-flow statements in the next section.

Control Flow

In programming, control flow refers to the order of execution of statements in a program. Most programming languages allow you to control the flow of executions of statements.

What is the default way in which Python executes statements?

Python executes one statement after another from top to bottom without skipping any statements. Two important methods of controlling the flow of execution are conditionals execution or loops. Let's take a look at conditional execution.

Conditional Execution

When we instruct Python to execute a block or piece of code only when it satisfies a condition, it is called conditional execution.

Conditional execution is implemented in Python using if-else statements. In-fact, most programming languages contain if-else statements. The keywords, if, else, and elif control code execution flow.

The general syntax of if-else statements or simply if statements is the following:

if condition1:
    # Execute this code block
elif condition2:
    # Execute this code block
elif condition3:
    # Execute this code block
else:
    # If none of the above conditions are true
    # Execute this code block

In the above code, Python evaluates the conditions one by one until it finds a True condition. Python executes the code block corresponding to that condition. If none of the conditions are True, Python executes the code block inside the else statement.

Figure 2: if-else conditional

Figure 2 shows a diagram representing the flow of execution of statements in Python.

The elif and else statements are optional.

Both if and elif keyword requires conditions to check if they can execute their code block. The conditions are usually in the form of Boolean expressions, which means the expressions upon evaluation will either return True or False.

For instance,

>>> a = 5
>>> if a < 6:
...     print("a is less than 6")
...
a is less than 6

In the above code listing, the expression a < 6 is the Boolean expression provided as a condition for the if statement.

What do you think is the output when the following script is run?

text = "The quick brown fox."

if 'a' in text:
	(1, 2).append(3)
	print("The letter a is present in text")
elif 'b' in text:
	print("The letter b is present in text")
elif 'c' in text:
	(1, 2).append(3)
	print("The letter c is present in text")
else:
	(1, 2).append(3)
	print("No result Found")
  1. The letter a is present in the text
  2. The letter b is present in the text
  3. No result Found
  4. Raises AttributeError

The code in the previous exercise outputs The letter a is present in text.

Do you notice anything interesting in the last exercise?

Appending an integer to a tuple should have caused Python to raise AttributeError. However, Python evaluates an if-elif code block only when the condition evaluates to True. Python executes the code block in the else statement if the conditions provided to all the other if-else statements are False.

Therefore, Python doesn't raise any errors while executing the script above.

We mentioned earlier that we could test any object in Python for its truth value. You can put any object as a condition for if-else statements. When you place an object as a condition for if-else statements, Python evaluates it to be the same as using the built-in bool(<obj>) function on the object.

>>> b = [10, 20]
>>> if b:					# Condition evaluates to bool(b)
...     print(b[0])
...
10

The following code block demonstrates using a dictionary key as a condition for the if statement.

>>> customer = {"name": "John", "member": True}
>>> if customer["member"]:
...     print("{} is a premium member".format(customer["name"]))
...
John is a premium member

What's the output of the following code?

>>> a = []
>>> if len(a):
...     print("We can use objects as conditions")
... else:
...     print("This shouldn't be printed")
...
  1. This shouldn't be printed
  2. Raises Error.
  3. We can use objects as conditions
  4. Doesn't print anything

To solve the previous exercise, we will recall that Python evaluates 0 as False.

Do you recall some other objects that Python evaluates as False?

Python evaluates empty containers such as sets, dictionaries, and lists as False. So far, we have seen a single condition for an if-else statement.

How can we have a code block evaluated only when two or more conditions evaluate True simultaneously?

We can chain multiple conditions using logical operators to create complicated condition tests and control flow. We can also nest if-else statements within one another. Let's take an example to understand more.

In mathematics, the triangle inequality theorem states that a triangle is valid if the sum of two sides is higher than the third side.

$$
\text{For a triangle with sides a, b and c to exists, the following conditions must be met.} \\
a + b > c \\
c + a > b \\
b + c > a \\
\text{where a, b, and c are non-negative integers}
$$

Let's write a function that checks if three sides can be a side of a triangle. It accepts sides of a triangle as three positional arguments a,b and c and returns whether these line-segments can form a triangle or not.

>>> def check_sides(a, b, c):
    	# Checks if sides are non-zero negative integers
        if all((a > 0, b > 0, c > 0)):
            # Triangle Inequality Theorem
            if a + b > c and a + c > b and c + b > a:
                print("{}, {} and {} can form a triangle"
                      .format(a, b, c))
            else:
                print("{}, {} and {} cannot form a triangle"
                      .format(a, b, c))
        else:
            print("Sides of a triangle cannot be negative or zero")

In the above code listing, we can see that the two if statements are nested. The outer if statement checks if all the sides are greater than 0. Simultaneously, the inner if statement checks if the sum of each of the two sides is greater than the third side.

Let's test our newly created function.

>>> check_sides(3, 4, 5)
3, 4 and 5 can form a triangle
>>> check_sides(10, 20, 300)
10, 20 and 300 cannot form a triangle
>>> check_sides(10, -20, 300)
Sides of a triangle cannot be negative or zero

Our function check_sides() is working as expected.

What's the output of the following script?

a, b = (), ()
c, d = [], []
if a is b:
	if c is d:
		d.append(1)
	else:
		d.append(2)
else:
	d.append(3)
print(d)
  1. [1, 2]
  2. [3]
  3. [2]
  4. []

Ternary expression

There is a short form of if statement to write a conditional expression in a single statement. It's called Ternary expression.

Ternary expression in Python is equivalent to ternary operation found in the C programming language. The following is the syntax of the compact if statement, also known as ternary expression:

[on_true] if [boolean_expression] else [on_false]

The ternary form requires

  • a boolean_expression
  • on_true value which is returned if the Boolean expression returns True
  • on_false value which is returned if the Boolean expression returns False

For example.

>>> a, b, = 10, 30
>>> smaller = a if a < b else b			# 10

Here,

  • on_true value : a
  • on_false value : b
  • boolean_expression : a < b

Unlike the earlier if-else statements, we cannot use the elif keyword cannot. You can use the ternary if statement to execute simple one-line expressions conditionally.

We can chain a couple of ternary if statements to create the same effect as nested if statements.

>>> a, b, c = 4, 2, 5
# Returns the largest of all three
>>> a if a > b and a > c else b if b > c else c
5
However, for your sanity and others, avoid using multiple ternary expressions in the same line.

What's the output of the following script?

if 1 if 1 < 2 else 2 :
	print("Even this works")
else:
	print("Not really")
  1. Not really
  2. Even this works
  3. Raises SyntaxError
  4. Raises ValueError

This brings us to the end of the conditional execution.

Python executes the code block in an if statement once. Python also contains control-flow statements that can execute a group of statements repeatedly. We call these control flow statements as loops. We will learn more about loops in detail in the next section.

Loops

In programming, to repeatedly execute a group of statements, a loop statement is used. In Python, we use for and while keywords to implement loops in Python.

A loop is a shape that bends around and crosses itself. Let's learn our first loop - the while loop.

While Loop

The while statement creates a loop in Python using the following syntax.

# Until the condition evaluates to `True`
while condition:
    # Execute this code block
Figure 3: While loop

Like the if statement, the' while' statement has a condition associated with it. Python executes the code block inside the while statement until the condition evaluates False.

>>> counter = 5				# Assign name counter to 5
>>> while counter :
...     print(counter)
...     counter -= 1		# Decrease the value of name by 1
...
5
4
3
2
1

In the above code block, the condition provided to the while statement is the object referenced by the name counter. As we might recall, most numbers' truth value is True, except 0.

After completing the code block's execution inside the while, Python checks if the condition associated still evaluates True. Until the condition evaluates to False, Python continuously executes the code block.

A single execution of the code block inside the while loop is called an iteration.

In the above code block, to make the condition evaluate to False, we decrease the value referenced by the name counter by one each time it executes the code block. At some point, the name counter's value becomes 0, which has a truth value of False, and thus Python exits the loop.

The above loop is finite as the condition associated with the while loop eventually evaluates to False. What do you think happens when the condition doesn't evaluate to False?

If you don't make provisions to make the condition to become False eventually, Python will execute the code block inside the while loop indefinitely. In programming, a loop code block that goes on endlessly is called an infinite loop.

Can you think of a small change in the above code listing to make it an infinite loop?

We can remove the statement counter -= 1 to make the code an infinite loop.

Before implementing a while loop, you should ensure that the condition eventually evaluates False. Python can't determine if a condition will subsequently become False or not.

If you executed an infinite loop, it might cause the console or terminal to become unresponsive. Then you can close an unresponsive console or terminal directly by usually (Ctrl + c or Cmd + c).

Is the following code an example of an infinite loop?

>>> grocery_list = ["Apples", "Oranges", "Bananas", "Soda"]
>>> while grocery_list:
...     print(grocery_list.pop())
...
  1. Yes, this is an infinite loop.
  2. No, this is not a loop.

Earlier, we saw that the list object has a method s.pop(), which retrieves a randomly selected element and removes it from the list. We also know that the truth value of an empty list is False. Equipped with these two sets of information, we can see that the previous exercises' code isn't an infinite loop.

The output of the code in the above exercise is as follows:

>>> grocery_list = ["Apples", "Oranges", "Bananas", "Soda"]
>>> while grocery_list:
...     print(grocery_list.pop())
...
Soda
Bananas
Oranges
Apples

In the above code listing, after the grocery_list list become empty, it evaluates to False, and Python exits out of the while loop.

In the above code sample, the grocery_list becomes empty due to using the pop() method. Can you think of a way to access each element without modifying the list element itself?

We can define the name counter as the condition for the while loop and then use it as an index to access each element of a list or sequence. Let's check an example.

Take a look at the script below.

grocery_list = ["Apples", "Oranges", "Bananas", "Soda"]
counter = len(grocery_list) - 1				# Index starts from 0

print("The following items are on my grocery list:")

while counter + 1:
	print(grocery_list[counter])			# Access the item using `counter` as index
	counter -= 1
grocery.py

We can check out the output of the code below.

>>> python3 grocery.py
The following items are on my grocery list:
Soda
Bananas
Oranges
Apples

As you can see, we can initialize a counter to iterate or loop through all the items in a list. The while loop condition is counter + 1 instead of counter. Otherwise, it would skip over the item at the 0 index.

Present below is a Python script.

guests = ["Luffy", "Zorro",  "Chopper", "Sanji"]
counter = len(guests)

while counter:
	print(f'{guests[counter-1]} just arrived !')
	counter -= 1

Which of the following statement is not printed as output?

  1. Sanji just arrived !
  2. Chopper just arrived !
  3. Luffy just arrived !
  4. All the above statements are printed
As you can see, we can use indexing to iterate through a list of items in a while loop. But what about containers that don't support accessing items using indexing such as sets and dictionary? Can you take a guess how do we iterate over elements in sets and dictionary?

We can access the elements of sets and dictionaries using for loops. Let's understand in detail about for loops.

For Loops

Objects that support iteration are said to be iterable.

This definition is not much helpful. Let's try another definition of iterables.

An iterable is any Python object capable of returning its members one at a time, permitting it to be iterated over in a for loop.

That's much better. Now, we can start with understanding for loops.

The for statement iterates over an iterable until there are no more elements available.

Figure 4: for loop

We can see the general syntax of a for loop below.

for i in iterable :
    # Execute this code block

In the statement, the name i is often referred to as iteration variable. With each iteration, the iteration variable is assigned a new value provided by the iterable. You can think of it as the iterable returning one of the members assigned to the iteration variable i for a while. Let's look at an example.

>>> a = [1, 2, 3, 4, 5]
>>> for i in a:
...     print(i)
...
1
2
3
4
5

In the above code sample, the for loop iterates over the list a. Python first assigns the iteration variable i to the value 1, which is the first item occupied in the list a in the first iteration. In the next iteration, it is assigned the next item in the list, and so forth.

Below is a python script.

person = { "name" : "John Doe", "age" : "16", "country" : "Bhutan" }
for detail in person:
	print(f'{detail.title()} : {person[detail]}')

Which of the following statement will be printed upon executing the above script?

  1. Country: Bhutan
  2. Bhutan: Country
  3. country: Bhutan
  4. Raises NameError: name 'detail' is not defined.

The iteration variable in a for loop can be assigned any valid name. It doesn't necessarily need to be i. You can use this to make your code more readable.

Let's look at a couple of more things about the iteration variable.

  • The scope of the iteration variable is not private to the for loop statement. If another name exists with the same name as the iteration variable, Python overwrites it.
  • The last value assigned to the iteration variable will remain attached even after loop completion.

We can check this by an example.

>>> grocery_list = ["Apples", "Oranges", "Bananas", "Soda"]
>>> item = "Nachos"
>>> for item in grocery_list:
...     print(item)
...
Apples
Oranges
Bananas
Soda
>>> item
'Soda'				# Last item of the list

You can see that the name item assigned to the string Nachos is reassigned to the last element of the list after the iteration.

In a while loop, we can define a counter variable and use it to iterate over a sequence. Can we do the same index-based looping in for loops?

As for loops take an iterable object, we need to define our sequence of numbers first before we can loop over it. To iterate over a sequence, the built-in function range() can be used to generate a sequence of numbers corresponding to the length of the sequence to use index-based looping.

Let's look at range() function next.

Range

The function signature of range() function is range(start, stop, step) where the start and step arguments are optional. The range function generates a produces a sequence of numbers from and including the start parameter to the stop parameter. The start argument defaults to 0 while the step parameter defaults to 1. For instance,

>>> range(9)			# same as range(0, 9, 1)
range(0, 9)

You can view the sequence generated by the range() by converting it to a list or tuple.

>>> tuple(range(9))
(0, 1, 2, 3, 4, 5, 6, 7, 8)

The range() function returns a <class 'range'> object, a type of iterable that can be used for iteration.

>>> for i in range(10, 50, 5):
...     print(i)
...
10
15
20
25
30
35
40
45

Which of the statement is printed out due to the execution of the following script:

alphabets = 'ABCDEFG'
counter = 1
for letter in alphabets:
	print(counter, ":", letter)
	counter += 1
  1. 7 : G
  2. A : 1
  3. 1 : G
  4. F: 4

As shown in the above exercise, you often will require the element's position index in an iterable along with the element value.

Many programming languages do this by keeping a separate variable to keep track of the iteration count and increasing it by one during each iteration. We can understand it better using the code listing.

The format of using an iteration count to keep track of the position index of the iterable is shown below:

index = 0
for a in s:
	# Code block
    index += 1

The name index acts as a counter that stores the current iteration value in the above code. Keeping a counter to track the position of the element in the iteration is a common programming paradigm. Python provides a built-in function enumerate() for the same.

Enumerate

The built-in enumerate() which provides a tuple of pair values containing, (index, iterable[index]). We can understand it better in the code listing below.

>>> grocery_list = ["Apples", "Oranges", "Bananas", "Soda"]
# i is a tuple (index, grocery_list[index])
>>> for i in enumerate(grocery_list):
...     print(i)
...
(0, 'Apples')
(1, 'Oranges')
(2, 'Bananas')
(3, 'Soda')

The enumerate() function also accepts an optional argument start, which defaults to 0 and can be used to give a user-defined starting index. We can even unpack the tuple using two iteration variables.

>>> for index, val in enumerate(grocery_list, 1):
...     print("{}. {}".format(index, val))
...
1. Apples
2. Oranges
3. Bananas
4. Soda

The enumerate() function is a useful function for creating an indexed list.

What is the value of the following operation?

>>> person = {'name': "John", "age": 16 }
>>> list(enumerate(a))
  1. [(1,'John'), (2, 16)]
  2. [(0, 'name'), (1, 'age')]
  3. [(1, 'name'), (2, 'age')]
  4. [(0, 'John'), (1, 16)]

You can convert the enumerate(<iterable>) object into sequences such as lists or tuples using their respective constructors.

Often, you need to iterate over two or more iterables or sequences in parallel. Let's take a look at how we can do this in Python.

Zip

Suppose you have three separate lists containing a person's name, city, and the food they prefer.

| Name            | City        | Food    |
|  | -- | - |
| Thomas Sowell   | Barcelona   | Tacos   |
| Li Xiu          | Tokyo   | Noodles |
| Joe Doe         | Pondicherry | Dosa    |
| Dark Puckerberg | Melbourne   | Bear    |

We wish to generate a print all of the person's details in a single string. We can do that by simultaneously looping over three lists and iterating over items using a while loop. Let's see this in action.

>>> names = ["Thomas Sowell", "Li Xiu",
             "Joe Doe", "Dark Puckerberg"]
>>> city, food = ["Barcelona", "Tokyo",
                  "Pondicherry", "Melbourne"],
				 ["Tacos", "Noodles",
                  "Dosa", "Bear"]
>>> i = 0
# Till `i` equals the shortest of three lists
>>> while i < min(len(names),len(city), len(food)):
    	print("{}. {} from {} likes {}.".format(i+1, names[i], city[i], food[i]))
    	i += 1
1. Thomas Sowell from Barcelona likes Tacos.
2. Li Xiu from Tokyo likes Noodles.
3. Joe Doe from Pondicherry likes Dosa.
4. Dark Puckerberg from Melbourne likes Bear.

In the above code example, we are iterating for three lists simultaneously and increasing our iteration variable i by 1 in each iteration.

This pattern of iterating over two or more lists in pretty common in programming, so Python provides a built-in function zip() to merge two or more lists to form a list of tuples. The above identifiers: names, city, and food can be zipped together to form a new object containing the tuple of objects from each list. For instance,

>>> zip(names, city, food)			# Returns a zip iterable
<zip at 0x7f5eaf293d48>
>>> list(zip(names, city, food))	# Convert the zip object into a list
[('Thomas Sowell', 'Barcelona', 'Tacos'),
 ('Li Xiu', 'Tokyo', 'Noodles'),
 ('Joe Doe', 'Pondicherry', 'Dosa'),
 ('Dark Puckerberg', 'Melbourne', 'Bear')]

We can rewrite the above while loop using the zip() function in the following way.

>>> for name, city_name, food_name in zip(names, city, food):
    	print("{} from {} likes {}".format(name, city_name, food_name))
Thomas Sowell from Barcelona likes Tacos
Li Xiu from Tokyo likes Noodles
Joe Doe from Pondicherry likes Dosa
Dark Puckerberg from Melbourne likes Bear

Continuing from the above names, what's the __A__ stands for in the following code listing?

>>> for ____A____ in list(enumerate(zip(names, city, food))):
    	print("{}. {} from {} likes {}".format(idx+1, name, city_name, food_name))
1. Thomas Sowell from Barcelona likes Tacos
2. Li Xiu from Tokyo likes Noodles
3. Joe Doe from Pondicherry likes Dosa
4. Dark Puckerberg from Melbourne likes Bear
  1. idx, (name, city_name, food_name)
  2. index, (name, city_name, food_name)
  3. [idx, (name, city_name,food_name)]
  4. idx, name, city_name, food_name

To include the index, we can use the enumerate() function after zipping the function. Note that the enumerate() creates a nested tuple; therefore, we need to unpack the nested tuple.

When you zip two or more iterables, it creates a list of tuples. Let's understand the previous exercise in detail.

>>> names = ["Thomas Sowell", "Li Xiu", "Joe Doe", "Dark Puckerberg"]
>>> city  = ["Barcelona", "Tokyo", "Pondicherry", "Melbourne"]
>>> food = ["Tacos", "Noodles", "Dosa", "Bear"]

When we zip the above lists, we get the following.

>>> list(zip(names, city, food))
[('Thomas Sowell', 'Barcelona', 'Tacos'), ('Li Xiu', 'Tokyo', 'Noodles'), ('Joe Doe', 'Pondicherry', 'Dosa'), ('Dark Puckerberg', 'Melbourne', 'Bear')]

If we need the index, we can use the enumerate() function on top of the zip().

>>> list(enumerate(zip(names, city, food)))
[(0, ('Thomas Sowell', 'Barcelona', 'Tacos')),
 (1, ('Li Xiu', 'Tokyo', 'Noodles')),
 (2, ('Joe Doe', 'Pondicherry', 'Dosa')),
 (3, ('Dark Puckerberg', 'Melbourne', 'Bear'))]

You can notice that each item is of the nested tuple format, (index, (names, city, food)).

When we want to iterate over the nested tuple, we need to unpack the tuple first.

>>> for index, (name, city_name, food_name)  in list(enumerate(zip(names, city, food))): # Unpacking nested tuple
    	print(f"{index}. {name} from {city_name} likes {food_name}")
1. Thomas Sowell from Barcelona likes Tacos
2. Li Xiu from Tokyo likes Noodles
3. Joe Doe from Pondicherry likes Dosa
4. Dark Puckerberg from Melbourne likes Bear

What's the output of the following code?

>>> for index, num in enumerate(range(9)):
...     print(list(range(index, num)))
...
  1. 9 empty lists
  2. 9 lists with an increasing number of elements
  3. 9 lists with a decreasing number of elements
  4. Raises an error

Break Statement

We can break out of a loop in the middle of an ongoing iteration using the break keyword.

The break statement exits out of the innermost for or while loop.

The break statement prematurely exits the loop, and the rest of the loop doesn't iterate over the remaining iterables.
For instance,

>>> guest_names = ["Adam", "James", "Sid", "David", "Harry", "Tyrion", "Lufy"]
>>> for idx, name in enumerate(guest_names):
...         if name == "Sid":
...             print("Wait, why is Sid invited?")
...             break
...         print("{} is invited".format(name))
...
Adam is invited
James is invited
Wait, why is Sid invited?

The Python stops looping over the guest_names iterable in the above code listing when it is the string object Sid. After the loop exits, Python doesn't iterate over the items after the Sid anymore.

For nested loops, the break statement breaks out of the innermost loop. Let's write a nested loop to get the prime numbers in the first 20 numbers to see this in action.

not_prime = set()
for n in range(2, 20):
     for x in range(2, n):
             if n % x == 0:
                not_prime.add(n)
                break
prime_numbers = set(range(2, 20)) - not_prime
print(prime_numbers)
prime_20.py

The output of the prime_20.py is shown below:

{2, 3, 5, 7, 11, 13, 17, 19}
As you can see, many things are happening in the above code samples. Do you want to attempt trying to explain the code written in prime_20.py?

In mathematics, a prime number is a number that is divisible only by one and the number itself. So, all we need to check is if a number is divisible by any number lower than itself.

In the above code listing, for every number between [2, 20), we are checking if it is divisible by a number other than one and itself. We can do this in the following way:

  • For a number n between 2 and 20, check if there exists a number x in the range(2, n), which perfectly divides the number.
  • If we find such a number x, the number n is added to the set not_prime, and the innermost loop exits using the break statement and continues the iteration for n+1.
  • After the iteration is complete, we can subtract the set of numbers in the range(2, 20) from the set not_prime.
  • The new set is assigned the name prime_numbers.

Reorder the code according to the code written in prime_20.py.

  1. Loop over range(2,20) with iteration variable n
  2. Create an empty set with the name not_prime.
  3. An if condition check if n is divisible by x
  4. Loop over over range(2, n) with iteration variable x.
  5. Return the difference between the set of numbers in the range(2, 20) and not_prime as prime_numbers.
  6. If n is divisible by x, add n to the set not_prime and break.

Continue Statement

To jump to the next iteration in a loop, we can use the continue keyword. The continue keyword skips the remainder of the loop body. Let's say we want to skip executing the code-block for a particular item in a list. We can put it in an if condition along-with a continue statement.

For example, in the below code example, we wish to skip printing the string object Sid.

>>> guest_names = ["Adam", "James", "Sid", "David", "Harry", "Tyrion", "Luffy"]
>>> for idx, name in enumerate(guest_names):
...     if name == "Sid":			# Ignore Sid
...             continue
...     print("{}.Inviting {}".format(idx+1, name))
...
1.Inviting Adam
2.Inviting James
4.Inviting David
5.Inviting Harry
6.Inviting Tyrion
7.Inviting Luffy

The loop iterates over the guest_names list and prints a string in the above code listing. In the loop, the if statement checks if the name iteration variable equals Sid, in which case, the execution of the remainder of the loop code is skipped, and iteration continues for the next item in the iterable. You can also notice that the index 3 is missing from the printed statements.

Which of the following numbers will be present in the following script's output?

for num in range(10, 20):
	if not num % 3:
		continue
	else:
		print(num)
  1. 10
  2. 12
  3. 15
  4. 20

Loops with else clause

Like if statements, loops can also have an else clause. We can use the else clause in loops in the following situations:

  1. for loop terminates through exhaustion of the iterable
  2. The condition associated with the while loop becomes from True to False
  3. The loop wouldn't execute at all

Python doesn't execute the else clause when a break statement terminates a loop.

We can see each of the cases in the following code listing.

for x in []: # Loop 1
    print("Loop 1")
else:
    print("Else statement executed for loop 1")

for x in [1]: # Loop 2
    print("Loop 2")
else:
    print("Else statement executed for loop 2")

for x in range(2): # Loop 3
    print("Loop 3")
    if x == 0:
        break
else:
    print("Else statement executed for loop 3")
for_else.py

The output of for_else.py is shown below:

Else statement executed for loop 1
Loop 2
Else statement executed for loop 2
Loop 3

In the above code sample, the else statements get executed for Loop 1 and 2 but not for 3 as loop three terminates through the break statement.

Is the else clause executed in the following script?

guest_names = ["Adam", "James", "David", "Harry", "Tyrion", "Luffy"]
for idx, name in enumerate(guest_names):
	if name == "Sid":
		print("Wait, why is Sid invited !")
		break
	print(f"{name} is invited")
else:
	print("Sid is not invited")
  1. Yes, it is.
  2. No, it isn't.

As the string object Sid isn't present in the list guest_name, the break statement is never executed. Therefore, the else clause is executed.

Let's take a look at a while loop with an else clause. In the script below, there are three different while loops.

  • first one, where the condition is False; therefore, the code block isn't executed, but the else is executed.
  • second one, where the condition is initially True but then becomes False, and the else clause is executed.
  • third one, where the code block exits using a break statement, and the else clause is not executed.
i = 1; j = 2		# For loop 2 & 3
while 0: # Loop 1
    print("Loop # 1")
else:
    print("Else statement executed for loop 1")

while i: # Loop 2
    print("Loop # 2"); i -= 1
else:
    print("Else statement executed for loop 2")

while j: # Loop 3
    print("Loop # 3"); j -= 1
    if j == 1:
        break
else:
    print("Else statement executed for loop 3")
while_else.py

The output of the while_else.py is shown below:

Else statement executed for loop 1
Loop 2
Else statement executed for loop 2
Loop 3

In the python script in file6.py, Loop 2 and 3 execute while the else statement for Loop 1 and 2 executes. This is similar to the results we got for for loops.

This comes to the end of loops in Python. In the next section, we will cover the call stack in Python.

Call Stack

We have seen that Python executes the statements in a program from top to bottom unless the statements involve control-flow constructs such as loops and if-else statements.

This is not a complete description of how Python executes the program. It is missing out on details about function calls.

What do you think happens when Python encounters a function call?

A function body is not executed until it is called.

Each time you call a function, Python takes a detour from the execution flow to go to that function code's function body. Let's do a small exercise.

Take, for instance, the following script.

def foo():				#1
    print("Line 2")		#2
						#3
def bar():				#4
    foo()				#5
    print("Line 6")		#6
    foo()				#7
						#8
def foobar():			#9
    bar()				#10
    foo()				#11
    print("Line 12")	#12
    					#13
print("Line 14")    	#14
foobar()				#15

What's the output when you run the above program?

  1. Line 14
  2. Line 2
  3. Line 6
  4. Line 2
  5. Line 2
  6. Line 12

From the previous exercise, we can see that Python doesn't seem to execute the statements sequentially. Let's take a deeper look at the code listing in the previous exercise to understand more.

The output of the above program is as follows:

Line 14
Line 2
Line 6
Line 2
Line 2
Line 12

Python executes the script using the following heuristic:

  1. Execute the first line that is not part of a function definition
  2. After executing the line, move on to the next line of code
  3. If the next line of code is part of a control statement such as if-else, loops, function calls, or function returns, Python executes them differently.

Earlier, we saw how control statements affect the flow of executions in Python. Now, we will focus on understanding how function calls and function returns are executed.

  • When Python encounters a function call, it jumps from that line of code to the first line of code inside the function definition.
  • When Python returns from a function call, it continues after the line, which invoked the function call that sent it to the function.

Therefore, Python executes the above script in the following way:

  1. Executes the print statement in line 14
  2. Calls the function foobar() at line 15
  3. Goes to line 10 and calls the bar() function
  4. Goes to line 5 and calls the foo() function
  5. Executes the print statement in line 2
  6. Returns from foo() to line 5
  7. Executes the line print statement in line 6
  8. Calls the foo() function at line 7
  9. Executes the print statement in line 2
  10. Returns from foo() to line 7
  11. Returns to line 10
  12. Call the foo() function at line 11
  13. Executes the print statement in line 2
  14. Returns from foo() to line 11
  15. Executes the print statement in line 12
  16. Returns to line 15
  17. End of Program

At this point, you might be wondering, how does Python keep track of where to jump when it returns from a function or how does it picks up from where it left.

Guess how Python keeps track of where it has jump next after returning from a function?

To understand how Python keeps track of function calls, we first need to understand something called the stack.

Stack

A stack is an ordered collection of items where new items and removal of existing items always occur at the same end.

In such ordering, the most recent item to add is the one that is in a position to be removed first. This ordering principle is called LIFO( last-in, first-out). Newer items are near the top, while the older items are near the base of the stack.

Many examples of stacks occur in our everyday world. A concrete example of a stack will be a tennis balls container with only one side open. In our tennis ball container, new balls can be added only from one end, while the last item added will be removed first.

How can we create a Python container that works like a stack?

We can implement a stack in Python using a list object in Python if we only use the append() method to add items to the pop() method to retrieve and remove an element from the list. Let's create a list object called the ball_holder.

>>> ball_holder = []

For the list object ball_holder to function like a stack, we can use only two of its methods:

  • append() method to add elements
  • pop() method to retrieve and remove elements
>>> ball_holder = []					# []
>>> ball_holder.append('Ball 1')		# ['Ball 1']
>>> ball_holder.append('Ball 2')		# ['Ball 1', 'Ball 2']
>>> ball_holder.append('Ball 3')		# ['Ball 1', 'Ball 2', 'Ball 3']
>>> ball_holder.pop()					# ['Ball 1', 'Ball 2']
>>> ball_holder.append('Ball 4')		# ['Ball 1', 'Ball 2', 'Ball 4']
>>> ball_holder.pop()					# ['Ball 1', 'Ball 2']
>>> ball_holder.pop()					# ['Ball 1']
>>> ball_holder.pop()					# []

We can visualize the tennis ball stack using the following diagram.

Figure 5: Ball Holder Stack

Now that we have some idea about stacks let's see how Python executes a function call.

What does the output of the following code listing look like?

ball_holder = ['Ball 1']
ball_holder.append('Ball 2')
ball_holder.append('Ball 4')
ball_holder.append('Ball 3')
print(ball_holder.pop())
print(ball_holder.pop())
ball_holder.append('Ball 5')
print(ball_holder.pop())
ball_holder.append('Ball 6')
ball_holder.append('Ball 7')
print(ball_holder.pop())
ball_holder.append('Ball 8')
print(ball_holder.pop())
  1. Ball 3
  2. Ball 8
  3. Ball 5
  4. Ball 7
  5. Ball 4

Now that we have some idea about stacks, let's look at how Python executes a function call.

Call Stack

The Python interpreter uses a stack to run a Python program. The stack is commonly referred to as call stack or run-time stack or stack. The call stack consists of call frames which store information about each function call. A call frame is how Python keeps track of execution while a function call progresses. The call stack works in the following way:

Whenever a function is called in Python, a new call frame is pushed onto the call stack.

Every time the function call returns, its call frame is popped or deleted from the call stack.

The module in which the program runs has the bottom-most frame. It is called the global frame or the module frame. Let's write another script to understand call frames.

def foo():
	print("Last")

def bar():
	print("Second)

def foobar():
	bar()
	foo()

print("First")
foobar()
foobar.py

The above script foobar.py returns the following output.

First
Second
Last

We can visualize the way Python executes the script using the following diagram.

Figure 6: Stack Diagram of foobar.py

Let's take our previous script.

def foo():
	print("Last")

def bar():
	print("Second)

def foobar():
	bar()
	foo()

print("First")
foobar()

Reorder the statements according to which Python executes them.

  1. Goes into the first line of bar() function body
  2. Executes print("First")
  3. Executes print("Last")
  4. Returns from the foo() function body
  5. Returns from the foobar() function body
  6. Executes print("Second")
  7. Returns from the bar() function
  8. Goes into the first line of foo() function body
  9. Goes into the first line of foobar() function body

As we can see, Python adds call frames to the call stack whenever it encounters a function call.

After the function call returns, Python removes the frame from the call stack and adds the next function call into the call stack. Figure 7 shows snapshots of the call stack during the execution above.

Figure 7: Call Stack Timeline

In the figure 7, you can see how the frames are added and removed after their execution.

Below is a code listing which we encountered earlier.

def foo():
    print("Foo")

def foo_foo():
    print("Foo-Foo")	# Curently Executing

def bar():
    foo()
    foo_foo()

def foobar():
    bar()
    foo()

foobar()

Python is currently executing the line 6 ( print("Line 6")). Can you reorder the below frames to resemble the call stack?

Assume that the bottom-most frame is the first call frame to be added to the call stack.

  1. print("Foo-Foo") Frame
  2. bar() Frame
  3. foo_foo() Frame
  4. foobar() Frame
  5. Module Frame

Call Frames

The call frames contains information such as the local namespace and variables inside a function. To understand, let's take another example.

We will create a script that solves a quadratic equation of form $ax^2 + bx + c$. The discriminant method can calculate the solution of the two roots of a quadratic equation. The following shows the formulae for obtaining a quadratic equation's roots using discriminant($\Delta$).

$$
\text{Roots of equation } = \frac{-b  \pm \sqrt(\Delta)}{2a} \
\text{where } \Delta = b^2 - 4ac \
$$

Let's say we want to solve the equation.

$$
x^2 + 2x +  1 = 0
$$

We can do so using the following script.

Note that we have to use the cmath module instead of math as the roots can be imaginary.
# Solve the quadratic equation
# 		ax**2 + bx + c = 0
# using discriminant method
import cmath		# Roots can be complex

a = 1
b = 2
c = 1

def quadratic_solver(a,b,c):
    # calculate the discriminant
	d = (b**2) - (4*a*c)

    # find two solutions using disrciminant
	x1 = (-b-cmath.sqrt(d))/(2*a)
	x2 = (-b+cmath.sqrt(d))/(2*a)

    return (x1, x2)

sol_1, sol_2 = quadratic_solver(a, b, c)
print(f'The solution is {sol_1} and {sol_2}')
quadratic.py

The output of quadratic.py is shown below:

The solution is (-1+0j) and (-1+0j)
The quadratic solver script returns the correct output. Can you describe briefly how does the quadratic.py script work?
Figure 8: Quadratic Solver

In the above script, we define the quadratic equation's coefficients and provide it to the quadratic_solver() function. The function calculates the solution using the discriminant method and returns a tuple of two solutions. Let's look at how Python executes the above script.

The diagram above depicts the stack diagram for the quadratic equation solver script. You can notice the following:

  1. The module frame contains the global names such as a, b, c, cmath
  2. While the quadratic_solver() frame is executed, its call frame is added on top of the module frame.
  3. The quadratic_solver() frame contains the local variables such a, b, c, d, x1, x2.
  4. After the quadratic_solver() returns, it's call frame and the information it contained is removed from the stack.

The advantage of using the stack to store data is that memory is automatically managed for you. But the size of the stack is limited or finite. At some point, you are going to run out of space to hold additional call frames. This problem is often faced when you recursively call a function. Let's understand more in the next section.

Recursion

Recursion occurs when a thing is defined in terms of itself. Recursive definitions are self-referential.

For instance, in his book Gödel, Escher, Bach, Douglas Hofstader gives an interesting example of a recursive definition.

Hofstadter's Law: It always takes longer than you expect, even when you take into account Hofstadter's Law.

In programming, recursion is one of the most interesting problem-solving methods.

The word recursion has roots in the Latin word recursiō, which means the act of running back or again or return. In programming, we define a recursive function as follows:

A function is said to be recursive when it calls itself from within its code definition.

When you call a function inside its definition, it becomes a recursive function. Let's look at one of such recursive functions below.

def recur_func():
	recur_func()

recur_func()
In the above script, we define a function recur_func() to invoke the function itself. What do you think happens when you execute the above program?

You might have guessed that Python encounters an error while executing the script.

In the previous topic, we discussed how Python uses call stacks to hold information regarding function calls. What do you think happens in the call stacks during the execution of the above recursive function?

The Python interpreter's call stack is finite and can store a limited number of call frames. When the stack is full, and you attempt to add additional frames, it is called stack overflow.

Stack overflow means that a Python program has run out of memory to hold the frames in the call stack.

When the Python Interpreter experiences stack overflow, it causes the interpreter to crash or freeze. To avoid the stack overflow, Python sets a limit for the total number of frames that the call stack can hold. This limit is called the recursion limit. It is usually much lower than the actual number that can cause a stack overflow.

You can find out the recursion limit using the getrecursionlimit() function from the sys module.

>>> import sys
>>> sys.getrecursionlimit()
1000

Usually, Python sets the limit is 1000 (the maximum number of frames that the call stack can hold). However, it might differ for different Python interpreters. You can set the recursion limit of your Python interpreter to any other number you like using the setrecursionlimit() function from the sys module. Although, if you set the number higher than the actual frames the interpreter call stack can hold, it might cause Python to crash while executing a recursive function.

When we try to execute recur_fun() , the number of call frames hit the recursion limit.

>>> def recur_func():
...     recur_func()
>>> recur_func()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 2, in recursive_function
  File "<stdin>", line 2, in recursive_function
  File "<stdin>", line 2, in recursive_function
  [Previous line repeated 996 more times]
RecursionError: maximum recursion dep3th exceeded

While executing the above script, Python invokes recur_func() repeatedly due to the function's recursive nature. As a result of the repeated invocation, the interpreter call stack is filled with recur_func() call frames till the recursion limit. Any additional invocation of recur_func() results in Python raising RecursionError.

Recursion Error is raised when the call frames' number in the call stack is equal to the recursion limit. Python attempts to add another call frame to the stack.

We can visualize the execution using the below stack diagram.

Figure 9: Call stack during the execution of a recursive function

In the above diagram, we can see that the module frame occupies the bottom-most frame while the rest 999 frames are occupied by the recur_func() frame. Adding another recur_func() call frame causes Python to raise RecursionError.

Functions that invoke themselves in their definition are susceptible to raising RecursionError. How can we ensure that recursive functions don't raise RecursionError?

While working with recursive functions, we need to think about breaking off the recursion. We can do so by organizing the recursive function definition inside an if-else statement.

We can rewrite our previous recur_func() as follows:

def recur_func(count=0):
    count = count + 1
    if count < 5:
        print(f"Recursion Count: {count}")
        recur_func(count)
    else:
        print("Recursion Completed")

recur_func()

We get the following by executing the script.

Recursion Count: 1
Recursion Count: 2
Recursion Count: 3
Recursion Count: 4
Recursion Completed

While using recursion as a problem-solving method, the general idea is to think about the ending first. In the updated version of recur_func(), we use a count object to keep track of the recursion along with an if statement. This ensures that the function doesn't repeatedly call itself indefinitely and eventually returns from the function call.

At this point, you might be wondering if there is any actual use of recursion in programming. There are many instances where recursion proves to be a practical approach. In the next section, we will take a look at one such problem: Factorial.

Factorial

The factorial of a given non-negative number $n$ is the product of all the natural numbers between $1$ and $n$.

The factorial of a negative number doesn't exist, and the factorial of $0$ is $1$. Mathematically it is shown in the below equation.

$$
\text{Factorial of a natural number } n \text{ is } \
n! = \prod_1^nn
$$

From the equation, we can think of factorial as follows:

$$
4! = 4\times3\times2\times1 = 24 \\
5! = 5\times4\times3\times2\times1 = 120 \\
6! = 6\times5\times4\times3\times2\times1 = 720
$$

That's pretty much everything that you need to know about factorials right now. The code below shows a function that accepts a given number and returns it's factorial.

def factorial(n):
    result = 1
	for num in range(1, n + 1):
        result *= num
	return result

Let's check out the values of the interpreter.

>>> factorial(4)
24
>>> factorial(5)
120
>>> factorial(6)
720
Can you explain how the factorial function works?

The factorial(n) function uses loops over a range of (1, n+1). In each iteration, it uses the name result to store the natural numbers' product. After completing the loop, it returns the name result as the factorial of the number.

The above solution for obtaining the factorial works fine. However, we can use recursion to solve the problem.

The first thing to create a recursive function is that we have to represent the function in terms of itself.

Let's take a look at the factorials once more. In the formula for obtaining the factorial of a number, you can notice the following:

$$
6! = 6\times5! \\
5! = 5\times4! \\
4! = 4\times3! \\
3! = 2\times1! \\
1! = 1\times0! \\
0! = 1
$$

This indicates that,

the factorial of a number is equal to the product of the number and the factorial of the preceding number.

Mathematically, we can state the following:

$$
\text{For a natural number, }\\
n! = n\times(n-1)!
$$

If we think about this with respect out function factorial(), we can write:

$$
\text{factorial}(n) =
\begin{cases}
n\times\text{factorial}(n-1),  & \text{if $n$ $\geq$ 1} \\
1, & \text{if $n$ = 0}
\end{cases}
$$

You can see that we have successfully defined the factorial() function in terms of itself. Now, let's rewrite the factorial().

def factorial(n):
	if n == 0:
		return 1
	return n*factorial(n-1)

You can notice that we are calling the factorial() inside its definition. This is a recursive function. To ensure that the recursion doesn't go on forever, we have set the limit condition at n = 0, which returns 1.

Let's test out our newly written factorial() function in the interpreter.

>>> factorial(4)
24
>>> factorial(5)
120
>>> factorial(6)
720

As you can see, the function gives the output as expected.

Can you describe in your own words how the new factorial function works?

When you invoke the factorial() function with the argument n, it first checks if the n = 1. If not, it calls the factorial() function again, but this time with the argument n-1. It does this until the function argument is 0, upon which it returns 1.

We can understand the way our above code works using the stack diagram.

Figure 10: Snapshots of the call stack during the execution of the factorial program

Because we are using recursion inside the function, we are always at risk of exceeding the call stack frames set by Python. This might not be significant for lower values of n, but it indeed becomes problematic for higher values.

For instance, if your interpreter has the recursion limit set at 1000, the factorial function above will work for n = 996. Still, it will not work for n >= 997. If you try to test out the factorial for 998, Python might give the following output.

>>> factorial(998)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 4, in factorial
  File "<stdin>", line 4, in factorial
  File "<stdin>", line 4, in factorial
  [Previous line repeated 995 more times]
  File "<stdin>", line 2, in factorial
RecursionError: maximum recursion depth exceeded in comparison

You can set the recursion limit to a higher number, using the sys.setrecursionlimit() function to bypass the error. However, doing so is not recommended.

What's the output of the following script?

def func_1(n):
	if n == 0:
		return 1
	return func_1(n-1)**n

print(func_1(6))
  1. 720
  2. 120
  3. 24
  4. 1

Any value raised to the power of 1 will return 1.

Let's look at another problem that we can solve using recursion: Fibonacci's Sequence.

Fibonacci's Sequence

In mathematics, a sequence starts from 0 and 1 where each number is the sum of the two preceding ones called Fibonacci Sequence. The numbers in the sequence are called Fibonacci Numbers.

The Fibonacci sequence is represented in the following way:

$$
F_n = F_{n-1} + F_{n-2} \\
\text{Given, } F_0 = 0, F_1 = 1 \\
$$

You might recall Leonardo of Pisa, also known as Fibonacci, posthumously. In the 1202 book Liber Abaci, he introduced the world to counting using the Hindu system of counting. In the same book, Fibonacci introduced the sequence to Western European Mathematics.

Note that the sequence had already been described by Indian Mathematician Acharya Pingala in the early 200 BC.

The beginning of the sequence is as follows.

$$
0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, ...
$$

If you want to write Fibonacci sequence on your own, you will find yourself doing the following:

$$
\text{Given}, F_0 = 0 \text{ & } F_1 = 1, \\
F_2 = F_1 + F_0 = 1 + 0 = 1 \\
F_3 = F_2 + F_1 = 1 + 1 = 2 \\
F_4 = F_3 + F_2 = 2 + 1 = 3 \\
F_5 = F_4 + F_3 = 3 + 2 = 5
$$

We will write a program that generates the Fibonacci sequence. We are going to approach this problem by breaking it into two functions.

  1. We will write a function fib() that gives the nth Fibonacci ($F_n$) number.
  2. We will write another function fib_seq()that generates n-length Fibonacci sequence using the fib() function

We will focus on writing the fib() function first. But before that, let's do a small exercise.

What's the output of the following code?

seq = []
(n1, n2) = (0, 1)	# initialize first two numbers

seq.append(n1)

next_num = n1 + n2	 # sum of two preceding numbers
(n1, n2) = (n2, next_num)
seq.append(n1)

next_num = n1 + n2	 # sum of two preceding numbers
(n1, n2) = (n2, next_num)
seq.append(n1)

next_num = n1 + n2	 # sum of two preceding numbers
(n1, n2) = (n2, next_num)
seq.append(n1)

next_num = n1 + n2	 # sum of two preceding numbers
(n1, n2) = (n2, next_num)
seq.append(n1)

print(seq)
  1. [1, 1, 2, 4]
  2. [1, 1, 3, 5]
  3. [1, 1, 2, 3, 5]
  4. [0, 1, 1, 2, 3]

Take a look at the code in the previous exercise. You will realize that it is generating the Fibonacci sequence itself. In the code, we sum up the previous two terms to get the next_num and then update n1 and n2. This is the central part of the nth Fibonacci number generating algorithm.

We can state the algorithm for generating the nth Fibonacci number generating as the following:

  1. Define the first two numbers of the sequence: n1 = 0, n2 = 1
  2. The next number in the sequence next_num can be obtained by adding n1 and n2.
  3. Before moving on to get the next number, update the values of n1 and n2 as follows:
  4. n1 = n2
  5. n2 = next_num
  6. Repeat steps two and 3 to get the next number in the sequence.

Now that we have figured out the algorithm, let's write the nth Fibonacci number generating function fib():

def fib(n):
    if n < 0:
        print('Please enter a positive integer')
    elif n in (0, 1):
        return 0 if n == 0 else 1
    else:
        count = 0
        (n1, n2) = (0, 1)	# first two terms
        while count < n:

            # sum of two preceding numbers
            next_num = n1 + n2
            # update value for the next loop
            (n1, n2) = (n2, next_num)

            count += 1
        return n1

Let's test out our function in the interpreter.

>>> print(fib(2))
1
>>> print(fib(3))
2
>>> print(fib(4))
3
>>> print(fib(5))
5
>>> print(fib(6))
8

As you can see, our function is returning the correct values.

Now that we can generate the nth Fibonacci number in the sequence, how can we get the entire n-length sequence?

As we have already created the fib() function, we can quickly write a function to generate the $n-length$ Fibonacci sequence.

We can proceed to write a function that generates n-length Fibonacci sequence as follows:

def fib_seq(n):
    sequence = []
    count = 0
    while count < n:
        sequence.append(fib(count))
        count += 1
    print(*sequence)		# unpacking the list object

We can unpack a list using the asterisk operator inside the print() function. Let's try it out on the interpreter.

>>> fib_seq(2)
0 1
>>> fib_seq(3)
0 1 1
>>> fib_seq(4)
0 1 1 2
>>> fib_seq(5)
0 1 1 2 3
>>> fib_seq(6)
0 1 1 2 3 5

As you can see, the fib_seq() function works as expected.

Can you take a moment to describe how the fib_seq() function works?

Previously, we rewrote the factorial() function using recursion. We can do the same for our fib() function. However, first, we need to understand how to represent the current fib() in terms of itself.

Mathematically, we can write the following about the Fibonacci numbers:

$$
F_n = F_{n-1} + F_{n-2} \\
\text{where, } F_0 = 0, \text{ }  F_1 = 1
$$

If we think about this with respect to our function fib(), we can write:

$$
\text{fib}(n) =
\begin{cases}
\text{fib}(n-1) + \text{fib}(n-2),  & \text{if $n$ $>$ 1} \\
1, & \text{if $n$ =1} \\
0, & \text{if $n$ =0 }
\end{cases}
$$

We have successfully defined the fib() function in terms of itself.

Now we can rewrite the fib() function using recursion.

def recur_fibo(n):
   if n <= 1:
       return n
   else:
       return(recur_fibo(n-1) + recur_fibo(n-2))

That's it. An elegant solution, isn't it?

Now, let's test out our function in the interpreter.

>>> print(fib(2))
1
>>> print(fib(3))
2
>>> print(fib(4))
3
>>> print(fib(5))
5
>>> print(fib(6))
8

The result is as we expected. If you test out the fib_seq() function with the recursive fib() function, you will find that it also works fine like the previous factorial() function.

Keep the recursion limit in mind while invoking the fib() function.

You just witnessed two different recursive functions used in solving problems. Can you take a moment to describe the main steps of writing recursive functions?

While creating a recursive function,

  • first, try to represent the function in terms of itself.
  • Second, keep the end of the function invocation in mind. Otherwise, you will end up invoking the function indefinitely.

Hopefully, how Python leverages stacks to execute function is clear to you now. Incidentally, call stacks are also helpful while tracking errors in the application. We will take a look at exceptions and how to handle them in the next section.

Exceptions

We mentioned earlier that Python terminates the program upon encountering any errors while executing the statements. In this section, we will delve deep into errors and exceptions in Python.

Can you think of some errors that we have already faced earlier?

We have faced many errors till now, such as NameError, AttributeError, IndexError etc. Let's look into errors in Python in more detail.

Errors in Python are of at least two types:

  1. Syntax Errors
  2. Exceptions

Let's start with Syntax Errors.

Syntax Errors

Syntax errors, also called parsing errors, are the kind of errors where you deviate from the syntax that Python programming language understands. In the first chapter, we mentioned that programming languages are formal languages that adhere to a strict syntax. Python has a strict syntax and will complain when you write something; it doesn't allow or understand. For instance,

>>> print("Hello World)
  File "<stdin>", line 1
    print("Hello World)
                      ^
SyntaxError: EOL while scanning string literal

In the code listing above, Python expects the string literal to have a matching double quote before the parenthesis. Python will repeat the offending line in syntax errors and put a little arrow mark where it encountered the error.
In the case of a script, Python prints the file name and line number to locate the error.

Syntax errors are easier to fix.

How can we fix the below code?

>>> a = {"name: "Joe", "age": 16"} 		# code to fix
  File "<stdin>", line 1
    a = {"name: "Joe", "age": 16"}
                 ^
SyntaxError: invalid syntax
  1. Surround name with matching quotes
  2. Surround 16 with matching quotes
  3. Surround both name and 16 with matching quotes

Exceptions

Errors apart from the syntax errors, are called exceptions in Python.

Exceptions are errors caused by a syntactically correct expression or statement that python encounters while executing the program.

For instance, an undefined name will raise NameError.

>>> print(my_name)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'my_name' is not defined

Another example is TypeError when adding two incompatible types of objects.

>>> (1, 2, 3) + [4, 5, 6]
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: can only concatenate tuple (not "list") to tuple

In the above code listing, Python encounters NameError and TypeError exceptions. Upon encountering exceptions, Python provides some additional information about the errors for you to fix them.

There are different types of exceptions in Python, and Python prints the type in the error message. Python assigns Standard exceptions to built-in identifiers. You can read about all the built-in exceptions in the python documentation[1].

Which of the following will raise a SyntaxError?

  1. print("Hello World)
  2. '20' + 20
  3. -0.5*3
  4. (10 * (1/0)

When Python encounters an error inside a function, it returns a report containing function calls that led to a particular error. Because Python uses a stack to store function call, the report is called stack trace or stack traceback or simply traceback.

Stack Trace

Let's write another code example. In the code below, we define a function hello(), which takes the required argument name.

>>> def hello(name):
...     print(f"Hi {name}")

If we call the hello() function while not providing an argument, Python will raise an error.

>>> hello()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: hello() missing 1 required positional argument: 'name'

Since the first stack frame is the module frame, the traceback location is written in <module>.

Now, let's take another example. We will define two additional functions and modify the definition of the hello() function in the following manner.

def hello(name):
	print(f"Hi {foo(name)}!")		# calls `foo()` on the name

def foo(name):
	return bar(name)				# calls `bar()` on the name

def bar(name):
    return name.upper()				# returns the uppercased string

Now, let's test our function.

>>> hello("Luffy")
Hi LUFFY!

Great. It works.

What do you think will happen if we pass an integer to the hello() function?

The upper() method employed in the bar() function definition is available only on string objects. If we pass int objects to the hello() function, Python will raise AttributeError.

Now, let's pass an integer as an argument to the hello() function.

>>> hello(12)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 2, in hello
  File "<stdin>", line 2, in foo
  File "<stdin>", line 2, in bar
AttributeError: 'int' object has no attribute 'upper'

As we expected, Python raises AttributeError.

In this case, you can notice the stack traceback printed in the error message. We can think of the call stack of the above function as something shown below.

Figure 11: Stacked Chart
Figure 11: Stacked Chart

If you compare the call stack diagram and the stack trace, you will find the resemblance—python prints out the call stack when it encounters an error.

Why do you think Python lists out the call stack?

In the stack traceback, Python lists out the call stack to narrow down the search while figuring out how and where the error occurred.

What about the error message AttributeError: 'int' object has no attribute 'upper'? What do you think is the utility of printing out the error message?

The error message AttributeError: 'int' object has no attribute, 'upper' helps us understand what caused the error.

Now, let's look at some of the standard exceptions that you might come across, along with the reasons they get raised.

Common Exceptions and when to expect them

Let's look at some of the common exceptions that you might come across, along with the reasons they get raised.

Let's go through each one of them to understand them more.

NameError

The NameError is raised when you have referenced an identifier, function, class, module, or other names that don't exist in your code. The Python documentation states

NameError is raised when a local or global name is not found. Raised when a local or global name is not found.

Example:

>>> a = 1
>>> aaaaaaa			# Causes `NameError` as not defined

IndexError

Python raises IndexError when you attempt to retrieve an index from a sequence such as list or tuple, and the index isn’t present in the sequence. The Python documentation states:

Raised when a sequence subscript is out of range.

Example:

>>> a = (1, 2, 3)
>>> a[4]		# Causes IndexError

ValueError

Python raises ValueError when you provide an incorrect value of an object. It is more often encountered while you attempt to unpack more items from a sequence. Python documentation states:

Raised when an operation or function receives an argument with the right type but an inappropriate value. The situation is not described by a more specific exception such as IndexError.

Example:

>>> a, b, c = [1, 2]
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: not enough values to unpack (expected 3, got 2)

AttributeError

Python raises AttributeError when you try to access an undefined or non-existent attribute on an object. The Python documentation states:

Raised when an attribute reference or assignment fails. (When an object does not support attribute references or attribute assignments, TypeError is raised.)

Example:

>>> a = 1
>>> a.attr
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'int' object has no attribute 'attr'

ImportError

Python raises ImportError when something goes wrong with an import statement.

Raised when the import statement has trouble trying to load a module. Also raised when the “from list” in from ... import has a name that cannot be found.

Example:

>>> from math import mat			# Non-existent function import
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ImportError: cannot import name 'mat' from 'math' (unknown location)

KeyError

Python raises KeyError when you try to access a key that isn’t in a dictionary.

Raised when a mapping (dictionary) key is not found in the set of existing keys.

Example:

>>> person = {"name" : "Luffy"}
>>> person["age"]
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
KeyError: 'age'

TypeError

Python raises a TypeError exception whenever an operation is performed on an incorrect/unsupported object type.

Raised when an operation or function is applied to an object of inappropriate type. The associated value is a string giving details about the type mismatch.
Passing arguments of the wrong type (e.g., passing a list when an int is expected) should result in a TypeError, but passing arguments with the wrong value (e.g., a number outside expected boundaries) should result in a ValueError.

Some general cases where Python raises TypeError:

  • Unsupported operation between two types
>>> (1,2) + [3]
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: can only concatenate tuple (not "list") to tuple
  • Calling a non-callable object
>>> name = "Luffy"
>>> name()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'str' object is not callable
  • Iterating through a non-iterable
>>> age = 16
>>> for x in age:
...     print(x)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'int' object is not iterable

The code below will encounter an error upon execution.

>>> a = lambda x: x + 1
>>> a((1,2))
  1. TypeError
  2. ValueError
  3. NameError
  4. IndexError

At some point, while designing programming, you are bound to encounter some form of exceptions. It is useful to learn how to handle those exceptions, which will do in the next section.

Handling Exceptions

In Python, there are two types of programming flavors regarding error-handling:

  • Look before you leap ( LBYL )
  • Easier to ask for forgiveness than permission ( EAFP )

Look before you leap

In the look before you leap ( LBYL ) style of handling errors, the programmer checks for exceptional cases or edge cases before executing a statement.

To illustrate the LBYL style, let's write a program that divides $100$ from each number from $-5$ to $5$ and prints out the result.

>>> for num in range(-5, 5):
...     print("{:.1f}".format(100/num))
-20.0
-25.0
-33.3
-50.0
-100.0
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
ZeroDivisionError: division by zero

In the above code listing, we can see that Python faces an error while dividing $100$ by $0$ and raises the exception ZeroDivisionError. Now because we are aware that $0$ is present in the $range(-5, 5)$, we can add statements to ignore $0$ to avoid this exception altogether.

>>> for num in range(-5,5):
...     if num == 0:
...             continue			# Ignore 0 and continue
...     print("{:.1f}".format(100/num))
...
-20.0
-25.0
-33.3
-50.0
-100.0
100.0
50.0
33.3
25.0

In the above code listing, we avoided encountering errors as if prohibited division by $0$. The code listing demonstrates the look before your leap way of handling exceptions.

Why do you think this sort of error handling is called Look before you Leap<?

Because we are checking for potential errors beforehand, this error handling style is called Look before you leap.

Easier to ask for forgiveness

The other style of handling exception involves letting Python encounter errors and then dealing with them. This style of handling exceptions is called Easier to ask for forgiveness than permission( EAFP ).

We can write programs to handle selected exceptions using the try-except clause. We can rewrite our previous program as follows :

>>> for num in range(-5, 5):
...     try:
...             print("{:.1f}".format(100/num))
...     except ZeroDivisionError:
...             print("Sorry, cannot divide by 0")
-20.0
-25.0
-33.3
-50.0
-100.0
Sorry, cannot divide by 0			# Asking for forgiveness
100.0
50.0
33.3
25.0

The way the try statement works are as follows:

  • First, Python executes the code block in the try clause
  • If no exception occurs, Python skips the code block in the except clause
  • If an exception occurs during the execution of a statement in the try clause code block, then the rest of the code block statements are skipped.
  • In case you write the name of the exception in the except clause, the except clause catches the exception, and Python executes the code block in the clause. This is called handling the exception.
  • If an exception occurs, which does not match the exception named in the except clause, Python passes it to outer try-except statements.
  • If there are no handlers for the exception present, the exception becomes an unhandled exception, and the execution stops with a message.
There is a lot to take in from the above. First, can you figure out why this form of handling is called Easier to ask for forgiveness than permission?

In the EAFP style of error handling, we let Python encounter errors and handle the errors accordingly. Rather than preemptively checking if we can execute an operation ( asking for permission ), we let Python execute the operation and deal with the errors.

Python displays an error message upon encountering an error (asking for forgiveness ) and handles it appropriately. Let's see some more examples of using try-except statements.

You should use the try-except statements when you have reasons to believe that Python might encounter errors while executing. If you know the type of error that might arise, you can write the except clauses' errors. For instance,

try:
	print(details)			# 'details` not defined
except NameError as err:
	print(f"Encountered NameError: {err}")
Error.py

In the above script, we are aware that the name details is not defined, and therefore it will raise NameError. We can print the error message by writing NameError as err in the except clause. The following is the output of the above script.

The output of Error.py is shown below:

>>> python3 Error.py
Encountered NameError: name 'details' is not defined

While taking inputs from the user, error handling can ensure that the user's input is correct. To take input from the user, we use the built-in function input().

while True:
	try:
		x = int(input("Enter a number: "))
		break
	except ValueError:
		print("Sorry! Not a valid number. Please try again...")
UserInput.py

The int() function only accepts numeric string or numeric literals. It will raise ValueError when you input other characters such as alphabets.

The output of UserInput.py is shown below:

>>> python3 Error.py
Please enter a number: abc
Oops!  That was no valid number.  Try again...
Please enter a number: ^_^
Oops!  That was no valid number.  Try again...
Please enter a number: 1
Let's say we remove the except clause from the UserInput.py. What do you think will happen when the user types an incorrect input?

Every try statement requires a corresponding except clause. Therefore, Python will raise SyntaxError and abruptly stop the program. Next, let's look at how to handle multiple exceptions in Python.

Multiple Exceptions Handling

A try-except statement may have multiple except clauses to specify a handler for different exceptions. Python executes only one handler for any given exception.

You can pass various exceptions in the form of a tuple to the except clause or chain distinct except clauses to handle separately. The last except clause may omit the exception name(s) to catch any exception that occurs.

The last except clause acts as a wildcard handler to catch any error that arises.
# Passing exceptions in tuple
try:
    # Do Something
except(TypeError, NameError, ValueError):
    # Handle Exceptions

# Separately handling exceptions
except TypeError:
    # Do this
except NameError:
	# Do this
except ValueError:
    # Do this
except:
    print("Unexpected Error: ", )

You can also choose not to do anything or silently pass after catching the error in the except clause.

try:
    # Do Something
except TypeError:
    pass	# Do nothing

However, it is a pretty bad idea to do nothing in the wildcard exception clause. The wildcard exception clause allows all sorts of errors to pass away, which becomes a headache when you are trying to debug in a large application silently. Don't do it, ever. Seriously.

try:
    # Do something
except:
    pass	# VERY BAD IDEA. DON'T DO IT.

What's the output of the below script?

try:
	a = [1, 2, 3] - [1, 2]
	print(a)
except ValueError:
	print("Something went wrong")
except NameError:
	print("Something else went wrong")
except:
	print("Something really went wrong")
  1. Something went wrong.
  2. Something else went wrong.
  3. Unknown thing went wrong.
  4. [3]

The else clause

The try-except exception handling can also have an else clause that Python executes when it encounters no exceptions.

The else clause must follow except clauses. We can see the general syntax of using the try-except-else clause in the code listing below.

try:
    # Do something
except Exception, e:
    # Catch Errors
else:
    # Execute this code if no exception is raised.

An instance of the try-except-else clause is shown below.

#  Block 1
try:
    print("Trying to remove 3 from the list [1,2,3]")
    a = [1,2,3] - [3] # Will result in TypeError
except TypeError:
    print("Subtracting two lists directly is not supported")
else: # Will not be executed
    print("Removed 3 from the list successfuly")

#  Block 2
try:
    print("Trying to remove 3 from the list [1,2,3]")
    a = [1,2,3].remove(3) # Will be executed without exception
except:
    print("Unexpected Error occured")
else: # Will be executed
    print("Removed 3 from the list successfuly")
TypeError.py

The output of TypeError.py is shown below:

Trying to remove 3 from the list [1,2,3]
Subtracting two lists directly is not supported
Trying to remove 3 from the list [1,2,3]
Removed 3 from the list successfully

In the above code-listing :

  • the first try clause tries to remove two lists, which causes the TypeError Exception to be raised. As Python raises an exception from the try block, it doesn't execute the else clause for the first block.
  • The second try clause executes successfully without exceptions; therefore, Python executes the else block.

What's the output of the following script?

try:
	a = [1, 2, 3, 4]
	b = [1, 2, 3]
	a.remove(b)
else:
	print("Everything ran successfully")
except ValueError:
	print("Something went wrong here")
except SyntaxError:
	print("Wrong Syntax")
except:
	print("Unexpected Error Occurred")
  1. Unexpected Error Occurred
  2. Wrong Syntax
  3. Something went wrong here
  4. Raises SyntaxError.

finally clause

The try statement can take an additional finally clause, which Python executes irrespective of whether it encounters an exception. Some things to note:

  • If the except clause handles a raised exception, the finally clause executes after handling the exception.
  • If an exception is raised but not handled, the finally clause executes, and the exception is re-raised.
  • If Python encounters no exceptions, finally code block executes nonetheless.

Primarily, we use the finally clause to handle external resources such as files, network connections, or databases. We need to free these resources regardless of whether an operation is successful or not. These are cleanup operations after the execution of statements.

We can see the general syntax of using a try-except-else-finally statement block in the following code listing.

try:
    # Do Something
except Exception as e:
    # Handle Exceptions
else:
    # Executes if no exception is raised
finally:
    # Executes always

Which of the following statements will not be printed after the script's execution?

try:
	a = [1, 2, 3, 4]
    print(a)
	b = [1, 2, 3]
	a.remove(b)
except ValueError:
	print("Something went wrong here")
except:
	print("Unexpected Error Occurred")
else:
	print("Everything ran successfully")
finally:
	print("Phew!")
  1. Something went wrong here
  2. Phew
  3. Everything ran successfully
  4. [1, 2, 3, 4]

raising exceptions using raise

Earlier, we saw how useful these error messages have been. The exceptions raised by Python give clues and hints to what went wrong. The same is pretty valuable when you write your Python programs with informative exceptions.

When you raise information exceptions, your application users can quickly figure out what caused their errors. In Python, you can raise exceptions with the raise statement. For instance,

>>> raise RuntimeError("Oops, something went wrong")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
RuntimeError: Oops, something went wrong

To understand how this is useful, let's revisit the function that gives the factorial of a given number. We can write the factorial of a number as follows:

>>> def factorial(n):
...     if n == 0:
...         return 1
...     return n*factorial(n-1)

Now, let's use the same function to get factorial numbers.

>>> factorial(5)
120
>>> factorial(7)
5040
>>> factorial(-2)				# Error will be raised for negative numbers
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 4, in factorial
  File "<stdin>", line 4, in factorial
  File "<stdin>", line 4, in factorial
  [Previous line repeated 995 more times]
  File "<stdin>", line 2, in factorial
RecursionError: maximum recursion depth exceeded in comparison

Our factorial() works well for positive integers; however, it will throw an exception when someone uses a float or a negative integer. For someone who is using our function, the RecursionError is not a useful message. Therefore, we will rewrite our factorial function to raise an error when a negative number or a floating-point number is given.

>>> def factorial(n):
...     if n == 0:
...         return 1
...     if n < 0:
...         raise ValueError("Number cannot be negative")
...     if type(n) == float and not n.is_integer():
...         raise ValueError("Number cannot be decimal")
...     return n*factorial(n-1)

In our function factorial(), we made the following changes:

  • It raises a ValueError exception when the argument number is negative.
  • It raises a ValueError exception when the argument number is a non-integer float.

Raising these two errors will help the users to get more useful error messages.

For instance,

>>> factorial(2.3)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 7, in factorial
ValueError: Number cannot be decimal
>>> factorial(-2)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 5, in factorial
ValueError: Number cannot be negative

That's how we use the raise statement to raise meaningful errors.

This brings us to the end of the chapter.

In the next chapter, we will start looking at some of the coolest things Python offers: Iterators, Generators, and Comprehensions.


Python documentation for built-in errors ↩︎