Control structures#
Now that we have learnt the different types of data that we can use in Python programs, it is time to start working with them in statements that perform actions. Using flow control, we may be able to repeat something written in a statement for a number of iterations or act in one way or another depending on the fulfillment of a condition.
A note on indentation#
In Python, the way that a program looks is very much determined by indentation. This differs from what we will find in other languages like Fortran or C, where formatting is not so important. In this chapter we will use indentation exhaustively, because as you will see it is essential for flow control.
Let’s look at an if
statement in
Python:
if x < 12:
print (x)
As you can see, we have indented the print
statement four positions to the right.
Had we not done this, the code simply would not run.
Exercise
Explore what happens when you do not respect indentation. Can you use more or less spaces? Can you write the code in a single line?
Different levels of indentation start where
we have colons (:
), and these have to be
indented with respect to the previous level
of indentation. Be
careful not to add white spaces where they do not
belong and use consistently tabs or spaces
(4 spaces is the preferred option in
PEP8).
Hint
This may feel like a nuisance if you have experience in some other programming language, but will help orient yourself when reading code in the long run.
Conditionals (if statements)#
In many cases, we want to execute a piece of code depending on the results of one or more conditions. We will call this type of statement logical tests. This is one of the main control flow structures in Python and most other programming languages.
The basic syntax in Python is as follows:
if condition1:
# Actions that take place if condition1 is True
...
elif condition2:
# Actions that take place if condition2 is True
...
else:
# Actions that take place if none of the above is True
...
Logical tests and conditional operators#
At this point, some words on logical tests and conditional operators are in order.
Logical tests are expressions that evaluate to True
or False
and correspond to variables of type bool
(Booleans, as seen in the
previous chapter).
Logical tests are built using conditional operators.
The most common ones are ==
(equal), !=
(not equal), >
(greater than),
<
(less than), >=
(greater than or equal to), <=
(less than or equal to).
As you would expect, these comparison operators give the following results:
1 == 2 # False
1 != 2 # True
1 < 2 # True
1 > 2 # False
Note
There is another operator, is
, that is similar to ==
but checks if two variables
are the same object (point to the same address in memory).
In general, for two different variables with the same value, this operator evaluates to False
.
For instance, if we compare two different float variables with the same value.
x = 1.0
y = 1.0
x == y # True
x is y # False
But this is not always the case for integers, because Python caches the most commonly used integers, so that they are the same object in memory.
x = 1
y = 1
x == y # True
x is y # True
# But with larger integers this is not the case:
x = 1554223641
y = 1554223641
x == y # True
x is y # False
So, in general, we use ==
to compare values (i.e., what we normally intend to do) and is
to compare objects.
Another useful operator we have already seen is in
(a membership operator), which checks if a value is
contained in a list, tuple or dictionary (or a string).
For instance:
1 in [1, 2, 3] # True
1 in [2, 3, 4] # False
Logical tests can be combined using the logical operators and
, or
and not
.
They work as follows:
True and False # False
True or False # True
not True # False
not False # True
Finally, it is also worth mentioning that some non-boolean values can be evaluated
as True
or False
. For instance, the number 0
is evaluated as False
, while
any other number is evaluated as True
. Similarly, the empty string ""
is evaluated
as False
, while any other string is evaluated as True
. An empty list also evaluates
as False
, while any other list evaluates as True
.
We can directly use such non-booleans as logical tests within if
statements, which are
first converted to booleans. The resulting boolean can be made explicit using the
bool
function.
bool(0) # False
bool(1) # True
bool("") # False
bool("Hello") # True
bool([]) # False
bool([1, 2, 3]) # True
Exercise
Write a code that uses non-booleans in logical tests, and check how are they evaluated. You can follow the scheme below:
if variable:
print("This variable evaluates to True")
else:
print("The variable evaluates to False")
Loops (for / while)#
Loops play a crucial role in automating repetitive tasks. Many times, we will want to do the same thing over and over. Occasionally, we may want to stop when a condition is or is not fulfilled. In these situations, loops become essential tools for processing data and performing calculations.
Imagine you have a list of numbers, and you want to calculate their sum.
numbers = [1, 2, 3, 4, 5]
To find the sum of these numbers, you could manually add them up:
numbers = [1, 2, 3, 4, 5]
Sum = numbers[0] + numbers[1] + numbers[2] + numbers[3] + numbers[4]
However, what if you had a list with hundreds or thousands of numbers? Performing this calculation manually would be impractical. This is precisely where loops come into play.
For loops#
Let’s explore a for
loop in Python to calculate the sum of numbers in a list.
The for loop iterates over each element
in the list and accumulates the sum.
numbers = [1, 2, 3, 4, 5]
total_sum = 0 # Initialize the sum
for num in numbers:
total_sum = total_sum + num # Add each number to the sum
print("The sum of numbers is:", total_sum)
Here, the for
loop allows us to automate the process of summing the elements in the list,
making it efficient and scalable. Note that the indentation is required and the colon at the end of the line.
The range()
function is often used in conjunction
with for
loops to create a sequence of numbers that you can iterate through. The range()
function generates a sequence
of numbers based on the parameters you provide. It has three possible forms:
range(stop)
: Creates a sequence from 0 up to (but not including) thestop
valuerange(start, stop)
: Creates a sequence from start up to (but not including) the stop value.range(start, stop, step)
: Creates a sequence from start up to (but not including) the stop value, with a specified step size.
You then use a for
loop to iterate over the elements in the generated sequence. In each iteration of the loop,
a variable takes on the value of the current element in the sequence.
Here’s a basic example:
for i in range(5):
print(i)
In this example, the range(5)
generates a sequence from 0 to 4, and the for loop iterates through these values.
0
1
2
3
4
While loops
Now, we consider a scenario where you want to find the sum of numbers until a certain condition is met. For instance, you want to find the sum of numbers until the cumulative sum exceeds 10.
numbers = [1, 2, 3, 4, 5]
total_sum = 0
index = 0
while total_sum <= 10:
total_sum = total_sum + numbers[index]
index = index + 1
print("The sum of numbers until the cumulative sum exceeds 10 is:", total_sum)
Hint
Take care when using while
loops, as they can easily lead to infinite loops.
Exercise
Write a code snippet that calculates and prints the sum of all even numbers from 1 to a given positive integer using both a for loop and a while loop. Compare the results obtained from both loops.
Iterators and generators#
Iterators and generators are both mechanisms in Python for working with sequences of data. They allow you to iterate through a collection of items one at a time. They both allow you to loop through a sequence of values, but they differ in how they are implemented and when they generate values.
Iterators#
An iterator is an object that represents a stream of data.
It allows you to traverse a sequence of elements one at a time without loading the entire sequence into memory.
In Python, an iterator is implemented using two methods: __iter__()
and __next__()
.
__iter__()
: Returns the iterator object itself and is called when you create an iterator from an iterable object (e.g., a list or a custom class).__next__()
: Retrieves the next element from the iterator and raises theStopIteration
exception when there are no more elements.
Below is an example of a simple iterator:
lista = ['Charles', 'John', 'Python', 'pato', 3]
a = iter(lista)
print(a)
print(next(a))
while True:
try:
print(next(a)) # fetch the position in the array
except:
break # exit the loop on error
You can also create your own custom iterators by defining classes
with __iter__()
and __next__()
methods, or you can use built-in Python functions like iter()
and next()
with iterable objects.
class Doublen:
def __init__(self, start, end):
self.current = start
self.end = end
def __iter__(self):
return self
def __next__(self):
if self.current > self.end:
raise StopIteration
x=2*self.current
self.current += 1
return x
my_iterator = Doublen(1, 8)
print(my_iterator)
for ii in my_iterator:
print(ii)
The output will be
<__main__.Doublen object at 0x106b4de10>
2
4
6
8
10
12
14
16
We will look in greater detail into how to write your own classes in the Object Oriented Programming chapter.
Generators#
A generator is a concise way to create iterators.
The main difference is that a generator is a function that contains one or more yield
statements.
When you call a generator function, it returns a generator object, which you can use to iterate through the values produced by the yield
statements.
Generator functions use the yield
keyword to yield values one at a time, and they automatically retain their execution state between calls.
You may create a generator function by defining a function with one or more yield
statements.
When you call this function, it doesn’t execute immediately but returns a generator object.
You can then iterate through the values by
calling the generator’s __next__()
method or using a for
loop.
def number_generator(start, end):
current = start
while current < end:
yield current
current = current + 1
my_generator = number_generator(1, 5)
print(my_generator)
for num in my_generator:
print(num)
In summary, both iterators and generators allow you to work with sequences of data, but generators provide a more concise and memory-efficient way to create iterators, especially for situations where data generation is dynamic or resource-intensive.
Comprehensions#
Lists and dictionaries can be created in a very compact way using comprehensions. They consist on one-liner expression, where a list (or dictionary) is created based on another iterable object. A condition can be added to filter the elements that are included in the new list. The syntax is as follows:
new_list = [expression for item in iterable if condition == True]
For instance, we can create a list with the squares of the numbers from 1 to 10 as follows:
squares = [x**2 for x in range(1, 11)]
If only the squares of even numbers from 1 to 10 shall be included, we can tune the output adding a condition:
squares = [x**2 for x in range(1, 11) if x % 2 == 0]
Comprehensions can also be used to create dictionaries. For instance, we can create
squares = {x: x**2 for x in range(1, 11)}
Note that we need to iterate over keys (x
in our example) and values (x**2
). We can for instance
run over tuples that contain (key, value)
pairs.
In the following example, we generate in-place a list of tuples containing the letters
and their position in the alphabet:
letters = { chr(j):i for (i,j) in enumerate(range(65,91), start=1) }
print('A:',letters['A'])
print('Z:',letters['Z'])
A: 1
Z: 26
In the above example, enumerate
is a built-in function that returns a list of tuples
(actually an iterator) on top of an iterable object (in this case the iterator generated
by range()
), adding a counter along with each element.
The counter is started at 0 by default and can be tuned with the start
argument.
We also used the chr()
function
converts an integer to the corresponding ASCII character
(the ASCII codes for upper case letters go from 65 to 90).
Exercise
Write a list comprehension that generates a list of squares for odd numbers from 1 to 10.
Error and exception handling#
While running your code, some errors are bound to occur.
For example, when you divide
a value by zero or when you try to open a non-existing file…
Python raises a specific object that identifies the error.
These are called exceptions.
Exceptions are a crucial mechanism for dealing with errors, unexpected behavior, and exceptional cases in Python programs.
For instance, the ZeroDivisionError
exception is raised
when trying to divide by zero.
1/0
---------------------------------------------------------------------------
ZeroDivisionError Traceback (most recent call last)
Cell In[2], line 1
----> 1 1/0
ZeroDivisionError: division by zero
And FileNotFoundError
is raised when trying to open a non-existing file.
f = open('missing_file.txt')
---------------------------------------------------------------------------
FileNotFoundError Traceback (most recent call last)
Cell In[3], line 1
----> 1 f = open('missing_file.txt')
File /usr/share/miniconda/envs/__setup_conda/lib/python3.8/site-packages/IPython/core/interactiveshell.py:284, in _modified_open(file, *args, **kwargs)
277 if file in {0, 1, 2}:
278 raise ValueError(
279 f"IPython won't let you open fd={file} by default "
280 "as it is likely to crash IPython. If you know what you are doing, "
281 "you can use builtins' open."
282 )
--> 284 return io_open(file, *args, **kwargs)
FileNotFoundError: [Errno 2] No such file or directory: 'missing_file.txt'
As shown above, when an exception is raised, the code stops running and an informative error message is printed. Some context information is also provided, e.g. the call stack, which shows the sequence of function calls that led to the error.
Built-in exceptions are listed in the Python documentation. Some of the most commonly raised exception classes are:
ZeroDivisionError
: raised when trying to divide by zero.NameError
: raised when a variable is not defined.TypeError
: raised when an operation or function is applied to an object of inappropriate type.ValueError
: raised when a built-in operation or function receives an argument that has the right type but an inappropriate value.IndexError
: raised when trying to access an element in a list using an invalid index.OSError
: raised when a system function returns a system-related error, including I/O failures such as “file not found” or “disk full”.KeyError
: raised when a dictionary key is not found.FileNotFoundError
: raised when trying to open a non-existing file.ImportError
: raised when an import statement fails.SyntaxError
: raised when there is an error in Python syntax.IndentationError
: raised when indentation is not correct.
Errors raised while running the code can be caught and handled using the try
and except
statements.
The basic syntax is as follows:
try:
# Code that may raise an exception
...
except ExceptionName:
# Code that is executed if the exception ExceptionName is raised
For instance, we can handle a division by zero error as follows:
try:
1/0
except ZeroDivisionError:
print("Division by zero!")
As you see, when an exception is handled, the code continues running without crashing the program. However, we may be missing some information from the original error message. In this type of situations we may use a slightly different syntax:
try:
file = open("non_existent_file.txt", "r")
content = file.read()
file.close()
except FileNotFoundError as e:
print(f"An error occurred: {e}")
As you see, using except <ExceptionType> as e
we are catching the exception and
printing the original error message.
The try
block contains the code that might raise an exception,
and it can be followed by multiple except
statements to handle different
types of exceptions with specific actions.
If no exception is specified, the except
statement
will handle any exception.
In the example below, we handle a division by zero error and a name error specifically, and then any other exception:
try:
print(x)
except ZeroDivisionError:
print("Division by zero!")
except TypeError:
print("Incorrect type!")
except:
print("Something else went wrong!")
# In this case we had a NameError, but we don't handle it specifically
Something else went wrong!
Moreover, else
and finally
statements can also be added to the
try
statement. The else
statement is executed if no exception is raised, and the
finally
statement is always executed, regardless of whether an exception is raised or not.
The general syntax is as follows:
try:
# Code that may raise an exception
...
except ExceptionName1:
# Executed if the exception ExceptionName1 is raised
...
except ExceptionName2:
# Executed if the exception ExceptionName2 is raised
...
except:
# Executed if any other exception is raised
...
else:
# Executed if no exception is raised
...
finally:
# Always executed, regardless of whether an exception is raised or not
...
We can also raise exceptions in our code using the raise
statement, using one of the built-in exceptions or a custom exception (we are not going to cover custom exceptions in this course).
For instance:
x = -1
if x < 0:
raise ValueError("x cannot be negative")
---------------------------------------------------------------------------
ValueError Traceback (most recent call last)
Cell In[5], line 3
1 x = -1
2 if x < 0:
----> 3 raise ValueError("x cannot be negative")
ValueError: x cannot be negative