Programming in Python

Please note this is still under active development. Please contact me if you spot any errors.

by Sam O'Neill

This is an introductory set of units for Python that have been created as the notes for the introductory Programming unit of the undergraduate computing course, 4CM523 - Programming.

It is designed to be reasonably comprehensive and should be read in order. Each unit has a number of lessons each with their own exercise.

I hope you enjoy the course

— Sam

Executing Code and Completing Exercises

If you are taking this course then you will be completing exercises and running code via Visual Studio Code. You will be shown in the labs how to do this. You may see references to Visual Studio Code and main.py in the text, if you prefer to use your own IDE or are comfortable in Python, by all means run the code as you wish.

You can also follow the instructions here.

Alternative ways to run code and do exercises are via the following online Python interpreter - Online GDB Python Compiler.

It is also recommended that you make use of the fantastic Python Tutor. This is an excellent way to view how the code runs step by step. You can also watch a number of excellent videos on their YouTube channel.

Introduction

We will be using the Python programming language in this course.

  • Python is a commonly used beginner programming language since it is considered to be more easily readable and has a simpler syntax than other mainstream languages. Syntax refers to the rules of a programming language; similarly to how grammar refers to the rules of a human language.
  • Python is also a relatively powerful language. It is used to support YouTube, Google, Instagram, Reddit, and other popular software programs.
  • Python has consistently been in the top three most popular programming languages over the past two decades. Other languages that have been consistently popular include Java, C, and Javascript.

In this introductory course, you will be gaining a solid foundation in the Python programming language. The programming concepts you learn will also be helpful for any future programming languages you learn in the future.

How to use this book

You should work through each of the lessons and do the TASK at the end of the lesson.

I would recommend pasting the code snippets into Python Tutor. This is an excellent way to view how the code runs step by step.

You can also refer to the many other books, tutorials and videos found online. An especially useful quick reference is the W3Schools Python Tutorial.

Unit 1 - Getting Started

This unit introduces some things that we need to get up and running. You will learn how to run a basic program, about basic exceptions in Python and about the fundamental data types that Python has to offer - these crucial allow us to handle data in our programs, e.g. storing a number or a persons name.

Hello World

It is customary that the first program that you write in a programming language is the "Hello World" program.

So as not to break this great tradition, let's start our Python journey there!

Python lets you output text to the Terminal.

You can output a piece of text using the print() function (more on functions later in the course).

Normally we will write out python programs in a source file (script) which is where we will write our code. You will need to create and save this file in Visual Studio Code. Make sure it has the .py extension.

1. Hello World!

For now, we can output a piece of text to the terminal by putting the following line into a Python file.

print("Hello World!")

Once you have added this line, hit the Run button at the top of Visual Studio Code.

Run Button

You should then see Hello World! outputted to the terminal.

1.1 Wow! What Just Happened?

When you hit the "Run" button, Python reads your code from the source file and immediately executes it, showing the result in the terminal. Python is an interpreted language, which means that you don’t need to compile your code before running it.

If you would like to execute manually without the run button. Go to the Terminal and type:

python <NAME OF YOUR FILE>.py

This will then convert your code for execution and then run it.

1.2 Tasks

Throughout this course, you'll encounter tasks that you should complete. These are practical exercises designed to reinforce the concepts you've just learned. Some tasks will be more challenging than others, so focus on completing the easier ones before tackling the harder ones.

A TASK will always look like the one given below.

TASKs will normally appear at the bottom of the page


=== TASK ===

Create a Python program in a new file that outputs "Hello World! is cool!" to the Terminal using the print() function.

Comments

In programming, it is important to provide comments in your code to explain what you're doing. This is helpful for both yourself and others who might read your code later.

Comments are not interpreted by Python, so they don't affect how your code runs. Python supports two types of comments:

  • Single Line Comments
  • Multiple Single-Line Comments

1. Single Line Comments

Single-line comments start with a # (hash symbol). Everything following the # on that line will be ignored by Python. For example:

# This is a single-line comment (This won't run).
print("I am learning about comments")

Notice that comments are usually shown in a different color in your code editor.

2. Multiple Single-Line Comments

Python doesn't have a specific syntax for multiline comments. Instead, to comment out multiple lines, you need to place a # at the start of each line. For example:

# This is a single-line comment.
# This is also a single-line comment.
print("I am learning about comments")

If you highlight multiple lines of code and press Ctrl + / (or Cmd + / on macOS), the editor will automatically add a # at the start of each line, turning them into comments.

Note: While you might see triple quotes (''' or """) used for what looks like multiline comments, these are actually docstrings used to document functions or classes. They should not be used for regular comments.

3. A Note on Using Comments

There is some debate about how often to comment, and different programmers have different styles. Here's some basic advice:

3.1 Comment When It’s Useful

For example, this comment is not useful:

# This line prints out Hello World!
print("Hello World!")

Everyone can see what the code does, so this comment doesn’t add value. Only comment when it's not obvious what a piece of code does.

3.2 Be Professional

Your comments reflect on you, especially if others are reading your code. Avoid unprofessional comments, like:

# This code sucks!
print("Hello World!")

or:

# Workaround for Tyrion being a traitor and going off with that dragon lady!
print("Hello World!")

3.3 Keep Comments Brief and Clear

Make sure your comments are concise and to the point. If you need to write a longer explanation, split it into multiple lines. For example:

# The following is a comment that has the job of telling the person reading the source code that this prints Hello World!
print("Hello World!")

would be better as

# Prints Hello World!
print("Hello World!")

or:

# The following is a comment that has the following job.
# To tell the person reading the source code that this prints Hello World!
print("Hello World!")

=== TASK ===

Copy the following code into a new Python file.

# This is a single line comment in Python

print("Replace this line with TASK 1")

print("Hello")
print("World")
print("with Comments")
  1. Modify the code so that the first print statement outputs: Single line comments start with #.
  2. Highlight lines 5-7 and press Ctrl + / (or Cmd + / on macOS) to comment out those lines.

Your final output in the terminal should look like this:

Single line comments start with #

Objects, Types, Operators, and Expressions

To do anything useful in our programs, Python will need to represent data such as numbers, words and booleans. We will learn more about these in Units 2 and 3.

For now we will introduce the most common 4 built-in data types.

1. Objects and Types

In Python, everything you work with is an object. An object can be a number, a word, or any other type of data. Each object has a specific type, which tells Python what kind of data it is and what operations can be done with it.

Python has several built-in data types, which it uses to represent data internally. We'll start by looking at four common ones:

Data TypeDescription
intAn integer. This is a whole number, it can be positive, negative or zero. e.g. 5
floatA decimal number. e.g. 3.14
strText. It consists of individual characters. Strings are enclosed in single quotation marks ' or double quotation marks ". e.g. "Hello World"
boolThe values True or False. Used to make decisions, more in Unit 3

A very useful function in python that we can use is type(). The type() function is a useful tool to check what type of data you're working with. Understanding the type of an object helps you know what operations can be performed on it and how Python will handle it in your program.

x = type(10)
print(x)

Here we are assigning the result of type(10) to the variable x. Basically this stores the result in memory in a name called x. We can then print out what is stored in x.

You could do this in one line like so.

print(type(10))

We will stick to using x as it is more readable.

If you run either of these you will see the output:

<class 'int'>

This is python telling you that 10 is an object of type int. For now, read class as type.

Try it for these other objects.

x = 10.3
print(x)
print(type(x))
x = "This is a string"
print(x)
print(type(x))
x = True
print(x)
print(type(x))

This first gets the type of the object, here a str and then passes that to the print() function.

2. Operators and Expressions

We can combine objects with operators to form expressions. When evaluated, these expressions produce a new object.

For example, we can combine the objects 10 and 5 with the + operator,

x = 10 + 5              # new object 15 assigned to x
print(x)                # prints out 15
print(type(x))          # prints out <class 'int'>

to create a new object 15 of type int.

All built-in data types have operators that we can use to form expressions.

For example, we can test if two expressions are equal with the == comparison operator.

x = 10 + 5 == 3 * 5     # assign the result of comparing 10 + 5 and 3 * 5. 
print(x)                # prints out True
print(type(x))          # prints out <class 'bool'>

This will return True. Python knows how to test whether two numbers are equal and returns you a new object of type bool.

We will cover operators for numbers, strings, and bools in their respective lessons in this unit.

3. Help Function

The help() function gets information about an object (it can also be used for other things.)

Try typing the following into the terminal or a Python file:

help(str)

You will need to click in the terminal and press Ctrl + c to exit this.

You can also type python into the terminal to enter the Python Interative Shell and then type help(str) and press Enter. You can open this by typing python into a terminal.


=== TASK ===

Create a new Python file.

Edit the file to output the type of the following expressions. You will need to use the print() and the type() function.

Make sure you have read all of the above before attempting this (especially the end of Section 1).

12 + 5
5 + 3.0
12 / 5
12 + 5 == 5 * 3.0

The output of your program should be:

<class 'int'>
<class 'float'>
<class 'float'>
<class 'bool'>

Basic Exceptions

Every time that you try and run your code, Python will first try to parse your code, if it encounters something that it does not recognise, it will raise an exception.

This is a warning to the coder that they have done something invalid and the program will not run. Please read the exception and try to understand it.

The three main exceptions you may encounter for now will be:

  1. SyntaxError
  2. NameError
  3. TypeError

1. SyntaxError

A SyntaxError is perhaps the most common kind of complaint you get while you are still learning Python.

It means you have entered something that Python does not understand, this is commonly a spelling mistake or something you have missed.

NOTE: Always pay attention to the error message, it is telling you what is wrong with your code!

Try running the following code:

print(hello world)

You will get the following SyntaxError because of some missing "" around the string.

  File "<stdin>", line 1
    print(hello world)
                ^
SyntaxError: invalid syntax

2. NameError

A NameError occurs when a local or global name is not found. This refers to variables, functions, and other things like modules and classes.

Basically, Python reserves particular words such as print.

Try running the following code:

printf("Hello World!")

You will get the following NameError because we have misspelled print.

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

3. TypeError

A TypeError occurs when the data types of objects in an operation are invalid. For example, trying to divide a number by a string.

Try running the following code:

100/"5"

You will get the following TypeError because we are trying to divide / an int by a str. These two types don't play together well!

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unsupported operand type(s) for /: 'int' and 'str'

=== TASK ===

  1. Copy the following code into a new Python file.
# You need to fix the following lines
# Run the code and then use the error messages to fix each line

# This line has a SyntaxError
print(hello world)

# This line has a NameError
printf("Hello World!")

# The following lines cause a TypeError
int1 = 100
str1 = "10"
print(int1 / str1)
  1. You will see a SyntaxError on line 5 because of some missing "" around the string.
print(hello world)

Fix this so that it prints out hello world.

  1. Once you have completed this run the code. You will see a NameError on line 8 because printf is not a valid name.
printf("Hello World!")

Fix this so that it prints out Hello World.

  1. Once you have completed this run the code. You will see a TypeError on line 13 because we are tring to divide / an int by a string.
int1 = 100
int2 = "10"
print(int1 / int2)

Fix this so that it prints out 10.0.

References

Python Exceptions

Indentation, WhiteSpace, and Blank Lines

Python is unusual in that it requires indentation for lines of code. This will make more sense when we do if statements, while loops, and for loops.

For now, all you need to know is:

  • Do not put whitespace (spacing or tabs) in front of a line of code.
  • Python doesn't care about blank lines.

=== TASK ===

Copy the following code into a new Python file.

# This line is not indented properly. Fix this
  print("Indentation matters in Python")

# You will notice that we have blank lines
# Python doesn't care about these!

# There is extra whitespace inside the print statement 
# Python doesn't care about this
print("Hello World!"  )  

Run the code, and you will see an IndentationError exception on line 2.

This is due to the whitespace at the beginning of the line.

Fix this.

NOTE: You may also see that the code has a red squiggly line. This is highlighting an error!

The Hello User Program

This is a mini-program and is presented to help you understand a concept. This is a very simple program that asks the user to input their name and their age.

It then says hello back to the user.

You will need to create a new file and paste this in to have a play.

name = input("Hello, what is your name?\n")
age = input("What is your age?\n")
print(f"Hello {name}, your age is {age}.")

Key Takeaways

We see 3 things here that are of interest:

  1. The input() function. This asks the user for some input via the terminal.
  2. We assign (=) the input to a variable (name and age)
  3. We print back out to the user using an f-string.

The next lesson on Simple I/O (Input/Output) will explain these in more detail.

Simple I/O (Input/Output)

For most of this course, we will work with the terminal. For your programs to be of more use, we need to have a way for the user to interact with them.

1. User Input

You can ask the user for input via the terminal using the input() function.

For example,

input("Please enter something:\n")

will print out Please enter something and then a blank line (this is because of the \n escape character in the string).

The user can then enter something. Try this in a Python file.

2. Storing User Input

The above code does not store the input that the user enters. To do this you need to assign it to a variable. Update the Python file as follows:

input_string = input("Please enter something:\n")
print(f"You entered - {input_string}")

Now the input is stored in the variable input_string and we can use it in our program.

You can think of input_string as a box that stores the input from the user. We can then get the contents of the box at different points of our program.

The line,

print(f"You entered - {input_string}")

uses a Python f-string, which just lets you put the contents of the variable into the string. Here we put the contents of input_string in between the curly braces {}.

We will discuss variables and f-strings a lot more in the next unit.

3. Casting the Input

Whenever you get input from the user, it will be of type str. Sometimes we wish to convert this to a number or other type. To do this we can cast the variable to another type. We can cast to an int using the int() function.

input_string = input("Please enter a number:\n")
x = int(input_string)
print(f"{x} + 5 = {x+5}")

The above code asks for a number, then casts the str to an int and then prints the result of adding 5 to the number.

Note that the line print(f"{x} + 5 = {x+5}") places the contents of x into the curly braces.

What happens if you don't enter a number? Copy the code into a Python file and have a play with this.

NOTE: We could have done the casting in one line.

x = int(input("Please enter a number:\n"))
print(f"{x} + 5 = {x+5}")

=== TASK ===

Create a new Python file.

Create a simple program that asks the user for a number and then prints out 10 times that number.

If the user enters 3 the program should work as follows:

Please enter a whole number:
3
3x10 = 30

If the user enters 10 the program should work as follows:

Please enter a whole number:
10
10x10 = 100

I have seen students try to solve this with an if statement (we haven't even introduced this yet!). Please don't do this, your program should handle any number and print out the result of multiplying it by 10. The two examples above are just two particular cases.

HINT: You will need to cast the input to an int.

Unit 2 - Basic Elements of Python

This unit looks at the basic elements of Python. It is really important that you get a good handle on these elements. That does not mean you have to memorise them, but you should understand them.

Programmers regularly switch languages and look up things. Your secret programming weapon is Google! You will find almost unlimited resources on Python, but knowing the right words to use in a search will certainly help you.

The lessons in this unit are:

  • Variables and Assignment
  • Statements vs Expressions
  • Numbers
  • Strings
  • Casting
  • String Indexing and Slicing
  • String Methods

Variables and Assignment

Variables are one of the most important things in programming languages. They allow us to assign a name to an object so we can store it for use later on in our program.

1. Assignment

We assign an object to a variable using the assignment operator =. For example,

pi = 3.14

assigns 3.14 to the variable named pi.

We can then use the variable in our programs. Consider the following program,

pi = 3.14
radius = 6
circumference = 2 * pi * radius
print(circumference)

this will print the value of the circumference of the circle with radius 6.

The point is that the line circumference = 2 * pi * radius now used the variables pi and radius to calculate the value of the circumference.

The following image illustrates the binding of the variables to the objects in memory. Note it isn't really how Python works internally, but it is good enough as a mental picture.

Variable Assignment

2. Reassignment

Python lets you reassign a variable to another object.

The following code,

pi = 3.14
radius = 6
circumference = 2 * pi * radius
print(f"The circle with radius = {radius} has circumference = {circumference}")

radius = 0

print(f"The circle with radius = {radius} has circumference = {circumference}")

will output,

The circle with radius = 6 has circumference = 37.68
The circle with radius = 0 has circumference = 37.68

because we reassigned the variable radius to the value 0, but we did not recalculate the circumference.

The following depicts the reassignment of the variable radius. Variable Reassignment Image reproduced from Chapter 2: Introduction to Computation and Programming Using Python.

3. Objects in Memory

Python stores objects in memory and when you assign a variable it binds that variable to the location of the object in memory.

You can find the unique id of an object by using the id() function. This refers to the object's memory address and will be different each time you run the program.

Input the following two lines into the terminal, remember to press enter after each line:

pi = 3.14
id(pi)

For now the id() function is a bit of a novelty. We will see later on that it can prove essential in debugging programs when we are editing mutable (changeable) types like lists.

Note that two objects can have the same id if one object has been removed from memory (Python automatically cleans up objects that are no longer used). Essentially the unique id is being re-used.


=== TASK ===

This should be relatively simple, consider it a freebie.

Copy the following code into a new Python file.

pi = 3.14
radius = 6
circumference = 2 * pi * radius
print(f"The circle with radius = {radius} has circumference = {circumference}")

# Reassign radius to the value 15
# Reassign circumference to the circumference of the circule with radius 15
# In addition to the existing print statement, print out the new circumference.

  • Reassign radius to the value 15
  • Reassign circumference to the circumference of the circule with radius 15
  • In addition to the existing print statement, print out the new circumference.

The output of the program should be:

The circle with radius = 6 has circumference = 37.68
The circle with radius = 15 has circumference = 94.2

Statements vs Expressions

So far you have already seen examples of both statements and expressions and your Python programs will be made up of both of these.

We will give a basic definition of the two, however, the real story is much more complicated.

1. Statements

Statements instruct Python to do something, that is the Python interpreter can execute the statement. To date, you have seen two types of statements: print and assignment.

For example,

print("Hello World!")

is a statement that instructs python to print the string "Hello World!" and,

int1 = 1

is a statement that instructs Python to assign 1 to the variable int1.

2. Expressions

Expressions differ from statements in that they evaluate to an object. They are a combination of objects and operators.

For example,

3 + 5 

combines the integers (objects of type int) 3 and 5 with the operator + and evaluates to the integer 8.

This example,

True and False

combines the boolean (objects of type bool) True and False with the logical operator and and evaluates to the boolean False.

3. Combining Statements and Expressions

Most of your code will tend to be a statement made up of expressions.

For example, you can assign the result of an expression to a variable. Consider the following statement (assignment)

a = 3 + 5 

Here 3 + 5 is an expression and the result, 8, is assigned to the variable a. The whole line is a statement that contains an expression.


=== TASK ===

A nice simple one.

Create a new Python file.

Print Statements instruct Python to do something

Print Expressions combine objects and operators and evaluate to an object

Print Statements can include expressions


Numbers

Python has three numeric types:

  • int
  • float
  • complex

Unless you are doing something specifically mathematical you will only use int and float. So we will cover just these in this lesson.

1. ints and floats

The following table provides a summary of int and float.

Data TypeDescription
intAn integer. There is no upper or lower limit to how high or low it can be (Python 3).
floatA decimal number. A double-precision floating-point number, equivalent to double in many other programming languages.

1.1 int

An int (integer) is simply a whole number such as 5 or -100. If you assign a whole number to a variable, python will automatically know that the type of object is an int.

Type the following two lines in the terminal:

a = 10
type(a)

You will see that the output confirms that a is of type int.

1.2 float

An float (floating point number) is a number containing one or more decimals, such as 5.3 or -100.43. If you assign a decimal number to a variable, python will automatically know that the type of object is an float.

Type the following two lines in the terminal:

a = 10.6
type(a)

You will see that the output confirms that a is of type float.

You can find out the minimum and maximum float using

import sys
print(sys.float_info.min)
print(sys.float_info.max)

On the Repl.it systems it is -2.2250738585072014e+308 to 1.7976931348623157e+308. Which is approximately -2.23*(10**308) to 1.80*(10**308)

That is astronomically big!

1.3 Dynamic Typing

You can combine an int and a float. Python will internally understand to convert the int to a float so that you can combine two floats.

For example, type the following into the terminal,

a = 1 + 3.3
print(a)
type(a)

the first line is adding an int and a float which results in an object of type float with the value 4.3.

2. Arithmetic Operators

Number objects can be combined with the following arithmetic operators.

OperatorNameExample
+Additionx + y
-Subtractionx - y
*Multiplicationx * y
/Divisionx / y
%Modulusx % y
**Exponentiationx ** y
//Floor Divisionx // y

All of +, -, * and / will be familiar to you. However, %, ** and // may not be.

2.0.1 Modulus (%)

The modulus operator is very useful for testing if a whole number is divisible by another whole number. It is an implementation of modulo (clock) arithmetic in mathematics.

If a number is divisible by another the output of x % y will be 0 (x and y can be positive or negative).

10 % 3 # output is 1 (not divisible)
10 % 2 # output is 0 (divisible)
14 % 4 # output is 2 (not divisible)
14 % 7 # output is 0 (divisible)

You will see this in computational mathematics, for more info look at Modulus Operator - Real Python

It can also be used to find the remainder, but be careful, this doesn't work in all circumstances. I would always use math.remainder().

import math # imports extra maths stuff
print(math.remainder(10, 3)) # remainder of 10/3 is 1
print(10 % 3) # output is 1. (% WORKS)

print(math.remainder(-10, 3)) # remainder of 10/3 is 1
print(-10 % 3) # output is 2. (% DOES NOT WORK)

print(math.remainder(10, -3)) # remainder of 10/3 is -1
print(10 % -3) # output is -2. (% DOES NOT WORK)

print(math.remainder(-10, -3)) # remainder of 10/3 is -1
print(-10 % -3) # output is -1. (% WORKS)

So it works when both numbers are positive or both numbers are negative.

My advice is don't use it for the remainder.

2.0.2 Exponentiation (**)

The exponentiation operator ** takes the number x (called the base) to the power of y (called the exponent).

2**3 # Evaluates to 8 (i.e. 2*2*2)
2**4 # Evaluates to 16 (i.e. 2*2*2*2)
5**2 # Evaluates to 25 (i.e. 5*5)
5**3 # Evaluates to 125 (i.e. 5*5*5)

2.0.3 Floor Division (//)

The floor division operator // takes the number x/y and rounds it down.

For example,

10/4 is 2.5 rounded down is 2. So,

10//4 # Evaluates to 2

-10/4 is -2.5 rounded down is -3. So,

-10//4 #Evaluates to -3

2.1 Order of Operations

When using arithmetic operators you need to be aware of the order in which Python evaluates an operator. This is known as the operator precedence.

For example,

3 + 5 * 2

is 16 right? If you enter this into the terminal you will get 13. Well done if you spotted this.

This is because the multiplication * operator has higher precedence than the addition + operator. Python is doing the following

# This is not code, we are manually evaluating to see how Python works with this expression

3 + 5 * 2     # (Evaluate 5 * 2)
3 + 10        # (Evaluate 3 + 10)
13            # (Final Object)

If you want to force the 3 + 5 to be evaluated first, then you need to use parentheses.

(3 + 5) * 2

This now evaluates to 16. Try it in the terminal.

The following table gives the arithmetic operator precedence. Higher entries have higher precedence.

OperatorName
()Parentheses
**Exponentiation
*, /, %, //Multiplication, Division, Modulus, Floor Division
+, -Addition, Subtraction

2.2 Left-to-right Evaluation

You will notice from the table that some operators have the same precedence as each.

What happens then with the following:

5 - 2 + 1

If you try this in the terminal you will get the answer 4. However, this could have been read as either 3 + 1 or 5 - 3 depending on whether you evaluated the - or + first.

Python follows the left-to-right convention. That is, if two operators are of the same precedence, then Python will evaluate the leftmost first. Hence:

# This is not code, we are manually evaluating to see how Python works with this expression

5 - 2 + 1     # (Evaluate 5 - 2)
3 + 1         # (Evaluate 3 + 1)
4             # (Final Object)

3. Comparison Operators

You can also compare two numbers and they will result in a bool object. Either True or False.

OperatorNameExample
==Equalx == y
!=Not equalx != y
>Greater thanx > y
<Less thanx < y
>=Greater than or equal tox >= y
<=Less than or equal tox <= y

For example, the following expressions evaluate to:

ExpressionResult
3 < 5True
3 > 3False
3 >= 3True
3 == 5False
3 != 5True

3.1 Order of Precedence

All the comparison operators given above have lower precedence than the arithmetic operators.

OperatorName
()Parentheses
**Exponentiation
*, /, %, //Multiplication, Division, Modulus, Floor Division
+, -Addition, Subtraction
==, !=, <, >, <=, >=Comparison Operators

Therefore something like the following expression,

3 + 5 == 3 * 5

is evaluated as follows:

# This is not code, we are manually evaluating to see how Python works with this expression
3 + 5 == 3 * 5   # Evaluate 3 + 5
8 == 3 * 5       # Evaluate 3 * 5
8 == 15          # Evaluate 8 == 15
False

Note: I would always want to convey my intention and not rely on the order of precedence, it is easy to forget. So I would rewrite the above as:

(3 + 5) == (3 * 5)

This is identical, but it tells the reader more explicitly that the stuff in the parentheses is evaluated first.


=== TASK ===

Copy the following code into a new Python file.

# DO NOT TOUCH THE FOLLOWING LINES 4, 5 and 6
# THESE ARE USED FOR THE INPUT. 

a = int(input("Please enter a number: ")) # leave this line alone
b = int(input("Please enter a number: ")) # leave this line alone
c = int(input("Please enter a number: ")) # leave this line alone

# TASK 1. 
# print out the type of 10.3 + 5

# TASK 2.
# print(a + b * c) # Correct this so that the ``+`` is evaluated first
# print(a**b+c)    # Correct this so that the exponent is b+c

# TASK 3. 
# print the output of a < b

# TASK 4. 
# print the output of (a < b) == (a <= b)

This reads in three whole numbers and stores them in variables a, b and c. Try running the code and it will ask you for three numbers.

  1. Print the type of 10.3 + 5

  2. Fix the following two lines:

print(a + b * c) # Correct this so that the ``+`` is evaluated first
print(a**b+c)    # Correct this so that the exponent is b+c
  1. Print the result of a < b

  2. Print the result of (a < b) == (a <= b)


If you enter 2, 3 and 2 into your program then a=2, b=3 and c=2. If you have made the above changes correctly, your program should output:

<class 'float'>
10
32
True
True

References

W3schools Python Operators

Precedence and Associativity of Operators in Python

Strings

This lesson introduces you to the basics of strings in Python.

Python has a built-in datatype str known as a string, you can think of this as a piece of text.

1. Basic Printing of Strings

Strings can be printed to the terminal using the python print() function (we will explore functions in detail later in the course).

For example:

print("hello world!")

Prints hello world! to the terminal.

Whatever is contained between the brackets will be printed out to the terminal using double quotes "".

2. Quotes

In Python strings can be enclosed in either single quotes '...' or double quotes "..."

For example:

print('hello world!')

Also prints hello world! to the terminal as per section 1.

NOTE: Although we can use both, please stick to double quotes as most languages represent strings with double quotes!

3. Escape Characters

My first program was "Hello World!" in Python

Try the following in the terminal.

print("My first program was "Hello World!" in Python")

You will see a SyntaxError: invalid syntax, this is the Python interpreter telling you that it does not understand the code you have entered. If you look closely you will see that it is pointing to the H, the character after the second "". This is because it is illegal to put a double quote character " within a string.

To fix this we need to use an escape character, in Python, this is the \ (backslash) followed by a particular character.

Amend the previous line of code to include backslashes for the quotes in the string.

print("My first program was \"Hello World!\" in Python")

If you run this you will now get the desired output My first program was "Hello World!" in Python.

There are a number of special escape characters in Python, you can look them up. We will just look at the following:

NameEscape Character
tab\t
new line\n
backslash\\
single quotation mark\' (You only need to use this when the string in enclosed is single quotation marks.)
double quotation mark\" (You only need to use this when the string in enclosed is double quotation marks.)

For example,

print("My first program was \"Hello World!\" in Python\n\nPython is cool!")

prints out :

My first program was "Hello World!" in Python
  
Python is cool!

4. Multiline Strings

Multiline strings let us write multiple lines in a string without escape characters or multiple print statements. In Python, they start with ''' or """ and end the same way.

numbers = '''3
5
6
7
'''  
print(numbers)

The above will print out the following to the terminal.

3
5
6
7

If you have an escape character in your multiline string and you want this to appear in the terminal you should escape it with another \.

For example,

print('''This is a multiline string.

The tab escape character in python is \t.
''')

will result in the output:

This is a multiline string.

print('''This is a multiline string.

The tab escape character in python is   .
''')

i.e. it will put a tab into the output. To get \t to show up you need to escape it with another \ as follows:

print('''This is a multiline string.

The tab escape character in python is \\t.
''')

NOTE: This is the same in a normal string. To output an escape character as text you should escape it!

5. Concatenating Strings

To concatenate, or combine, strings you can use the + operator.

For example,

str1 = "Hello"
str2 = "World!"

print(str1 + str2)

results in the output:

HelloWorld!

To get the standard Hello World! we could do the following.

str1 = "Hello"
str2 = "World!"

print(str1 + " " + str2)

Note that you can also just concatenate strings within the print function:

print("Hello" + " " + "World")

6. Formatting Strings

We can also combine strings using the format() method.

The format() method takes the passed arguments and puts them in the string where the placeholders {} are:

For example,

str1 = "string 1"
str2 = "string 2"
print("Hello {} and hello {}.".format(str1, str2))

results in the output:

Hello string 1 and hello string 2.

We can also achieve this using index numbers starting at {0}

str1 = "string 1"
str2 = "string 2"
print("Hello {0} and hello {1}.".format(str1, str2))

This is especially useful if you wish to repeat strings stored in variables.

For example,

str1 = "string 1"
str2 = "string 2"
print("Hello {0} and hello {1}.\nBye {0} and bye {1}.".format(str1, str2))

results in the output:

Hello string 1 and hello string 2.
Bye string 1 and bye string 2.

7. f-Strings

Since Python 3.6 you can use f-Strings (formatted string literals), we will use these throughout the course, but you should understand how to use the others in case someone else has written code using them.

We can achieve the previous output by doing the following:

str1 = "string 1"
str2 = "string 2"
print(f"Hello {str1} and hello {st2}.\nBye {str1} and bye {str2}.")

which again results in the output:

Hello string 1 and hello string 2.
Bye string 1 and bye string 2.

Note that in front of the string we put an f. This tells python that anything in curly braces should be evaluated. So in the example above, the values of str1 and str2 are substituted into the string.


=== TASK ===

Create a new Python file.

  1. Print Programming in Python to the terminal.  

  2. Print Programming in Python with single quotes to the terminal using single quotes ''.

  3. Print the following to the terminal.

I know how to put "quotes" into a string.
  
And put in new lines!
  1. Using a multiline string print
I am using a multiline string to:
- print multiple lines 
- without the use of the escape character \n

to the terminal.

  1. Create the following strings and store them in the variables str1 and str2
str1 = "string 1"
str2 = "string 2"

Print Hi, I am string 1 and I am string 2! to the terminal using string concatenation.

  1. Print string 1 is before string 2 and string 2 is after string 1 to the terminal using f-strings.

The entire output of the program should be as follows:

Programming in Python
Programming in Python with single quotes
I know how to put "quotes" into a string.

And put in new lines!
I am using a multiline string to:
- print multiple lines
- without the use of the escape character \n
Hi, I am string 1 and I am string 2!
string 1 is before string 2 and string 2 is after string 1

Casting

It is very common that you will need to convert one data type to another.

Type the following into the terminal:

print("The meaning of life is " + 42)

You will the following TypeError:

TypeError: can only concatenate str (not "int") to str

This is because you are trying to add a str and an int. Python does not know how to do this. To correct this you need to do something called casting.

The following code casts (converts) the int to a str using the constructor function str():

print("The meaning of life is " + str(42))

If you try this you will see that it works.

Type str(42) into the terminal on its own and you will see that it returns the str '42' (note it uses single quotes, but that is the same as double quotes in Python!).

Note that we could have done the following:

print(f"The meaning of life is {42}")

This is because the python f-string knows how to convert the number 42 to play nicely with the string. Try it in the terminal.

1. How to Cast

Casting in python is therefore done using constructor functions:

FunctionDescription
int()Constructs an integer number from an integer, a float (by removing all decimals), or a string (providing the string represents a whole number)
float()Constructs a float number from an integer, a float, or a string (providing the string represents a float or an integer)
str()Constructs a string from a wide variety of data types, including strings, integers, floats, and booleans
bool()Constructs a boolean from a wide variety of data types, including strings, integers, floats, and booleans

1.1 Examples

Try these in the terminal:

int(1)           # Creates an int with value 1
int("2")         # Creates an int with value 2
int(4.3)         # Creates an int with value 4
int(True)        # Creates an int with value 1
int(False)       # Creates an int with value 0
float(1)         # Creates a float with value 1.0
float("2")       # Creates a float with value 2.0
float("3.142")   # Creates a float with value 3.142
float(4.3)       # Creates a float with value 4.3
float(True)      # Creates a float with value 1.0
float(False)     # Creates a float with value 0.0
str(1)           # Creates a str with value '1'
str("3.142")     # Creates a str with value '3.142'
str(4.3)         # Creates a str with value '4.3'
str(True)        # Creates a str with value 'True'
str(False)       # Creates a str with value 'False'

bool() can throw up some unexpected results unless you understand what it is doing.

You might think that bool("0") would result in False, it doesn't!

Python treats everything as True other than False, 0, an empty string "" and some other things we have yet to encounter, empty lists, dictionaries, tuples, and the None keyword which represents no value at all (more on that later).

bool(1)          # Creates a bool with value True
bool("3.142")    # Creates a bool with value True
bool(4.3)        # Creates a bool with value True
bool("0")        # Creates a bool with value True
bool(True)       # Creates a bool with value True
bool(False)      # Creates a bool with value False
bool(0)          # Creates a bool with value False
bool("")         # Creates a bool with value False
bool([])         # Creates a bool with value False
bool({})         # Creates a bool with value False
bool(())         # Creates a bool with value False
bool(None)       # Creates a bool with value False

=== TASK ===

Create a new Python file and write a program that outputs the following to the terminal for a given X and Y. The result of multiplying X by Y is X*Y

For example, for 2.1 and 3:

Please enter a number:
2.1
Please enter another number:
3
The result of multiplying 2.1 by 3.0 is 6.3

For example, for 5.2 and 3.4:

Please enter a number:
5.2
Please enter another number:
3.4
The result of multiplying 5.2 by 3.4 is 17.68

References

W3Schools - Python Casting

The What's My Age Again (in Hours)? Program

The following program is named after the pop-punk classic by Blink 182 - What's My Age Again off the album Enema of the State.

The program asks the user for the hour, day, month, and year of their birth.

It then outputs the person's age in hours.

Here is a plan for our program.

In pseudocode:

Ask the user for the hour, day, month and, year of birth

Compute the difference between the current date and time and the user's birth

Convert the difference into hours

Print out the user's age in hours

Please try to understand how this program works and match it with the pseudocode above.

Copy and paste this code into a new file to play around with it.

1. The Complete Program

import datetime

hour = int(input("What hour (24hr) were you born?\n"))
day = int(input("What day (number) of the month were you born?\n"))
month = int(input("What number month were you born?\n"))
year = int(input("What year were you born?\n"))

# create a new datetime object with user input
birth_datetime = datetime.datetime(year, month, day, hour)
# get current datetime
current_datetime = datetime.datetime.now()
# compute the difference
diff = current_datetime - birth_datetime

# convert days (*24) and seconds (/3600) to hours and sum 
hours = diff.days*24 + diff.seconds/3600

print(f"Your age in hours is {round(hours)}.")

How would you estimate that the output is actually correct?

You should try and do a calculation to convince yourself that this works properly.

String Methods

Python has a number of built-in methods that you can use on strings to do simple transformations.

You can see a comprehensive list via the link in the References.

1. What is a Method?

A method is something you can call on an object that does something with that object. This will make more sense when we get to object-orientation later in the course.

For now, if we have a string we can call a method using the . notation and we will get back a new string.

For example, type the following into the terminal,

"hello world".upper()

returns the new string:

"HELLO WORLD"

Here we called the upper() method on the str object "hello world" and it gave us back the str "HELLO WORLD".

1.1 Be Careful

String methods return new strings, consider the following code:

a = "hello world"    # (assign "hello world" to variable a)
a.upper()            # (call upper() on variable a)
print(a)             # (print a)

will output

hello world

This is because it returns a new string, it does NOT change the original string. So a is left as "hello world".

We would either have to do the following to print out the upper case version,

a = "hello world"    # (assign "hello world" to variable a)
print(a.upper())     # (print out the result of calling upper() on variable a)

or,

a = "hello world"    # (assign "hello world" to variable a)
b = a.upper()        # (call upper() on variable a and assign result to variable b)
print(b)             # (print b)

1.2 Familiarise Yourself

Take a look at the following link - W3Schools - Python String Methods and try some of these out. You will need some of them for the TASK.


=== TASK ===

Copy the following into a new Python file.

sentence = input("Please enter a sentence:\n")

print(sentence)

Amend the code so that the inputted sentence is then printed out as

  1. upper case
  2. lower case
  3. first character of each word is upper case

For example,

Please enter a sentence:
The quick brown fox jumps over the lazy dog
THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG
the quick brown fox jumps over the lazy dog
The Quick Brown Fox Jumps Over The Lazy Dog

References

W3Schools - Python String Methods

String Indexing and Slicing

A string is a sequence of characters representing Unicode characters.

For example the string "Helloworld" can be thought of like this.

Hello World String

Unlike many programming languages, Python does not have a character type and a character is just a string (str) of length 1.

1. String Indexing

You can access elements of the string using square brackets [] and the index of the position you wish to access.

Note indexing starts at 0. So the first character has an index of 0.

String indexing

For example,

str1 = "String Indexing"

print(str1[0]) # prints the first character "S"
print(str1[4]) # prints the 5th character "n"

1.1 IndexError

If you try the following:

str1 = "String Indexing"

print(str1[15]) # IndexError

You will get an exception as there is no 16th character (index 15 tries to access the 16th character).

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
IndexError: string index out of range

1.2 Negative Indexing

We can also access the characters of the string from the end.

String indexing

For example,

str1 = "String Indexing"

print(str1[-1]) # prints the last character "g"
print(str1[-2]) # prints the second to last character "n"

1.3 Length of a String

The len() function will return the length of the string.

str1 = "String Indexing"

print(len("String Indexing")) # prints 15

2. String Slicing

We can easily get substrings from a given string by using string slicing.

For example, we can get "Index" from the string "String Indexing".

The following gets the characters from position 7 to position 11 (12 not included)

str1 = "String Indexing"

print(str1[7:12]) # prints Index

Basically you specify the start index and the end index (not included), separated by a colon, to return a part of the string.

2.1 Start to a Given Position

You can get all characters from the start to a given position by:

str1 = "String Indexing"

print(str1[0:6]) # prints String
## OR
print(str1[:6]) # prints String

The second way is very common and is shorthand for the start index 0.

2.2 A Given Position to the End

You can get all characters from a given position to the end of the string using.

str1 = "String Indexing"

print(str1[4:15]) # prints ng Indexing
## OR
print(str1[4:])   # prints ng Indexing

The second way is quite common and it means we don't need to know the length of the string!

Otherwise, in general you would need to do the following

str1 = "String Indexing"

print(str1[4:len(str1)]) # same as str1[4:15] or str1[4:]

2.3 Negative Slicing

You can also slice using negative indexing.

str1 = "String Indexing"

print(str1[-3:]) # prints out the last 3 characters - "ing"
print(str1[-4:-2]) # prints out character 11 and 12. Same as str1[11:13]

=== TASK===

Copy the following into a new Python file.

# DO NOT EDIT THESE TWO LINES
firstname = input("Please enter a first name: \n")  # leave this alone!
surname = input("Please enter a surname: \n")  # leave this alone!

# -------------------------------------------------------

# Edit the following line so that it prints out the first character of the first name
print(f"The first character of the first name is {firstname}")

# Edit the following line so that it prints out the last character of the surname (negative indexing)
print(f"The last character of the surname is {surname}")

# Edit the following line so that it prints the initials of the person. e.g. Mary Smith would result in M.S
print(f"The person's initials are {firstname}.{surname}")

# Edit the following line so that it prints the first 3 characters of the first name. For example Mary would print out Mar
print(f"The first 3 characters of the first name are {firstname}")

# Edit the following line so that it prints the last 4 characters of the surname
print(f"The last 4 characters of the surname are {surname}")
  1. Edit the following line so that it prints out the first character of the first name
print(f"The first character of the first name is {firstname}")
  1. Edit the following line so that it prints out the last character of the surname (negative indexing)
print(f"The last character of the surname is {surname}")
  1. Edit the following line so that it prints the initials of the person. e.g. Mary Smith would result in M.S
print(f"The person's initials are {firstname}.{surname}")
  1. Edit the following line so that it prints the first 3 characters of the first name. For example, Mary would print out Mar
print(f"The first 3 characters of the first name are {firstname}")
  1. Edit the following line so that it prints the last 4 characters of the surname (note we assume for simplicity that the surname contains at least 4 characters, what happens if it is less than 4?)
print(f"The last 4 characters of the surname are {surname}")

An example of the correct program for the input Mary Smith is given below.

Please enter a first name:
Mary 
Please enter a surname:
Smith
The first character of the first name is M
The last character of the surname is h
The person's initials are M.S
The first 3 characters of the first name are Mar
The last 4 characters of the surname are mith

Unit 3 - Branching and Decisions

So far in this course, we have written straight-line programs. These execute one statement after another.

These aren't particularly interesting programs.

Branching programs provide more complexity to our programs and make them more interesting.

Generally, we will look to use a conditional statement which has three parts:

  1. boolean test - an expression that evaluates to True or False
  2. True block - a block of code that is executed if the test evaluates to True
  3. False block - a block of code that is executed if the test evaluates to False

 

The following image demonstrates the program flow of a basic branching statement.

You can see that the code enters the conditional statement (dotted box) and encounters the Test, based on whether it evaluates to True or False it executes either the True block or the False block.

Flow chart for conditional statement Image reproduced from Chapter 2: Introduction to Computation and Programming Using Python.

If ... Else Statement

As stated in the overview of this unit. A conditional statement has three parts:

  1. Boolean test - an expression that evaluates to True or False
  2. True block - a block of code that is executed if the test evaluates to True
  3. False block - a block of code that is executed if the test evaluates to False

1. Structure of a conditional statement

In Python, a conditional if statement has the following form:

if <boolean expression>:
  # If the boolean expression is True, run this block of code
else:
  # If the boolean expression is False, run this block of code
  

You can see how this mirrors the diagram from the overview of this unit, if the boolean expression is True, execute the True block, else it is False, execute the False block.

Conditional flow diagram

Example 1.1 - Just an if statement

The following example tests if a variable x is less than5 and only prints out if x is less than 5

print("code before if statement")

# store 4 in the variable x
x = 4

# Test the variable using the boolean expression x < 5?
if x < 5:
    print(f"{x} is less than 5")
  
print("code after if statement")

The whole output of the program is as follows:

code before if statement
4 is less than 5
code after if statement

If we change x = 10 then the program will not execute the statement print(f"{x} is less than 5") and the output will be:

code before if statement
code after if statement

Copy and paste the following program into a Python file and experiment by entering different numbers.

# get some input from the user and store as an int
input_string = input("Please enter a whole number\n")
x = int(input_string)

print("code before if statement")

if x < 5:
    print(f"{x} is less than 5")
  
print("code after if statement")

Example 1.2 - if ... else statement

The following example tests if a variable x is less than 5 and prints out information for both cases.

print("code before if statement")

# Test the variable using the boolean expression x < 5?
if x < 5:
    print(f"{x} is less than 5")
else:
    print(f"{x} is greater than or equal to 5")
  
print("code after if statement")

In this example x = 7 which is greater than 5, therefore x < 5 evaluates to False and the program prints 7 is greater than or equal to 5 (the else block)

The whole output of the program is as follows:

code before if statement
7 is greater than or equal to 5
code after if statement

Try copying the code below into a Python file and running the program. Think about the following:

  1. What happens with different int values of x?
  2. What happens if x is not an int, for example of type str?
# get some input from the user and store as an int
input_string = input("Please enter a whole number\n")
x = int(input_string)

print("code before if statement")

# Test using the boolean expression x < 5?
if x < 5:
    # run this code if boolean expression is True
    print(f"{x} is less than 5")
else:
    # run this code if boolean expression is False
    print(f"{x} is greater than or equal to 5")
  
print("code after if statement")

2. Indentation

You will notice that the code is indented, this is how Python determines blocks of code. Indentation is determined by the programmer, I would stick to 2 or 4 spaces. Repl.it defaults to 2 spaces.

It can be done using a different number of spaces or the tab character, but you should be consistent in your program. Try the code without the indentation. What happens?

Example 2.1 - No indentation

print("code before if statement")

x = 4

if x < 5:
print(f"{x} is less than 5")
else:
print(f"{x} is greater than or equal to 5")

print("code after if statement")

You will see that you get the following error - IndentationError: expected an indented block.

3. Compound Boolean Expressions

The conditional test for the if statement can be a more complicated expression as long as it evaluates to True or False.

For example, the following program tests to see if a number is between 1 and 10:

Example 3.1 - Logical And

# get some input from the user and store as an int
x = int(input("Enter a whole number:\n"))

if (x > 0) and (x < 11):
  print("Number is between 1 and 10")
else:
  print("Number is not between 1 and 10")

Here the boolean expression (test) is (x > 0) and (x < 11) which requires that both (x > 0) and (x < 11) need to be True for the whole expression to be True.

Example 3.2 - Logical Not

# ask the user for a number and cast it to an int
x = int(input("Enter a whole number:\n"))

if not x < 10 :
  print("Number is above 10")

Here the boolean expression (test) is not x < 0. This will first evaluate x < 0 and then take a not of the result. e.g. if x = 11 then x < 10 is False, therefore not x < 10 is True.

Try out these programs and make sure you understand what they do.


=== TASK ===

You can test if a number is even or odd using the modulus operator %.

For example, 4 % 2 = 0 evaluates to 0 because 2 divides 4 with no remainder.

However, 7 % 2 = 1 evaluates to 1 because 2 divides 7 with remainder 1.

We can use this to evaluate if a number is odd or even. The expression x % 2 == 0 evaluates to True if x is even and False if it is odd.

The expression x % 2 == 0 compares the left side x % 2 to the right side 0 to see if they are equal.

For example,

xx % 2x % 2 == 0
40True
71False

I suggest you try some even and odd examples out in the terminal if you don't understand this.

E.g. Try:

# test 4 to see if it is even
4 % 2 == 0  # will print out True as 4 % 2 evaluates to 0
# test 7 to see if it is even
7 % 2 == 0  # will print out False as 7 % 2 evaluates to 1

Write a program that asks a user for a number and then prints out whether it is even or odd.

Your program should work as follows:

Please enter a whole number:
7
Your number is odd!
Please enter a whole number:
4
Your number is even!

Note that to pass the tests you must have exactly the output above, apart from the numbers which will differ depending on what the user inputs.


MP: The Really Rubbish Password Program

This is an example mini-program to demonstrate the use of an if ... else statement.

The aim is to ask the user for a password and if the password matches the secret password then the user gets into the system, if not the user is denied access.

Feel free to play around with the program as much as you like.

Planning our program in pseudocode.

Set the secret password

Ask the user to enter their password
if the input matches the secret password
  grant access
else
  do not grant access

1. The Complete Program

The complete program is given below and then explained in the subsequent sections.

# The Really Rubbish Password Program

# This is the stored password for the user
secret_password = "secret"

print("Welcome to NOSA Inc.")
print("Did you know that the Moon is an average of 238,855 miles away from Earth\n")

password = input("Please enter your password:\n")

if password == "secret":
  print("\nAccess Granted!")
else:
  print("\nAccess Denied!")

input("\n\nPress the enter key to exit.")

2. Breaking Down the Program

The following section explains the two main parts of the program.

2.1 Getting the User Input.

The first part of the code prompts the user for their password and stores it in the variable password.

# The Really Rubbish Password Program

# This is the stored password for the user
secret_password = "secret"

print("Welcome to NOSA Inc.")
print("Did you know that the Moon is an average of 238,855 miles away from Earth\n")

password = input("Please enter your password:\n")

2.2 Testing the Password

The second part of the code then uses the variable password to check if the password is correct. If the condition password == "secret" is True then access is granted otherwise (else) access is denied.

if password == "secret":
  print("\nAccess Granted!")
else:
  print("\nAccess Denied!")

input("\n\nPress the enter key to exit.")

References

Dawson, M. (2010). Python programming for the absolute beginner, third edition (3rd ed.). Delmar Cengage Learning.

Nested if Statements

It is possible to nest if statements within other if statements. This can be useful for testing multiple conditions but allowing us to run code for each conditional.

1. Nested if Statement

We can nest if statements as follows:

1.1 Example - Nested if Statement

x = 101

if x > 100:
  print("Above 100, ", end="")
  if x > 150:
    print("and also above 150!")
  else:
    print("but not above 150!")

As x = 101 the first program will first execute the statement print("Above 100, ", end="") and then execute the statement print("but not above 150!"). Try different values of x by pasting the code above into a Python file.

If we compare this with:

x = 101

if (x > 100) and (x > 150):
  print("Above 100, and also above 150!")

The second program cannot do this and only prints out "Above 100, and also above 150! if x is greater than 150.

If x = 99 or x = 130 the program will print nothing.

You should make sure you understand the program flow. The two program flows are depicted in the diagram below.

Nested if statement flow

2. Indentation

You should note that in the example given in 1.1, the blocks of code are given by indentation. If we just examine the structure of the example it looks as follows:

# main block of code, anything aligned with this is in this block
if x > 100:
  # True block of code
  print("Above ten,")
  if x > 150:
    # True block of code
    print("and also above 150!")
  else:
    # False block of code
    print("but not above 150!")

This is also illustrated by the diagram below. You should note that the indentation defines each of the blocks of code.

Nested if statement flow with blocks

=== TASK ===

Using nested if statements, write a program that asks the user for a whole number. Your program should do the following:

  • If the number is divisible by 3 and 5 it should print out

Your number is divisible by 3 and 5.

  • If the number is divisible by 3 and NOT 5 it should print out

Your number is divisible by 3 and NOT by 5.

  • If the number is NOT divisible by and by 5 it should print out

Your number is NOT divisible by 3 and is divisible by 5.

  • If the number is NOT divisible by and by NOT 5 it should print out

Your number is NOT divisible by 3 and 5.

Your program should match the examples below. I have given examples for each output.

Please enter a number:
15
Your number is divisible by 3 and 5.
Please enter a number:
12
Your number is divisible by 3 and NOT by 5.
Please enter a number:
20
Your number is NOT divisible by 3 and is divisible by 5.
Please enter a number:
22
Your number is NOT divisible by 3 and 5.

HINT: We learned how you can test if a number is divisible by 2 in the Lesson: If ... Else Statement earlier in this unit.


Elif Statement

Sometimes we may wish to use an alternative test should our previous test evaluate as False.

elif which is short for else if, lets us do exactly that.

1. Using the Elif Statement

We can test another condition after the first condition as follows:

1.1 Example - if ... elif

Remember from Nested If Statements that we can do the following:

# change these to experiment with the if..elif block
x = 4
y = 5

if x < y:
  # block of code
  print("x is less than y")
elif x > y:
  # block of code
  print("x is greater than y")

Here depending on the values of x and y one (and only one) of the print statements is executed.

If x is less than y then x < y is True and the first block is executed.

If x is greater than y then x < y is False and the elif part is checked. As x > y is True, the second block is executed.

What about if x and y are equal?

Copy the code above into a Python file and experiment with different values of x and y.

1.2 Example - if ... elif ..else

We can also include an else statement, now our program will output when x and y are equal.

# change these to experiment with the if..elif..else block
x = 4
y = 5

if x < y:
  # block of code
  print("x is less than y")
elif x > y:
  # block of code
  print("x is greater than y")
else:
  # block of code
  print("x is equal to y")

You should think about this and realise that there are three possibilities.

  1. x is less than y
  2. x is greater than y
  3. x is equal to y

If the first two are False, then it must be that x is equal to y. We don't need a test, we can just the else statement.

1.3 The Connection with if ... else

The program from the last section:

# change these to experiment with the if..elif..else block
x = 4
y = 5

if x < y:
  # block of code
  print("x is less than y")
elif x > y:
  # block of code
  print("x is greater than y")
else:
  # block of code
  print("x is equal to y")

Can be rewritten in terms of just if and else statements:

# change these to experiment with the if..elif..else block
x = 4
y = 5

if x < y:
  # block of code
  print("x is less than y")
else:         # You can see from these two lines where elif gets it's name, it is doing the same as else if!
  if x > y:
    # block of code
    print("x is greater than y")
  else:
    # block of code
    print("x is equal to y")

1.4 Example - Multiple elif statements

Another example is testing to see if the first letter of someone's name begins with a vowel.

input_name = input("Please enter your name:\n")

# convert the name to lowercase
name = input_name.lower()

if name[0] == "a":
  print("The name begins with an a")
elif name[0] == "e":
  print("The name begins with an e")
elif name[0] == "i":
  print("The name begins with an i")
elif name[0] == "o":
  print("The name begins with an o")
elif name[0] == "u":
  print("The name begins with an u")
else:
  print("The name does not begin with a vowel")

I suggest trying each of these programs out in Python.

Note that this is not the most efficient way to do this, we can either use the newer match statement available since Python 3.10 (version). We could also do a similar thing using lists


=== TASK ===

Write a program that outputs whether a number is positive, negative, or zero. Your program should accept numbers of type float.

  • If the number is positive it should output Your number is positive!
  • If the number is negative it should output Your number is negative!
  • If the number is zero it should output Your number is zero!  

For example, your program should output the following given these inputs:

Please enter a number:
2.3
Your number is positive!
Please enter a number:
-3.3
Your number is negative!
Please enter a number:
0
Your number is zero!

The Mood Face Program

This is an example mini-program to demonstrate the use of an elif statement.

The program generates a random number between 1 and 3 and then prints out a given mood face.

Work through the three sections.

1. The Random Mood Program

# Mood Computer
# Demonstrates the elif clause
import random
print("Right now I am feeling...")

mood = random.randint(1, 3)

if mood == 1:
  # happy
  print( \
  """
-----------
|         |
| O     O |
|    <    |
|         |
| .     . |
|  `...`  |
-----------
  """)
elif mood == 2:
  # neutral
  print( \
  """
-----------
|         |
| O     O |
|    <    |
|         |
| ------- |
|         |
-----------
  """)
elif mood == 3:
  # sad
  print( \
  """
-----------
|         |
| O     O |
|    <    |
|         |
|   .'.   |
|  '   '  |
-----------
  """)

input("\n\nPress the enter key to exit.")

You'll note that because the multiline strings do not have indentation, the lines,

  print( \
  """
-----------
|         |
| O     O |
|    <    |
|         |
| .     . |
|  `...`  |
-----------
  """)

are actually really one line of code, but the multiline string is split across the 10 lines.

2. Refactoring the Program

If you look carefully you will notice that all the faces have the same first 5 lines. and the same last line.

-----------
|         |
| O     O |
|    <    |
|         |
-----------

Therefore we can just print this out once and then print out the bottom 3 lines depending on the mood.

# Mood Computer
# Demonstrates the elif clause
import random
print("Right now I am feeling...")

# print the first five lines of the face
print("""-----------
|         |
| O     O |
|    <    |
|         |""")

mood = random.randint(1, 3)

# use elif to print mood lines of the face
if mood == 1:
  # happy
  print("| .     . |")
  print("|  `...`  |")
elif mood == 2:
  # neutral
  print("| ------- |")
  print("|         |")
elif mood == 3:
  # sad
  print("|   .'.   |")
  print("|  '   '  |")

# print the bottom line of the face
print("-----------")
input("\n\nPress the enter key to exit.")

This is exactly the same program but slightly shorter.

3. Accept User Input

We make one final change to the program to ask the user for a number between 1 and 3 instead of randomly generating the number.

# Mood Computer
# Demonstrates the elif clause
mood = int(input("Please enter a number between 1 and 3:\n"))

# use elif to print mood lines of the face
if (mood < 1) or (mood > 3):
  print("Illegal mood value!")
else:
  print("Right now I am feeling...\n")

  # print the first five lines of the face
  print("""-----------
|         |
| O     O |
|    <    |
|         |""")
  
  if mood == 1:
    # happy
    print("| .     . |")
    print("|  `...`  |")
  elif mood == 2:
    # neutral
    print("| ------- |")
    print("|         |")
  elif mood == 3:
    # sad
    print("|   .'.   |")
    print("|  '   '  |")

  # print the bottom line of the face
  print("-----------")

input("\n\nPress the enter key to exit.")

Note now if you enter a number that isn't 1,2 or 3 you will get back,

Illegal mood value!

References

Dawson, M. (2010). Python programming for the absolute beginner, third edition (3rd ed.). Delmar Cengage Learning.

Booleans

This lesson introduces you to the built-in data type bool. Booleans are essential in computer science and take on either a value of True or False.

True and False are keywords in Python and are used to represent their respective boolean values. You can assign them to variables, for example:

my_bool = True
type(my_bool)

The above code assigns the value True to my_bool and then checks the type. Try this in the terminal and you will see that my_bool is now an object of type <class 'bool'>.

1. Boolean Expressions

You can compare two objects of the same type using the comparison operators listed in the table below. These expressions will return either True or False and are known as boolean expressions.

1.1 Comparison Operators

OperatorNameExample
==Equalx == y
!=Not equalx != y
>Greater thanx > y
<Less thanx < y
>=Greater than or equal tox >= y
<=Less than or equal tox <= y

1.2 Comparing Numbers

For example, the following expressions compare int objects and evaluate to:

ExpressionResult
3 < 5True
3 > 3False
6 >= 6True
3 == 5False
3 != 5True

1.3 Comparing Strings

Interestingly you can also compare other objects such as str. The following,

"held" < "helm"

evaluates to True and,

"help" < "helm"

evaluates to False.

Python knows how to compare the order of strings! It looks at each character in turn and compares their order in the alphabet. Try experimenting. For example, what do the following two expressions return?

"hel" < "helm"
"hello" < "helm"

Try to reason about the answers that you get.

1.4 Comparing Booleans

Believe it or not, you can also compare the order of Booleans.

True < False      # (Evaluates to False)
False < True      # (Evaluates to True)

This is because Python also represents True as the bit value 1 and False as the bit value 0. Now the above should make sense!

1.5 Comparing Different Types

Do you know how to order the str "hello" and the int 10? I don't and neither does Python.

"hello" < 10     

results in the following exception:

TypeError: '<' not supported between instances of 'str' and 'int'

Two objects you can mix are numbers (int and float),

4 < 5.6       # (Evaluates to True)

and numbers and booleans (bool),

4 < True      # (Evaluates to False)

because True is also represented by 1.

2. Logical Operators

Boolean expressions can be combined with logical operators to create larger boolean expressions:

OperatorDescriptionExample
andReturns True if both statements are Truex < 5 and x < 10
orReturns True if one of the statements is Truex < 5 or x < 10
notReverse the result, returns False if the result is Truenot(x < 5 and x < 10)

2.1 Order of Precedence and Left-to-Right

All the logical operators given above have lower precedence than the arithmetic operators and comparison operators.

You will also notice that the order of precedence (high to low) for the logical operators is not, and and then or. This means you evaluate not first, then and, then or.

You also evaluate left-to-right.

OperatorName
()Parentheses
**Exponentiation
*, /, %, //Multiplication, Division, Modulus, Floor Division
+, -Addition, Subtraction
==, !=, <, >, <=, >=Comparison Operators
notLogical NOT
andLogical AND
orLogical OR

2.1 Evaluating a Complicated Boolean Expression

The table gives quite a complicated expression for the not example.

not(x < 5 and x < 10) 

Let's look at this for x = 4,

# This is not code, we are manually evaluating to see how Python works with this expression

not(x < 5 and x < 10)     # (Substitute x = 4)
not(4 < 5 and 4 < 10)     # (Evaluate 4 < 5)
not(True and 4 < 10)      # (Evaluate 4 < 10)
not(True and True)        # (Evaluate True and True)
not(True)                 # (Evaluate not True)
False 

and for x = 6,

# This is not code, we are manually evaluating to see how Python works with this expression

not(x < 5 and x < 10)     # (Substitute x = 4)
not(6 < 5 and 6 < 10)     # (Evaluate 6 < 5)
not(False and 6 < 10)      # (Evaluate False and ....)
not(False)                 # (Evaluate not False)
True 

False and 6 < 10 evaluated to False because and requires both expressions to be True. As the first is False, why bother evaluating the second?

This is known as short-circuit evaluation or McCarthy evaluation. Named after the great John McCarthy.

2.2 Truth Tables

We can consider the result of combining two bool variables p and q. The following are known as truth tables and display the result for different combinations of p and q using and and or.

2.2.1 Truth Table for and

pqp and q
TrueTrueTrue
TrueFalseFalse
FalseTrueFalse
FalseFalseFalse

2.2.2 Truth Table for or

pqp or q
TrueTrueTrue
TrueFalseTrue
FalseTrueTrue
FalseFalseFalse

=== TASK ===

For this program you should fix the single line instructed.

Copy the following code into a new Python file.

# DO NOT TOUCH THESE LINES. THEY ARE USED FOR THE INPUT
is_raining = bool(int(input()))
no_hat = bool(int(input()))
#######################################################

# You should fix this line to by forming an expression using is_raining and no_hat to produce the correct result for takes_umbrella. 

# e.g takes_umbrella = is_raining or no_hat

# Note you only have to fix this line. No if statements etc.. required!

takes_umbrella = True

print(takes_umbrella)

Sam doesn't like getting his hair wet and sometimes wears a hat.

  • On days that it is raining and Sam is not wearing a hat, Sam takes his umbrella.
  • On days that it is raining and Sam is wearing a hat, Sam does not take an umbrella.
  • If it is not raining Sam does not take an umbrella.  

We use two variables is_raining and no_hat to represent whether it is raining and if Sam is wearing a hat.

  • If it is raining is_raining = True
  • If Sam is NOT wearing a hat no_hat = True.  

Using a third variable takes_umbrella determine if Sam should take his umbrella by combining is_raining and no_hat.

For example, on days that it is raining and Sam is not wearing a hat the variables will have the following values:

  • is_raining = True
  • no_hat = True
  • takes_umbrella = True  

is_raining and no_hat have been set up for you. Combine them with logical operators to get the correct value of takes_umbrella.

HINT: You should consider the truth table and fill in the missing entries. This will then give you a hint to what the expression should be.

is_rainingno_hattakes_umbrella
FalseFalse?
FalseTrue?
TrueFalse?
TrueTrueTrue

W3Schools - Python Booleans

W3Schools - Python Comparison Operators

Unit 4 - Iteration (Loops)

In the previous module, we looked at how we could write more complicated problems using conditionals (if statement, etc...).

In addition, we can make our programs repeat particular steps by looping (iteration).

Python has two basic loop statements:

  • while loops
  • for loops

Iterations provide more complexity to our programs and make them more interesting.

Generally an iteration (loop) has two parts:

  1. Boolean test - an expression that evaluates to True or False
  2. Loop body - a block of code that is executed if the test evaluates to True

 

The following image demonstrates the program flow of a basic iteration program.

You can see that the code enters the iteration (dotted box) and encounters the Test, if this evaluates to True it runs the loop body and then repeats the test. If it evaluates to False the program continues.

Flow chart for conditional statement Image reproduced from Chapter 2: Introduction to Computation and Programming Using Python.

While Loops

A while loop lets us execute a series of statements as long as the condition (boolean expression) remains True.

A while loop has the basic structure:

while <condition>:
    # Do something until the condition is False

1. While Loop Using a Counter

A good portion of the while loops that you write will involve a counter variable.

The following example demonstrates a basic while loop that prints out the numbers 1 - 5:

i = 1 # Define a variable i and bind the value 1 to it
while i < 6: 
    print(i)  # print out the value of i
    i += 1    # This increments i by 1. i.e. i = i + 1

print("Program has ended!")

Note the indentation here! The indented block is the loop body (the code that is executed inside the loop).

The above code first defines a counter variable i = 1. It then defines a while loop that tests whether i < 6. If this is True it prints out the value of i and then adds 1 to i.

Note it is traditional to use i for a counter variable.

The image below demonstrates how the code runs:

While loop animation

2. While Loop Using a Boolean Variable

Consider the following program which just keeps asking the user for a number until they enter a 0. Then the program ends.

# loop until the user enters 0

input_string = input("Please enter a number:\n")
num = int(input_string)

while num != 0:
    input_string = input("Please enter a number:\n")
    num = int(input_string)

print("Program has ended!")

We can rewrite this program by using a bool variable (program_over) which keeps track of whether the while loop should continue or stop.

After a number is entered, we use an if statement to test if the number entered was 0, if it was, then we set program_over to True. This will then stop the while loop and the program will end.

# loop until the user enters 0

program_over = False

while not program_over:
    input_string = input("Please enter a number:\n")
    num = int(input_string)
    if num == 0:
         program_over = True

print("Program has ended!")

3. Non-terminating While Loop

Now consider the code if you forget to increment (add to) i.

i = 1 # Define a variable i and bind the value 1 to it
while i < 6: 
    print(i)  # print out the value of i

This loop will repeat forever! i will never change its value from 1 and therefore the condition i < 6 will forever remain True.

  • What will be the output?
  • Try it in Python. To stop (exit) the program in the terminal press Ctrl + c.

4. Nested While Loops

Just like if statements, we can nest loops.

The following program shows an outer and an inner loop. This prints out the multiplication table.

i = 1
while i < 13:
    j = 1
    while j < 13:
        print(f"{i} x {j} = {i*j}")
        j += 1
    i += 1

The outer loop has a counter variable i and the inner loop has a counter variable j. Below is a flow diagram of the program.

While loop multiplication flow

The following is of an animation of a similar program (below), but the conditions are changed to allow us to visualise it quicker.

i = 1
while i < 3:
    j = 1
    while j < 3:
        print(f"{i} x {j} = {i*j}")
        j += 1
    i += 1

While loop nested animation


=== TASK ===

Use a while loop to print out the even numbers starting at 2 and up to and including 100.

2
4
6
.
.
.
100

Note that the dots represent numbers between 6 and 100. It saves us writing it all out!

HINT: You only need a single while loop.


The Number Guessing Game

This is an example mini-program to demonstrate the use of while loops and if .. else statements.

The basic aim is a simple game to ask the user to guess a number between 1 and 10.

Feel free to play around with the program as much as you like.

Note: You will need to set the secret_guess to 4.

1. Number Guessing Game

Planning the program is important. The basic pseudocode for this program is:

pick a random number
while the player hasn’t guessed the number or chosen to exit
    let the player guess
if player guessed correct
    congratulate the player

The program in Python is given below.

# The Number Guessing Game 
import random

# randomly generate a number between 1 and 10
secret_guess = random.randint(1,10)
# to pass the test uncomment this line
# secret_guess = 4

print("Welcome to the Number Guessing Game!")
print("You need to try and guess the number between 1 and 10...")
print("If you wish to exit the game enter 0..")

guess = int(input("Please enter a guess:\n"))

while guess != secret_guess and guess != 0:
  print("That is not correct, please try again.")
  guess = int(input("Please enter a guess:\n"))

if guess != 0:
  print(f"Well done the correct answer is {secret_guess}")

This program generates a random number and then asks the user to input a guess.

The guess is then tested in the while condition and if it is not the correct number and not 0 we repeat the step. If it is the correct number we exit the while.

2. Adding Better Feedback

If you try the program above you will get pretty frustrated by the lack of feedback.

To improve the game we can let the user know if their guess is too low or too high.

This is the subject of exercise 4.5.

The Less Rubbish Password Program

This is an example mini-program to demonstrate the use of an if ... else statement and a while loop.

We previously created a rubbish password program using the following code.

# The Really Rubbish Password Program

# This is the stored password for the user
secret_password = "secret"

print("Welcome to NOSA Inc.")
print("Did you know that the Moon is an average of 238,855 miles away from Earth\n")

password = input("Please enter your password:\n")

if password == secret_password:
  print("\nAccess Granted!")
else:
  print("\nAccess Denied!")

input("\n\nPress the enter key to exit.")

Aside from this being very insecure, a major issue is that the user only gets one guess at entering their password.

1. Improving with a Loop

To improve our program we can add a while loop that repeats the block of code.

password = input("Please enter your password:\n")

if password == "secret":
  print("\nAccess Granted!")
else:
  print("\nAccess Denied!")

To do this we add a boolean variable named access which will track whether the user has access to the system. We then set this to True within the if statement when access is granted.

1.1. The Complete Program

# The Less Rubbish Password Program

# This is the stored password for the user
secret_password = "secret"
access = False

print("Welcome to NOSA Inc.")
print("Did you know that the Moon is an average of 238,855 miles away from Earth")

while not access:
  password = input("\nPlease enter your password:\n")

  if password == "secret":
    print("\nAccess Granted!")
    access = True
  else:
    print("\nAccess Denied!")

input("\n\nPress the enter key to exit.")

Ranges and Lists

Before we come onto for loops we must understand ranges and lists.

1. List

A list in python is used to store multiple values in a single variable.

For example:

list1 = [1,2,3]
list2 = ["Citreon", "Ford", "Audi", "Mercedes"]
list3 = ["Citreon", "Ford", 67, True]

You can access an element in a list using indexing. Each member of the list has an index number starting from 0.

For example the code,

list3 = ["Citreon", "Ford", 67, True]
print(list3[0])
print(list3[2])

results in the output:

Citreon
67

Try creating your own list in the and printing some of its elements.

For now, we will not discuss more about lists until later in the course. It suffices to know that they store multiple values and are heterogeneous (a fancy way of saying they can store different types).

2. Ranges

A range allows us to create a sequence of numbers using the range() function.

range(10) # This will create a range of numbers 0,1,2,3,4,5,6,7,8,9
range(3, 10) # This will create a range of numbers 3,4,5,6,7,8,9
range(2, 10, 2) # This will create a range of numbers 2,4,6,8

Ranges can take either:

  • an end value range(end)
  • a start and end value range(start, end)
  • a start, end, and step value range(start, end, step)

In some ways a range behaves like a list, however, to save space, Python doesn't create the list.

If you type the following into the terminal, you will see that the object type of a range is a range!

a = range(10)
print(type(a)) # prints <class 'range'>

So think of it as a convenient way to store a list of numbers without actually storing them!

By default start is 0 and step is 1.

So range(10) is the same as both range(0,10) and range(0,10,1).

2.1 Convert a Range to a List

If you try and print a range you won't get the numbers. Try the following in the terminal.

a = range(10)
print(a)

and you will get the output:

range(0, 10)

You can convert the range to a list using the list() function.

a = list(range(2, 10)) # converts the range to a list [2,3,4,5,6,7,8,9]
print(a)

If you run the following code in the terminal or a Python file you will get the output:

[2,3,4,5,6,7,8,9]

The list() function is forcing Python to build the list in memory.

To prove this to ourselves let's check the size of range(10**6). Note 10**6 = 1000000

import sys
a = range(10**6)
print(sys.getsizeof(a))

This returns 48 which is 48 bytes of memory. Try a range of any size and you will always get 48.

Now let's convert it to a list and print the size:

import sys
a = list(range(10**6))
print(sys.getsizeof(a))

This returns a whopping 8000056 (8 million) bytes, which is approximately 166667 times as large!


=== TASK ===

Using the range() and list() functions, print out the odd numbers from 1 up to and including 99.

Your output should look like this:

[1, 3, 5, 7, 9, 11, 13, 15, 17, 19, 21, 23, 25, 27, 29, 31, 33, 35, 37, 39, 41, 43, 45, 47, 49, 51, 53, 55, 57, 59, 61, 63, 65, 67, 69, 71, 73, 75, 77, 79, 81, 83, 85, 87, 89, 91, 93, 95, 97, 99]

HINT: Make sure you have read Section 2.1


For Loops

The other main way of looping Python is by using a for loop.

1. Structure of a For Loop

A for loop in python is structured as follows:

for x in <iterable>:
  ## do something

Both ranges and lists are iterables which means we can loop over them.

1.1 For Loop with a List

A list is a collection of objects. They don't have to be of the same type.

The following 3 examples show a for loop that loops over each item in the list and prints out that item.

Example 1.1.1 - List of numbers

for x in [1,6,-23]:
  print(x)
1
6
-23

The following animation shows how this works.

For loop

Example 1.1.2 - List of strings

The following loops over a list of strings,

for x in ["Citreon", "Ford", "Audi", "Mercedes"]:
  print(x)

and outputs.

Citreon
Ford
Audi
Mercedes

We can also do this by first assigning the list to a variable and then using this in our for loop.

cars = ["citreon", "ford", "audi", "jaguar"]
for car in cars:
 print(car)

Example 1.1.3 - List of different types

The following loops over a list of different types of objects,

for x in ["Citreon", "Ford", 67, True]:
  print(x)

and outputs.

Citreon
Ford
67
True

1.2 For Loop with a Range

It is common to use the range() function to automatically generate a sequence of numbers. Do you really want to type out the entire list?

It is also faster and more efficient because it does not have to build the list in memory.

Example 1.2.1

Here range(10) generates the numbers 0,1,2,3,4,5,6,7,8,9. You can think of it like the list [0,1,2,3,4,5,6,7,8,9].

The program then prints out double of each item in the list.

for i in range(10):
  print(2*i)
0
2
4
6
8
10
12
14
16
18

Example 1.2.2

Here range(3,10) generates the numbers 3,4,5,6,7,8,9. You can think of it like the list [3,4,5,6,7,8,9].

The program then prints out each item in the list.

for i in range(3,10):
  print(i)
3
4
5
6
7
8
9

Example 1.2.3

Here range(3,20,2) generates numbers using a step size of 2 - 3,5,7,9,11,13,15,17,19. You can think of it like the list [3,5,7,9,11,13,15,17,19].

The program then prints out each item in the list.

for i in range(3,20,2):
  print(i)
3
5
7
9
11
13
15
17
19

1.3 For Loop with Underscore

If you are not using the counter variable in the for loop, it is convention to use an _ (underscore).

for _ in range(10):
  print("Hello")

The above will print out Hello 10 times.

2. Nested For Loops

Just as we did with while loops, we can nest for loops. Here we use an outer for loop and an inner for loop to print out the multiplication tables.

for i in range(1,13):
  for j in range(1,13):
    print(f"{i} x {j} = {i*j}")

The animation below demonstrates how this program works, note that instead of printing out the whole table we have limited this to just 1 and 2.

for i in range(1,3):
  for j in range(1,3):
    print(f"{i} x {j} = {i*j}")

For loop multiplication table

If we compare this to the same program using a while loop, you can see that the for loop version is less verbose and easier to read.

i = 1
while i < 13:
  j = 1
  while j < 13:
    print(f"{i} x {j} = {i*j}")
    j += 1
  i += 1

=== TASK ===

You can find the length of a string by using the len() function.

For example:

string1 = "for loop"
len_string = len(string1)
print(len_string)
# note you could do this in one line print(len(string1))

This will output the length of the str "for loop". That is, 8.

Copy the following into a new Python file:

# Do not touch this line - It is just there to set up the string list from the input
# string_list will contain a list of strings that have been entered.
string_list = input("Please enter a list of numbers separated by a comma.\ne.g. Citreon,Ford,Audi,Mercedes\n").split(',')

print(string_list)

This code will accept a list of comma-separated strings. e.g. Citreon,Ford,Audi,Mercedes.

If you try running the program and you enter a list of comma-separated strings they will be printed back to the terminal.

['Citreon','Ford','Audi','Mercedes']

The strings inputted are stored in the list named string_list.

Amend the program so that it prints out the following for each inputted string.

The length of the string {string} is {length of string}.

For example, for the string "Ford" the program would print:

The length of the string Ford is 4.

For the input one,two,three,four,five, the whole program acts as follows:

Please enter a list of numbers separated by a comma.
e.g. Citreon,Ford,Audi,Mercedes
one,two,three,four,five
The length of the string one is 3.
The length of the string two is 3.
The length of the string three is 5.
The length of the string four is 4.
The length of the string five is 4.

For the input Citreon,Ford,Audi,Mercedes, the whole program acts as follows:

Please enter a list of numbers separated by a comma.
e.g. Citreon,Ford,Audi,Mercedes
Citreon,Ford,Audi,Mercedes
The length of the string Citreon is 7.
The length of the string Ford is 4.
The length of the string Audi is 4.
The length of the string Mercedes is 8.

Note that to pass the tests you must have exactly the output above, apart from the input which will differ depending on what the user inputs.


Break and Continue

break and continue are keywords in python that allow you to stop the loop or skip the current iteration.

Many times programmers use break and continue when they don't need to. Generally break and continue can lead to funny flows in your program and bugs. If you can avoid using them then do!

1. Break

The break keyword allows us to exit ("break") out of a loop early. We can do this with both while and for loops.

Example 1.1

The following program will stop short of printing the numbers 0,1,2,3,4,5,6,7,8,9 and only print 0,1,2,3,4.

If you need to convince yourself, copy, paste and run this code in Python.

i = 0
while i < 10:
    if i == 5:
        break
    print(i)
    i += 1

The program tests each value of i and if it is equal to 5 the program exits the loop.

We can write this without the break.

i = 0
while i < 5:
    print(i)
    i += 1

Example 1.2

Here is the same program with a for loop

for i in range(0,10):
    if i == 5:
        break
    print(i)

We can write this without the break.

for i in range(0,5):
    if i < 5:
        print(i)

2. Continue

The continue keyword allows us to skip the current iteration of a loop. We can do this with both while and for loops.

Example 2.1

The following program will not print out the number 5, but all other numbers.

So the output will be the numbers 0,1,2,3,4,6,7,8,9. Notice that 5 is missing.

If you need to convince yourself, copy, paste and run this in Python.

i = -1
while i < 9:
    i += 1
    if i == 5:
        continue
    print(i)

The program tests each value of i and if it is equal to 5 the program exits the loop.

We can write this without the continue.

i = 0
while i < 10:
    if not i == 5:
        print(i)
    i += 1

Example 2.2

Here is the same program with a for loop

for i in range(0,10):
    if i == 5:
        continue
    print(i)

We can write this without continue.

for i in range(0,10):
    if not i == 5:
        print(i)

3. No Do...While

To date in Python, we have seen a while loop that will check a condition and execute a code block until the condition becomes False.

while <condition>
    # Do something 

In many languages it is possible to do the following:

do
  run a block of code
while <condition>

This guarantees that the code block runs at least once as the while is checked after the code block.

One of the major reasons Python does not support such a statement is that it breaks the indentation rule that a code block should be indented. Here it isn't!

3.1. Try Not To Use Break

It is quite common to see the following in Python.

while True:
    # do something
    if <condition>:
        break

The following essentially emulates a do...while loop. Here is an example.

# The Number Guessing Game 
import random

# randomly generate a number between 1 and 10
secret_guess = random.randint(1,10)

print("Welcome to the Number Guessing Game!")
print("You need to try and guess the number between 1 and 10...")

while True:
    guess = int(input("Please enter a guess:\n"))
    
    print("That is not correct, please try again.")
    
    if guess == secret_guess:
        break
    
print(f"Well done the correct answer is {secret_guess}")

However, the use of a break statement here relies on the person reading this to implicitly understand that the programmer is intending to use a do...while.

There are also other reasons we would not want to do this in other languages which are compiled to do with the fetch-decode-execute cycle.

We can easily rewrite this with an additional boolean.

# The Number Guessing Game 
import random

# randomly generate a number between 1 and 10
secret_guess = random.randint(1,10)

print("Welcome to the Number Guessing Game!")
print("You need to try and guess the number between 1 and 10...")

game_over = False

while not game_over:
    guess = int(input("Please enter a guess:\n"))
    
    print("That is not correct, please try again.")
    
    if guess == secret_guess:
        game_over = True
    
print(f"Well done the correct answer is {secret_guess}")

Now, these are almost identical, however, it is now more clear that the while will end when gameover is set to True. This conveys the programmers' intention.


=== TASK ===

Print out the numbers 1 to 100 but leave out the meaning of life 42.

Use a continue statement.


The Sum Numbers Program

This is an example mini-program.

The aim is to ask the user for a series of numbers. If the user enters 0, the program then outputs the sum of the inputted numbers

Feel free to play around with the program as much as you like.

The first program uses a break to exit the while loop. Notice that the while condition is always True. So the break is essential to exit.

# The Sum Numbers Program
print("Welcome to The Sum Number Program")
total = 0

while True:
  input_string = input("Please enter a number. Enter 0 to stop\n")
  if input_string == "0":
    break
  
  x = float(input_string)
  total += x  # note this is shorthand for: total = total + x

print(f"Sum of numbers entered is {total}")

We can also do this without a break by using a bool named program_over. You will notice that this is a little more verbose.

print("Welcome to The Sum Number Program")
program_over = False
total = 0

while not program_over:
  input_string = input("Please enter a number. Enter 0 to stop\n")
  if input_string == "0":
    program_over = True
  else:  
    x = float(input_string)
    total += x

print(f"Sum of numbers entered is {total}")

NOTE: Please avoid using break if you can. It can lead to code that becomes hard to reason about or follow.

While this might seem irrelavant to this program, imagine a much larger program with a more complicated flow and the use of lots of breaks and continues. This can become hard to reason about.

The Check Valid Input Program

The following mini-program demonstrates the use of try..except.

We will look at exceptions in more detail later on; however, it is important to see try and except early on.

1. A Program That Fails

Consider the following program.

num_string = input("Please enter a number:\n")

# try to convert the number using float()
# this will fail if the user doesn't enter anything valid. e.g. hello
x = float(num_string)

print(f"{x} + 5 = {x+5}")

If you enter something which is not a number, Python will throw an exception, in particular, a ValueError.

2. Validating the User Input

We can fix this issue by catching this exception using the try...except statement.

input_valid = False

while not input_valid:
  num_string = input("Please enter a number:\n")
  try:
    num = float(num_string)
    input_valid = True
  except ValueError:
    print("Input is not a valid number.\n")

print(f"{num} + 5 = {num+5}")

Here the program attempts to convert num_string to a float.

If this throws an exception, then we trigger the except block and print out Input is not a valid number.. Note that the line input_valid = True is not executed and thus input_valid remains set to False. Therefore, we ask the user for another number.

This will continue until the user enters input that does not trigger the exception and input_valid is set to True.

Unit 5 - Functions

Functions are such a huge part of programming that we will be spending two units (units 5 and 8) on this subject.

In this unit we will be discussing:

  • What a function is
  • Why do we use functions
  • Arguments vs Parameters
  • Return Values
  • Variable Scope
  • Passing by Assignment

A function consists of three parts:

  1. The input(s) to the function
  2. The function itself
  3. The output from the function

You can think of a function as a machine (black box) that takes some input(s), does something, and then produces an output.

Once you define a function, you can reuse it. This is the principal reason for their existence. Don't Repeat Yourself! (DRY)

I have included 5 examples of functions. For now, I want you to understand the idea as a concept. We will see examples 1, 2, 4, and 5 in Python very soon!

1. Example Function - Is a Number Odd?

The first function takes in 1 input (an int) and produces 1 output (a bool).

This function is a machine that tells us whether the input is odd.

For now, we don't care how it works, we just assume it does.

The following image depicts the function on the left and then two examples of it being used on the right.

Odd Function Example

2. Example Function - Add Two Numbers

The second function takes two inputs which are numbers (float) and outputs the sum of those two numbers.

Key point, a function can take multiple inputs!

Add Function Example

3. Example Function - What Colour is a Shape?

The third function might look odd as in python we do not have a Shape type (yet!). Later in the course, we will make one.

It serves to demonstrate that a function is just a machine that does something to the input and produces an output.

Colour Function Example

4. Example Function: Print Hello Name

The final two examples are quite common. The first has an input, but no output. It takes in a person's name (e.g "Emily") and prints "Hello Emily" to the terminal.

We will talk about this and its relationship to Python's None.

Colour Function Example

5. Example Function: Print Hello World

The final example has no input and no output. It is just a machine that runs a piece of code.

In this case, it just prints "Hello World".

Colour Function Example

Passing Unit Tests

This is very important

To date, we have been using input/output tests. From now on we will be mainly using unit tests that import functions from your files.

These allow us to test individual functions. We will discuss this properly later on in the module.

There are two main points here:

  1. You should only write code in either a function block. e.g. the add() function.
  2. You should write all other code that runs your program and calls the functions in the if __name__ == "__main__" block.

Any code outside of this can cause problems with the tests!

Function Basics

As we mentioned in the overview a function consists of three parts:

  1. The input(s) to the function
  2. The function itself
  3. The output from the function

You can think of a function as a machine (black box) that takes some input(s), does something, and then produces an output.

Once you define a function, you can reuse it. This is the principal reason for their existence. Don't Repeat Yourself! (DRY)

In essence it is a way of packaging up bits of code for reuse or to make your program more readable.

1. Defining a Function in Python

We define a function using the keyword def, a function name, and the function parameters. We then write the code the function performs in the function body.

def function_name(<formal parameters>):
    <function body>

The easiest way to demonstrate a function is by an example.

Our first function will just add 5 to the input

def add_five(x):
    return x + 5

Note that the return keyword is how you return (output) something from a function. Here the function add_five() returns the result of adding 5 to the input x.

2. Calling a Function in Python

add_five() is now a function that you can call with different values of x. e.g. add_five(10)

Try running the following code,

def add_five(x):
    return x + 5

result = add_five(10)  # call function with 10 and bind result
print(result)      # this prints the value of result

and you will get

15

as the output.

Function Call Visualisation

You can visualise this yourself on Python Tutor.

Note that we could have called this function twice.

def add_five(x):
    return x + 5

result_one = add_five(10)  # call function with 10 and bind result
result_two = add_five(7)   # call function with 7 and bind result
print(result_one)      # this prints the value in result_one
print(result_two)      # this prints the value in result_two

and you will get

15
12

as the output.

2.1 Define a Function Before You Call It

Now try the following,

result = add_five(10)
print(result)

def add_five(x):
    return x + 5

and you will get NameError. This is because the function has not yet been defined, i.e. it is below the function call. Python does not know about that name.

2.2 Order of Precedence

We can also do more interesting things like:

def add_five(x):
    return x + 5

result = add_five(10)*add_five(7) # same as 15*12
print(result) # prints 180

Here Python will evaluate add_five(10) and then evaluate add_five(7), then multiply them together.

This is because other than parentheses (), function calls have higher precedence than everything else in Python.

2.3 Multiple Return Statements

Consider the following function.

def greater_than_five(x):
    if x > 5:
        return True
    else:
        return False

if __name__ = "__main__":
    print(greater_than_five(3)) # prints False
    print(greater_than_five(7)) # prints True

Here we use an if statement to test whether x is greater than 5, if it is the function returns True, otherwise it returns False.

This is a key point, although your function can only return one output, it can have more than one return statement in your function.

Note that we could have done this with one return statement by returning the result of the boolean expression.

def greater_than_five(x):
    return x > 5

if __name__ = "__main__":
    print(greater_than_five(3)) # prints False
    print(greater_than_five(7)) # prints True

Again which one you use will be your preference and which one you are more comfortable with. Both ways are fine. If the second one isn't for you, then use the first one.

3. if __name__ = "__main__":

You will have noticed in the last couple examples used the if __name__ = "__main__": block. This is not needed to call your functions, but as stated in the 5.1 - MP: Passing Unit Tests you should write any code and calls to your functions inside this block.

The reason is that the unit tests import your code and anything outside the if __name__ = "__main__": will get run due to the import. This can mess up the unit tests!


=== TASK ===

Create a function that tests whether a number is odd or even.

Copy the following into a new Python file.

def odd_test(x):
    # delete pass and replace with code to implement the function
    pass

if __name__ == "__main__":
    print(odd_test(3))     # should output True
    print(odd_test(8))     # should output False

Your function odd_test and take in one input. It should return a True or False depending on the number.

For example:

odd_test(3) should return True and odd_test(8) should return False.


More About Functions

HINT: I would copy and past examples into a Python interpreter to make sure you understand them.

This is one of the most important lessons on functions. Please make sure you understand all 3 of the following.

In this lesson we will cover the following:

  • Different types of functions
  • Parameters vs arguments
  • Function composition

All of these are important concepts and will help you understand later material.

1. Types of Function

1.1 Function with Multiple Parameters

We are not limited to just one parameter, we can have as many as we like. The following two functions take two parameters.

The first returns the sum of the two inputs.

def add(x, y):
    return x + y

if __name__ == "__main__":
    result = add(3, 13)
    print(result) # prints 16

The seconds returns the maximum of the two inputs.

def maximum(x, y):
    if x > y:
        return x
    else:
        return y

if __name__ == "__main__":
    result = maximum(3, 5)
    print(result) # prints 5

You will note that this function has two uses of return for the two possible cases.

1.2. Function with No Return Value

Here we define a function with a single parameter name (the input of the function).

def print_hello(name):
    print(f"Hello {name}")

This is interesting as it takes an input name and does something with the input. However, it does not have a return statement.

Be careful though, this function does return something, it returns the None keyword which represents no value.

Run the following code and you will see this for .

def print_hello(name):
    print(f"Hello {name}")

if __name__ == "__main__":
    # call the function and bind the return value to a
    a = print_hello("Jimi")
    print(a)

You will notice that it outputs,

Hello Jimi
None

because a stores the return value of print_hello() which is None.

1.3. Function with No Input and No Return Value

The final function has no input and no return statement. Its sole purpose is to print two lines.

def print_hello_world():
    print("Hello World")
    print("Welcome to another day on planet earth!")

if __name__ == "__main__":
    print_hello_world()
    print_hello_world()

The code above will output,

Hello World
Welcome to another day on planet earth!
Hello World
Welcome to another day on planet earth!

as the function is called twice.

1.4. Some Familiar Functions

You have already met a bunch of Python functions.

FunctionInputReturnsPurposeExample
print()str to output to the terminal.NonePrints the input to the terminal.print("Hello World")
input()str to output to the terminal.str entered by the user.Prints the input to the terminal and returns what the user inputted as a stringinput("Please enter your name:\n")
int()str representation of a whole number, e.g."5"int conversion of input. e.g. 5Converts a str to an int if possible. If not throws ValueErrorint("5")

1.5 The NotImplementedError

Sometimes it is convenient to define a function, but not write the code yet. This is especially useful when planning a program. It is also useful to make sure that if this function is called it doesn't result in a silent bug.

To stop this, we can define a function that raises a NotImplementedError. This as the name suggests means that you have not implemented the function.

Here we set up a program for computing the total and length of a list. The length() function has not been implemented and raises a NotImplementedError.

def total(num_list):
    """ compute the total of the numbers in the list"""
    tot = 0
    for x in num_list:
        tot += x
    return tot

def length(num_list):
    """ count the number of numbers in the list"""
    raise NotImplementedError("The length function has not been implemented yet!")

if __name__ == "__main__":
    num_list = [4,3,5,7,4]
    print(f"The total of the list is {total(num_list)}") # This line will run
    print(f"The number of numbers in the list is {length(num_list)}") # This line will fail

If you try this in a Python file you will get the output.

The total of the list is 23
Traceback (most recent call last):
  File "main.py", line 14, in <module>
    print(f"The number of numbers in the list is {length(num_list)}") # This line will fail
  File "main.py", line 10, in length
    raise NotImplementedError("The length function has not been implemented yet!")
NotImplementedError: The length function has not been implemented yet!

This is because when we call the length() function it raises the NotImplementedError with the message The length function has not been implemented yet!.

Note thought that Python actually ran the code before it and printed out the list total.

1.6 The Pass Statement

Another way to do this is by using the pass statement. When a programmer doesn't know what to write or is creating a skeleton program they will sometimes put pass. pass will mean the code is valid and can therefore be run.

def total(num_list):
    """ compute the total of the numbers in the list"""
    tot = 0
    for x in num_list:
        tot += x
    return tot

def length(num_list):
    """ count the number of numbers in the list"""
    pass

if __name__ == "__main__":
    num_list = [4,3,5,7,4]
    print(f"The total of the list is {total(num_list)}") # This line will run
    print(f"The number of numbers in the list is {length(num_list)}") # This line will run but prints - The number of numbers in the list is None

The length() function has a pass statement and will now return None.

Try this in a Python Tutor

2. Parameters vs Arguments

It is important to understand the difference between the parameters of a function and arguments. These words are often confused or used interchangeably.

Parameters

Parameters are the names that are in the function definition in between the parentheses ().

Arguments

Arguments are the names that are used in the function call.

The following image shows both the function definition and the function call.

Parameters vs Arguments

3. Function Composition

Function composition is the act of applying a function to the result of another function.

The name is taken from the mathematical concept of function composition.

Consider the following code.

def add5(x):
    return x + 5

def mul2(x):
    return 2*x

if __name__ == "__main__":
    x = 10      # binds 10 to x
    x = add5(x) # binds 15 to x, i.e. 10 + 5 = 15
    x = mul2(x) # binds 30 to x, i.e. 2*15 = 30
    print(x)    # prints 30

We can write this using function composition, note that we evaluate the innermost function first. So work inner to outer.

The example below first evaluates add5() and then evaluates mul2() with the result of add5().

x = 10 # binds 10 to x
x = mul2(add5(x)) # first computes add5(10) = 15, then computes 2*15 = 30 and binds to x
print(x) # prints 30

We can go further and compose three functions. Now we pass the result of the first two to the print() function.

x = 10
print(mul2(add5(x))) # first computes add5(10) = 15, then computes 2*15 = 30 and then prints 30

You could even just do the following.

print(mul2(add5(10))) # does the whole lot in one line!

OK great, we did it in one line!

However, take a look at the readability of the code. The examples which use function composition here are not exactly very readable, in fact, it obfuscates things.

The first example is clear. Assign 10 to x, then add 5 to x, then multiply x by 2 and finally print x.

There are many examples of complex programs that we can write in python with one-liners. This is fine if it is just for your eyes only.

I would suggest for readability you avoid it unless it is necessary or makes sense and is readable.

3.1 Function Composition Isn't Commutative

It is also important to know that function composition isn't commutative, that is just a fancy way of saying that if you change the order of evaluation it doesn't have the same result.

For example,

3 * 5 == 5 * 3 # True, because multiplication doesn't care about the order (commutative)
3 / 5 == 5 / 3 # False, division cares about the order (not commutative)

The following example shows the add5() and mul2() functions composed in the two possible ways give two different results.

def add5(x):
    return x + 5

def mul2(x):
    return 2*x

if __name__ == "__main__":
    x = 10
    y = mul2(add5(x))
    z = add5(mul2(x))
    print(y)  # prints 30
    print(z)  # prints 25

You can try this in Python Tutor and see that y and z are different values because the order of composition was changed.


=== TASK ===

Copy the following into a new Python file. You will need to remove the raise NotImplementedError and replace it with your own code. Make sure you read the whole of task.

def reverse(str_to_reverse):
    raise NotImplementedError("You have not implemented the reverse function")

def get_character(string_one, i):
    raise NotImplementedError("You have not implemented the get_character function")

if __name__ == "__main__":
    print(reverse("This is a string")) # prints "gnirts a si sihT"
    print(reverse("Hello World")) # prints "dlroW olleH"
    print(get_character("This is a string", 3)) # prints "i"
    print(get_character("Hello World", 5)) # prints "o"
    

The first function is called reverse() and has a single parameter, a string. The function should output the reverse of the string.

You should do this using a loop. There is a simple way to do this in Python, but it is good practice to write your own reverse() method.

The second function is called get_character() and has a string and a number as parameters. It should return the ith character of the string.

The table below gives the return values of the functions for different calls.

Function callExpected return value
reverse("This is a string")"gnirts a si sihT"
reverse("Hello World")"dlroW olleH"
get_character("This is a string", 3)"i"
get_character("Hello World", 5)"o"

I suggest you test the above work correctly in the terminal, you should not rely on the tests as the feedback may not be useful.


The Maximum of a List Program

We are now going to recycle our maximum() function.

def maximum(x, y):
    if x > y:
        return x
    else:
        return y

1. Planning the Program

Here is an outline of the program in pseudocode.

Given a list 
Store the first value of the list as current_max
for each element x in the list (not including the first)
    current_max = max(current_max, x)

print out current_max as the result
        

2. Program - Maximum of a list

Let's now implement this and use our maximum() function in a program to loop through a list of numbers and print out the maximum.

def maximum(x, y):
    if x > y:
        return x
    else:
        return y

if __name__ == "__main__":
    num_list = [3,6,2,1,8,4,4,2,7]
    
    # set the current max to the first element in the list
    current_max = num_list[0]
    
    # loop through the list (start at the second element)
    for x in num_list[1:]:
        # store result of current_max and x
        current_max = maximum(current_max, x)
    
    print(f"Maximum number is {current_max}")

Hopefully, you can see that we didn't really care how maximum() worked, we just knew that maximum() takes in an x and y and returns the larger.

3. Program - Maximum of a list (Function)

I now want to take this opportunity to rewrite this. Let's create a function that can take in a list of numbers and outputs the maximum.

def maximum(x, y):
    if x > y:
        return x
    else:
        return y

def max_list(num_list):
    current_max = num_list[0]
    for x in num_list[1:]:
        current_max = maximum(current_max, x)
    return current_max

if __name__ == "__main__":
    list_one = [3,6,2,1,8,4,4,2,7]
    list_two = [30,16,4,45,27,84]
    list_three = [-10,-4,-3,-2]
    
    print(f"Maximum of list 1 is {max_list(list_one)}")
    print(f"Maximum of list 2 is {max_list(list_two)}")
    print(f"Maximum of list 3 is {max_list(list_three)}")

Congratulations, you have just written a function max_list() that takes in a list of numbers and returns the maximum.

This is a reusable function that we don't really care how it works, we just know it works! This is the essence of what we call decomposition and abstraction which we will look at in unit 7.

Interestingly Python provides such a function for us. Try typing this into the Python Interactive Shell.

max([5,2,4,7])

Again let me stress, max() is a built-in function that we do not know how it works. We just know it takes list and returns the maximum.

You can type the following into the Python Interactive Shell to get help information about the max() function.

help(max)

Scope

This can be a tough one to get your head around, so I suggest you speak to the instructors in the labs if it doesn't make sense.

When you define a function you define a new namespace also called a scope.

Any variable defined within the function is said to be a local variable and can only be accessed inside the function (the scope of the function).

We use function parameters and the return value to exchange information between parts of our program.

NOTE: I do not use the if __name__ == "__main__": block here, it is NOT required, but normally it is very good practice to use it.

1. Example 1

Let's illustrate this using the example from Guttag, J.V. (2021).

def f(x):
  # variables defined here are only available to f() - local
  # we also do not have access to variables created outside of f()
  y = 1
  x = x + y
  print(f"x = {x}")
  return x


# main body of code starts here
# note we have left out if __name__ == "__main__" here. It isn't needed, but is recommended.
x = 3
y = 3
z = f(x)
print(f"z = {z}")
print(f"x = {x}")
print(f"y = {y}")

When a function is called, it creates a new stack frame and any variable defined in the function scope exists in this stack frame and is local to this stack frame.

Once the function has finished, the stack frame is removed and the local variables are also removed.

Here x and y are first defined on the global frame. Then we call f(x) which creates a new stack frame and new variables x and y. We say that information is passed to f()

These two x's and ys' are different! I cannot stress the importance of this. The same name, but different scope. They are not the same variable!

I have created an animation from Python Tutor which shows the stack frame that is created when the function is called and the other local variable x.

Scope Animation

I would also copy the example into Python Tutor and follow the program line by line.

2. Example 2

The following code defines two functions f() and g() and calls both of them one after another.

def f(x):
  """ adds 1 to x and prints result"""
  y = 1
  x = x + y
  print(f"x = {x}")
  return x

def g(x):
  """ adds 2 to x and prints result"""
  y = 2
  x = x + y
  print(f"x = {x}")
  return x

# main body of code starts here
x = 3
y = 3
z = f(x)
t = g(x)
print(f"z = {z}")
print(f"t = {t}")
print(f"x = {x}")
print(f"y = {y}")

This has the effect of creating a stack frame for f(). When f() is finished the stack frame is removed.

Next a stack frame for g() is created. When g() is finished the stack frame is removed.

The program now drops back to the global stack frame and completes.

The animation below illustrates this visually.

Scope Animation

Again, I would also copy the example into Python Tutor and follow the program line by line.

3. Example 3

Each call to a function creates a new stack frame.

So if we call a function from within a function, we actually create a new stack frame on top of the previous stack frame.

The reason we call them stack frames is because they get stacked upon each other.

Here f() has been updated to actually call g() from within its function body.

def f(x):
  """ adds g(x) to x and prints result"""
  y = g(x)
  x = x + y
  print(f"x = {x}")
  return x

def g(x):
  """ adds 2 to x and prints result"""
  y = 2
  x = x + y
  print(f"x = {x}")
  return x

# main body of code starts here
x = 3
y = 3
z = f(x)
print(f"z = {z}")
print(f"x = {x}")
print(f"y = {y}")

You will see from the animation or, by experimenting on Python Tutor or using the debugger, that we end up with the following stack frames all at once.

  • Global stack frame
  • f - stack frame for f()
  • g - stack frame for g()

Once g() finishes it is popped off (removed) from the stack and we go back to f(). Once f() finishes we go back to the global frame.

Note that Python Tutor displays them underneath each other, if it helps think of them stacked on top of each other.

Scope Animation


=== TASK ===

Don't skip reading the above lesson, in the long term it won't help you!

This one is a bit of a freebie.

Print out the following about scope, namespaces, local variables, and stack frames

print("When you define a function, you define a new namespace also called a scope.\n")

print("Any variable defined within the function is said to be a local variable and can only be accessed inside the function (the scope of the function).\n")
 
print("Calling a function creates a new stack frame and any variable defined in the function scope exists on this stack frame and is local to this stack frame.")

References

Guttag, J.V. (2021). Introduction to Computation and Programming Using Python, Third Edition (3rd ed.). MIT Press.

Keyword Arguments and Default Values

1. Parameters and Arguments

To date, our functions have just had parameters such as:

def print_name_age(name, age):
    print(f"Hello {name}, your age is {age}")

This function takes two parameters; name and age. When we call the function we can provide arguments (values) to the function that are bound to the parameters.

So print_name_age("Jimi", 27) will bind name = Jimi and age = 27. These are then used in the print statement.

Formally these are known as positional parameters because you call the function and pass the arguments in the correct position.

2. Keyword Arguments and Default Values

Python provides another way of defining the parameters of a function called keyword arguments.

You can think of these as optional arguments, you will though need to give them a default value.

Let's look at an example from Guttag, J.V. (2021).

def print_name(first_name, last_name, reverse = False):
    if reverse:
        print(f"{last_name}, {first_name}")
    else:
        print(f"{first_name}, {last_name}")

print_name() is now a function whereby the third parameter is a keyword argument with a default value of False.

This means we can leave out the keyword argument. We can just call using the two positional parameters first_name and last_name. Inside the function reverse will take on the default value of False.

print_name("Jimi", "Hendrix")  # prints out Jimi, Hendrix

We can also choose to call the function and overwrite the value of the keyword argument, for example we can tell the function to set reverse to False.

print_name("Jimi", "Hendrix", reverse = True)  # prints out Hendrix, Jimi

which prints out Hendrix, Jimi because reverse is True.

There is also nothing stopping you setting reverse to False, this is perfectly legal, but not necessary.

print_name("Jimi", "Hendrix", reverse = False)  # prints out Jimi, Hendrix

2.1 Order matters.

You can't put keyword arguments before positional parameters.

The following is illegal in Python:

def print_name(reverse = False, first_name, last_name):
    if reverse:
        print(f"{last_name}, {first_name}")
    else:
        print(f"{first_name}, {last_name}")

2.2 Another Simple Example

Let's say you want a price() function that calculates discounted price.

This has an obvious default, that is, no discount.

Here is a function that reflects this. Note that discount is a percentage between 0 and 100.

def calculate_price(price, discount=0):
    """ returns new price with discount applied"""
    return price * (1 - discount/100)

if __name__ == "__main__":
    print(calculate_price(30)) # will just return 30. No discount 
    print(calculate_price(30, discount=50)) # will just return 15. 50% discount on the price

We could extend this to add an optional tax argument.

def calculate_price(price, discount=0, tax=20):
    """ returns new price with discount applied and tax"""
    total_before_tax = price * (1 - discount/100)
    total_after_tax = total_before_tax * (1 + tax/100)
    return total_after_tax

if __name__ == "__main__":
    print(calculate_price(30)) # will just return 36. No discount, tax=20
    print(calculate_price(30, discount=50)) # will return 18. 50% discount on the price, tax=20
    print(calculate_price(30, discount=50, tax=25)) # will return 18.75. 50% discount on the price, tax=25

3. A Few More Keyword Arguments

Just to complete the picture. Here is a function with two keyword arguments. You can have any number of keyword arguments.

def print_person(first_name, last_name, reverse=True, age=None):
    if reverse:
        print(f"{last_name}, {first_name}")
    else:
        print(f"{first_name}, {last_name}")
    
    if age:
        print(f"Aged {age}.")

We can call this function a number of different ways.

print("Jimi", "Hendrix") # Prints Jimi, Hendrix
print("Jimi", "Hendrix", age = 27) 
# Prints 
# Jimi, Hendrix
# Aged 27.
print("Jimi", "Hendrix", age = 27, reverse = True) 
# Prints 
# Hendrix, Jimi
# Aged 27.

Some of you may have spotted in the last example that age and reverse are given in a different order to the function definition.

This is deliberate, once you have given the positional arguments, you can give the keyword arguments in any order.

4. Mutable Keyword Arguments (DO NOT USE)

Consider the following code:

def test_keyword_mutability(list_one=[]):
    list_one.append("sam")
    return list_one

if __name__ == "__main__":
    test_keyword_mutability()
    test_keyword_mutability()

We would expect this to print out:

['sam']
['sam']

Instead it will print out:

['sam']
['sam', 'sam']

This wasn't the intention, this is because the keyword argument list_one=[] is mutable and Python stores it the when the function is defined, not when it is called.

We can correct this by doing the following:

def test_keyword_mutability(list_one=None):
    if list_one is None:
        list_one = []
    list_one.append("sam")
    return list_one

if __name__ == "__main__":
  test_keyword_mutability()
  test_keyword_mutability()
['sam']
['sam']

Much better!

For more information you can see this link:

Python Mutable Defaults Are The Source of All Evil

Common Gotchas


=== TASK ===

Write a function called print_list that prints the multiple of each item in the list

For example print_list([4,1,6,7], multiple = 2)

8
2
12
14

For example print_list([4,1,6,7], multiple = 3)

12
3
18
21

By default your function when called without the keyword argument multiple, i.e. print_list([4,1,6,7]) should output:

4
1
6
7

Copy the following into a new Python file to get started.

def print_list():
    pass

if __name__ == "__main__":
    print_list([4,1,6,7], multiple = 2)
    print_list([4,1,6,7], multiple = 3)
    print_list([4,1,6,7])

Note: Due to the way that the tests are written for this task, an incorrect solution may result in the tests looking as though they are "hanging". Refresh the page to reattempt the test after considering why your solution may be incorrect.

References

Guttag, J.V. (2021). Introduction to Computation and Programming Using Python, Third Edition (3rd ed.). MIT Press.

Specification and Docstrings

When defining functions it is important to inform the person using your function of three things

  1. What does the function do?
  2. What are the parameters of the function and what type should they be?
  3. What does the function return?

Sometimes a function won't take parameters or return anything, so not all of these need documenting.

The standard way of doing these in Python is docstrings. A docstring uses triple quotes """.

1. Creating a Docstring

It is easiest to look at a simple example.

The following function is the simple add function we have seen before:

def add(x, y):
    return x + y

The answers to the 3 questions above for this function are

  1. Adds up two numbers and return the result.
  2. x (int or float), y (int or float). These are the numbers to add.
  3. Returns the sum of x and y.

The following code snippet shows how we add these with a docstring.

def add(x, y):
    """
    Adds up two numbers and returns the result.
    
    Parameters
    ----------
    x: int or float
    First number to add.
    y: int or float
    Second number to add.
    
    Returns
    -------
    int or float
      Sum of x and y.
    """
    return x + y

Our basic template is:

    """
    Function description
    
    Parameters
    ----------
    param_name: type
    Description of parameter
    param_name: type
    Description of parameter
    .
    .
    .
    
    Returns
    -------
    type
      Description of return value
    """

We will be using the NumPy standard in this course that works with the documentation generator library Sphinx. We are not looking at this in detail in this course but is important to know about it.

NumPy Standard

If we are missing either the parameters or return statement then we just leave these out of the docstring.

def print_hello():
    """
    Prints Hello.
    """
    print("Hello")
def print_sum(x, y):
    """
    Prints the sum of the two numbers.
    
    Parameters
    ----------
    x: int or float
    First number to add.
    y: int or float
    Second number to add.
    """
    print(x + y)

And even though you would rarely see a function like the following, for completeness.

def five():
    """
    Returns 5
    
    Returns
    -------
    int
    Always returns 5
    """
    return 5

2. Python's help() Function

Python has many built-in functions. You can find an extensive list below.

Python Built-in Functions

One of these functions is help() which will display the docstring of a function.

Try the following in the terminal:

help(abs)

This gives you the docstring for the built-in function abs().

3. When To Document

Generally, when you are hacking away at your own code, documentation probably will be furthest from your mind. I would still suggest putting in a short description.

Every programmer has written some complicated code and then come back to it at a later date and not had a clue what it does. This then requires time, some hints will help you.

Clearly when working with others and on a shared codebase documenting is important. It is also important if people will be using your code and they would like some hints by using the help() function. If you don't write a docstring, there won't be any help!


=== TASK ===

Copy the following into a new Python file. You won't have to understand this code to pass the task, but you will need to read on.

def add(x, y):
    """
    Adds up two numbers and returns the result.

    Parameters
    ----------
    x: int or float
    First number to add.
    y: int or float
    Second number to add.

    Returns
    -------
    int or float
     Sum of x and y.
    """
    return x + y


# Example from Guttang (2021)
def find_root(x, power, epsilon=0.01):
    """
    Find a y such that y**power = x (within epsilon x).

    e.g. For x = 27 and power = 3 then y = 3 as 3**3 = 27.
    i.e. the cubed root of 27 is 3.

    Parameters
    ----------
    x :int or float
    Number we want the root of.
    power: int
    Root number e.g. square root, power = 2.
    epsilon: int or float, default 0.01 
    Error tolerance for the answer.

    Returns
    -------
    float or None
    """
    # if x is negative and power even. No root exists
    if x < 0 and power % 2 == 0:
        return None
    low = min(-1.0, x)
    high = max(1.0, x)
    ans = (high + low) / 2.0
    # check if the answer is close enough
    while abs(ans**power - x) >= epsilon:
        if ans**power < x:
            low = ans
        else:
            high = ans    
        ans = (high + low) / 2.0
    return ans
   
if __name__ == "__main__":
    # add the line of code here
    pass

The built-in help() function allows us to print out the docstring of a function.

You will also see a more complicated function called find_root.

For now, it doesn't matter how it works, but the docstring does tell us what it does and requires a longer description.

Add a line of code to the bottom that prints out the docstring for find_root by using the help() function. Make sure you don't use any indentation so that the code isn't inside a function.

Think carefully, help() is a function that prints out to the terminal and returns None.


The Receive and Return Program

This is an example program from:

Dawson, M. (2010). Python programming for the absolute beginner, third edition (3rd ed.). Delmar Cengage Learning.

It demonstrates three variations of functions.

display() receives one parameter message, prints it out, and then does not have a return statement. It will therefore return None.

give_me_five() receives no parameters and it returns the value 5.

ask_yes_no() receives one parameter question and asks the user to answer "y" or "n". It returns the user's response.

1. Complete Program

# Receive and Return
# Demonstrates parameters and return values

def display(message):
    print(message)
  
def give_me_five():
    return 5
  
def ask_yes_no(question):
    """Ask a yes or no question."""
    print(question)
    print("\nPlease enter 'y' or 'n': ")
    response = None
    while response not in ("y", "n"):
        response = input().lower()
    return response

if __name__ == "__main__":
    # main
    display("Here's a message for you.\n")
    number = give_me_five()
    print("Here's what I got from give_me_five():", number)
    answer = ask_yes_no("\nDo you like Python? ")
    print("\nThanks for entering:", answer)
    input("\n\nPress the enter key to exit.")

2. The Function Specifications

# Receive and Return
# Demonstrates parameters and return values

def display(message):
    """Prints out the message.
    
    Parameters
    ----------
    message: str
    Message to display.
    """
    print(message)
  
def give_me_five():
    """Returns the value 5.
    
    Returns
    -------
    int
    Always returns 5
    """
    return 5
  
def ask_yes_no(question):
    """Ask a yes or no question to the user and return the answer "y" or "n".
    
    Parameters
    ----------
    question: str
    The question to ask the user.
    
    Returns
    -------
    str 
    The response of the user. Either "y" or "n".
    """
    print(question)
    print("\nPlease enter 'y' or 'n': ")
    response = None
    while response not in ("y", "n"):
        response = input().lower()
    return response

OK, so our code now looks more verbose, but it is much clearer to someone else what the function does.

Sometimes functions are complicated and giving the person reading it a good specification is important.

How this is done can differ between projects and programmers, but in larger codebases, it is very important as programmers may come and go or hundreds or thousands of programmers may work on a single codebase.

Passing by Assignment

WARNING: This is really fundamental and can cause major bugs and errors if you don't understand it.

I strongly suggest that you paste the examples in Python Tutor and work through them.

If you are struggling to follow this then please speak to the instructors in the practicals.

Python differs from other programming languages in that everything is an object.

Some of these objects are immutable (cannot be changed) and some are mutable (can be changed).

Immutable and mutable types are common in most languages and how these are dealt with differs.

We are specifically talking about what Python does.

1. Immutable vs Mutable Types

1.2 Immutable Type

An immutable type is something that once created cannot be changed. Immutable types you have seen in Python are str, float, int and bool.

Consider the following example which uses int. Here we also use the is keyword to see if x and y are the same object in memory.

x = 1
y = x
print(x is y) # prints True. They are the same object in memory

y += 1
print(x) # prints 1
print(y) # prints 2
print(x is y) # prints False. They are now different objects in memory

The first print(x is y) prints True because x and y are both attached to the same immutable object 1.

The reason the second print(x is y) prints False is because to start with x and y are attached to the immutable object 1; however, when we add 1 to y Python creates a new object and reassigns y to 2.

Now x points to the object 1 in memory and y points to the object 2 in memory. Different objects!

1.2 Mutable Type

Lists are mutable. If we do something similar to the above we get a very different result.

Consider the following example which uses list. Here we also use the is keyword to see if list_one and list_two are the same object in memory.

list_one = [1,2,3]
list_two = list_one
list_two.append(4)

print(list_one) # prints [1,2,3,4]
print(list_two) # prints [1,2,3,4]
print(list_one is list_two) # prints True. They are the same object in memory

When we assign list_two = list_one both names are pointing to the same object [1,2,3] in memory.

When we call the append method, we add 4 to the end of the list [1,2,3]. Both list_one and list_two still point to the same list which is now [1,2,3,4]. Hence print(list_one is list_two) prints True.

We can stop this from happening by using a copy.

Here we assign list_two to a copy of the object [1,2,3] in memory. This now means that list_one points to one object [1,2,3] and list_two points to a separate object [1,2,3].

list_one = [1,2,3]
list_two = list_one.copy() # now we create a copy 
list_two.append(4) 

print(list_one) # prints [1,2,3]
print(list_two) # prints [1,2,3,4]
print(list_one is list_two) # prints False. They are now different objects in memory

The append() method is now only called on the object attached to list_two and therefore the object attached to list_one is not updated. Hence print(list_one is list_two) prints False.

2. Passing by Assignment

When we pass arguments to functions, what we are actually doing is binding objects to the parameters of the function.

2.1 Passing an Immutable Type

n Python, a variable is just a name (label) that can be attached to an object. When we pass an object to a function, the function parameter is attached to the passed object

In the example below x will be attached to whatever is passed into the function add_one(). If you pass in an immutable object, as soon as you try to update the object it will create a new object to reflect the changes.

Remember immutable objects can't be changed.

The following example illustrates this. We pass in a number that is attached to num and then add 1. Because we are trying to change an immutable object, we have to create a new object. However, we don't return anything and reassign, so the original variable num_one is not changed.

def add_one(num):
  num += 1 # add one to the local variable x

if __name__ == "__main__":
    num_one = 1
    print(num_one) # prints 1 
    add_one(num_one)
    print(num_one) # prints 1

We can reflect the changes by returning the result and reassigning it to num_one.

def add_one(num):
  num += 1
  return num # return the new object created by adding 1

if __name__ == "__main__":
    num_one = 1
    print(num_one) # prints 1
    num_one = add_one(num_one) # reassign x1 to the result of add_one()
    print(num_one) # prints 2

2.2 Passing a Mutable Type

When you pass a mutable object to a function and change it you are updating the original object. That is doing something to the object passed to the function doesn't create a new copy, it operates on the same object!

Here is a simple example that adds 4 to the end of a list.

def func_one(items):
  """ append 4 to the list. """
  # append adds to the end of the list
  items.append(4)

if __name__ == "__main__":
    list_one = [1, 2, 3]
    func_one(list_one) # call func_one with list_one
    print(list_one) # prints [1,2,3,4] - this has been updated by the function call

The object attached to variable list_one is first passed to func_one() and the variable items is attached to the object.

The function then adds 4 to the end of the list using the append() method. What happens here is both list_one and items point to the mutable list. When it is updated, both are updated as they point to the same object.

Below are some more examples that illustrate passing a list to a function.

def func_one(items):
    """ append 4 to the list. """
    # append adds to the end of the list
    items.append(4)

def func_two(items):
    """ add the list and [4] """
    # this creates a new object by adding the lists items and [4]
    items = items + [4]

def func_three(items):
    """ add the list and [4] """
    # again this creates a new object by adding the lists items and [4]
    items = items + [4]
    # however this time we return the new object
    return items

if __name__ == "__main__":
    list_one = [1, 2, 3]
    func_one(list_one) # call func_one with list_one
    print(list_one) # prints [1,2,3,4] - this has been updated by the function call
    
    list_one = [1, 2, 3]
    func_two(list_one) # call func_two
    print(list_one) # prints [1,2,3] - this has not been updated by the function call
    
    list_one = [1, 2, 3]
    list_two = func_three(list_one) # call func_three and bind to the variable list_two
    print(list_two) # prints [1,2,3,4] - the new object created by the function call

func_one() operates on the original list, this means you don't need to return anything. You could choose to return the list here, but there is no point.

func_two() adds the original list and the list [4]. This creates a new list but we do not return it and therefore we lose the new list.

func_three() adds the original list and the list [4]. This creates a new list and we return it. This means we can use it outside of our function.

I would experiment with these in Python Tutor to make sure you understand what is going on.

3.3 To Return or Not To Return

Generally, if you are operating on a mutable object and you don't need to keep a copy of the original then there is no need to return anything.

If you need the original list intact then you need to copy the original list before you operate on it and return the modified copy. See the next section.

3.4 Copy and Deep Copy

We will talk about these more in unit 6, but we can stop Python from updating the original list by using a copy of the list.

For now, we will only need the copy() function as we will demonstrate on a list of numbers. Things get more complicated if we operate on a list that contains other mutable data types like a list of lists.

import copy

def func_four(items):
  """ append 4 to a copy of the list. """
  # create a copy of the list
  new_list = copy.copy(items)
  # append to the copy
  new_list.append(4)
  # return the copy
  return new_list
if __name__ == "__main__":
    list_one = [1,2,3]
    list_two = func_four(list_one)
    print(list_one)  # prints [1,2,3]
    print(list_two)  # prints [1,2,3,4]

Note, we could have overwritten the original list by reassigning the return value. e.g.

list_one = [1,2,3]
print(list_one) # prints [1,2,3]
list_one = func_four(list_one)
print(list_one)  # prints [1,2,3,4]

=== TASK ===

Create two functions. The first function update_list_item_one() should take a list and a number and update the first item in the list with the number. It should operate on the original list. It does not require a return statement.

For example,

list_one = [1,2,3,4]
update_list_item_one(list_one, 0)
print(list_one) # prints out [0,2,3,4]

The second function new_list_item_one() should create a copy of the list that is passed into the function (this will be a new object), update the first item in the new list and then return the new list.

For example,

list_one = [1,2,3,4]
new_list_item_one(list_one, 0)
print(list_one) # prints out [1,2,3,4]

does not update the original list, however,

list_one = [1,2,3,4]
list_one = new_list_item_one(list_one, 0) # bind the new object to list_one
print(list_one) # prints out [0,2,3,4]

reflects the changes made because the new object returned by new_list_item_one() was reassigned to list_one.

To get you started copy the following into a new Python file.

import copy

def update_list_item_one(items, x):
  pass

def new_list_item_one(items, x):
  pass

if __name__ == "__main__":
    list_one = [1,2,3,4]
    update_list_item_one(list_one, 0)
    print(list_one) # should print out [0,2,3,4]
    
    list_one = [1,2,3,4]
    new_list_item_one(list_one, 0)
    print(list_one) # should print out [1,2,3,4]
    
    list_one = [1,2,3,4]
    list_one = new_list_item_one(list_one, 0) # bind the new object to l
    print(list_one) # should print out [0,2,3,4]

References

Python Docs - Passing by Assignment

Python Memory Model

Unit 6 - Lists, Tuples, Sets, and Dictionaries

Data structures are a fundamental concept in all programming languages as a means of organising and accessing data in your programs.

They allow us to store and retrieve information in different parts of our programs.

Python is no different and supports 4 built-in data structures.

  1. Lists
  2. Tuples
  3. Sets
  4. Dictionaries

Lists and Dictionaries are by far the most important and frequently used, but we will deal with them in the order given above in this unit.

Lists

Lists are by far the most used data structure in Python, so we will spend a bit more time on them.

The list type in Python is used to store multiple items (objects) in a single variable.

We have already seen them in use, but here is a quick example:

car_list = ["Volvo", "Audi", "Honda", "Ford", "Mercedes"]

This is a list of strings which has 5 items. If you need convincing that this is a list type, then you can use type(car_list) to check the type.

Lists are hetrogeneous, which is a fancy way of saying we can store different data types. They can also store duplicates e.g.

car_list = ["Volvo", 1, False, "Ford", "Ford", False, 3.14]

This list has both different types and duplicates.

1. List Items

Each object in the list is called an item (element). We will use the previous example to demonstrate simple operations on lists.

If you want to follow along then type this into the Python interactive shell.

car_list = ["Volvo", "Audi", "Honda", "Ford", "Mercedes"]

1.1 Access List Items

You can access a list item by it's index. Python indexing starts at 0, so to get the first item of a list you would use.

For example we can print the first item using,

print(car_list[0]) # prints out Volvo

or the third item by using:

car_list[2] # prints out Honda

Notice how the car_list has 5 items. So it's indexing goes from 0-4.

If you try typing,

car_list[5] # IndexError

you will get an IndexError because your list index if not in the valid range 0-4.

1.1.1 Slicing

You can also access slices (portions) of a list. For example we can access items 1,2,3 using:

print(car_list[1:4]) # prints ["Volvo", "Audi", "Honda"]

1.1.2 Negative Indices

You can also access from the back of the list. To get the last item use -1.

print(car_list[-1]) # prints ["Mercedes"]

You can also get slices such as the last three items.

print(car_list[-4,-1]) # prints ["Honda", "Ford", "Mercedes"]

1.2 Changing List Items

You change a list item using indexing as follows:

car_list[2] = "Ferrari"

If we print out the list,

print(car_list) # prints ["Volvo", "Audi", "Ferrari", "Ford", "Mercedes"]

you will get ['Volvo', 'Audi', 'Ferrari', 'Ford', 'Mercedes'].

1.3 Adding List Items

You can add to a list by using the append() and insert() methods.

Methods are very much like functions, they take in arguments and return something. Hey, they even look like them!

The key difference is a method is attached to an object and you call the method using dot notation.

To add to the end of the list use append() method. Note that it is called using a .:

car_list.append("Skoda") # ["Volvo", "Audi", "Ferrari", "Ford", "Mercedes", "Skoda"]

Here the str "Skoda" is added to the end of the list.

To insert an item somewhere use the insert() method, you supply the index as the first parameter and the item as the second.

car_list.insert(2, "Porsche") # insert "Porsche" at index 2, i.e. 3rd item 
# ["Volvo", "Audi", "Porsche", "Ferrari", "Ford", "Mercedes", "Skoda"]

1.4 Extending and Joining Lists

To extend the list by multiple items you can use either the extend() method or the + operator.

list_one = ["a", "b" , "c"]
list_two = [1, 2, 3]
list_one.extend(list_two) # list_one now contains ["a", "b", "c", 1, 2, 3]
list_one = ["a", "b" , "c"]
list_two = [1, 2, 3]
list_three = list_one + list_two # list_three contains ["a", "b", "c", 1, 2, 3]

Note the difference, the extend() method extends the list it is operating on (e.g. list_one). The + operator creates a new list by joining the lists, we stored this in list_three.

We can use the + operator to join many lists.

list_one = ["a", "b" , "c"]
list_two = [1, 2, 3]
list_three = [True, False]
list_four = list_one + list_two + list_three # list4 contains ["a", "b", "c", 1, 2, 3, True, False]

1.5 Remove List Items

Again working on the car example:

car_list = ["Volvo", "Audi", "Honda", "Ford", "Mercedes"]

You can remove a list item using either remove() or pop() methods, or the del keyword.


remove() lets you remove an item by specifying the value of the item, .e.g.

car_list.remove("Audi") # removes the second item of the list 

If you try to remove an object that isn't in the list you will get a ValueError. For example

car_list.remove("Ferrari") # ValueError: list.remove(x): x not in list

Note that this returns None. This is demonstrated by this code snippet.

car_list = ["Volvo", "Audi", "Honda", "Ford", "Mercedes"]
item_removed = car_list.remove("Audi")
print(car_list) # prints ["Volvo", "Honda", "Ford", "Mercedes"]
print(item_removed) # prints None

Here we stored the return value of remove() in item_removed which was None.


pop() lets you remove an item by index, e.g.

car_list.pop(1) # removes the second item of the list

Note that this returns the item that is removed from the list. This is demonstrated by this code snippet.

car_list = ["Volvo", "Audi", "Honda", "Ford", "Mercedes"]
item_removed = car_list.pop(1)
print(car_list) # prints ["Volvo", "Honda", "Ford", "Mercedes"]
print(item_removed) # prints Audi

The del keyword also lets you remove by index, e.g.

del car_list[1] # removes the second item of the list

We can also remove slices.

car_list = ["Volvo", "Audi", "Honda", "Ford", "Mercedes"]
del car_list[1:3]
print(car_list) # prints ["Volvo", "Ford", "Mercedes"]

1.6 Note on In-place Methods

All the methods we have seen operating on a list are known as an in-place method as they update the list directly. We will discuss this more in the next lesson.

Also be careful with understanding what a method returns. If you tried the following,

print(car_list.append("Renault")) # prints None

it will update the list, but print the return value which is None.

2. List Methods

There are a number of built-in list methods you can use:

MethodDescription
append()Adds an element at the end of the list
clear()Removes all the elements from the list
copy()Returns a copy of the list
count()Returns the number of elements with the specified value
extend()Add the elements of a list (or any iterable), to the end of the current list
index()Returns the index of the first element with the specified value
insert()Adds an element at the specified position
pop()Removes the element at the specified position
remove()Removes the item with the specified value
reverse()Reverses the order of the list
sort()Sorts the list

For example we can sort the list as follows:

car_list = ["Volvo", "Audi", "Honda", "Ford", "Mercedes"]

car_list.sort() # in-place operation, sorts car_list

print(car_list) # prints ['Audi', 'Ford', 'Honda', 'Mercedes', 'Volvo']

3. Looping Through a List

You can loop through a list using a for loop.

car_list = ["Volvo", "Audi", "Honda", "Ford", "Mercedes"]

for car in car_list:
    print(car)

# prints out...
# Volvo
# Audi
# Honda
# Ford
# Mercedes

We can also loop through a list using a while loop.

car_list = ["Volvo", "Audi", "Honda", "Ford", "Mercedes"]

i = 0
while i < len(car_list):
    print(car_list[i])
    i += 1

# prints out...
# Volvo
# Audi
# Honda
# Ford
# Mercedes

3.1. List of lists

We can also have lists of lists. These are very common and you will need to now how to iterate (loop) over them

Here is an example that just iterates over the list and prints out each of the sublists.

list_one = [[4,2,6], [3,8,3], [5,4,7]]
for sub_list in list_one:
    print(sub_list)

# prints out...
# [4,2,6]
# [3,8,3]
# [5,4,7]

As each of the sublists is a list, we can iterate over each of these.

list_one = [[4, 2, 6], [3, 8, 3], [5, 4, 7]]
for sub_list in list_one:
    for x in sub_list:
        print(x)
# prints out...
# 4
# 2
# 6
# 3
# 8
# 3
# 5
# 4
# 7

We can even write a simple program that gives us back a list of the total of each sublist. Here we create an empty list called sum_list and then we compute the total of each sublist and store it in the sum_list.

list_one = [[4, 2, 6], [3, 8, 3], [5, 4, 7]]

sum_list = []

for sub_list in list_one:
    total = 0
    for x in sub_list:  # iterate over the sub_list
        total += x  # keep adding items in the sub_list to total

    sum_list.append(total)  # append the result to sum_list

print(sum_list)  # prints [12,14,16]

3.2. Zipping Two Lists

Zipping lists together is a very useful thing to do when we want to combine lists together and loop over them.

The zip operation is demonstrated by the following code snippet. Note both must be the same length.

list_one = [1,2,3]
list_two = ["a","b","c"]

for x,y in zip(list_one, list_two):
    print(x,y)

# prints
# 1 a
# 2 b
# 3 c

=== TASK ===

Copy the following into a new Python file to get started.

def longest_word(string_list):
    pass # placeholder, delete and replace with your code
  
def num_characters(string_list):
    pass # placeholder, delete and replace with your code
  
if __name__ == "__main__":
    test_string = [["Hello", "World"], ["You", "say", "you", "want", "a", "revolution"], ["It's", "fine", "to", "celebrate", "success", "but", "it", "is", "more", "important", "to", "heed", "the", "lessons", "of", "failure"]]
    test_string2 = [["Hello", "World"], ["Apple", "Pears"]]
    print(longest_word(test_string)) # should print revolution
    print(num_characters(test_string)) # should print 105
    print(longest_word(test_string2)) # should print hello
    print(num_characters(test_string2)) # should print 20

The first function is called longest_word which takes in a list of a list of strings and returns the longest word in any of the lists. In the case of equal length words, return the first word you encounter. In the case of no words, e.g. an empty list [] return None.

For example:

longest_word([["Hello", "World"], ["You", "say", "you", "want", "a", "revolution"], ["It's", "fine", "to", "celebrate", "success", "but", "it", "is", "more", "important", "to", "heed", "the", "lessons", "of", "failure"]]) # return revolution

The second is called num_characters which takes in a list of a list of strings and returns the total number of characters in all of the strings in the list lists.

num_characters([["Hello", "World"], ["You", "say", "you", "want", "a", "revolution"], ["It's", "fine", "to", "celebrate", "success", "but", "it", "is", "more", "important", "to", "heed", "the", "lessons", "of", "failure"]]) # return 105

References

Python List Methods - W3schools

MP: The High Scores Program

This is an example program from:

Dawson, M. (2010). Python programming for the absolute beginner, third edition (3rd ed.). Delmar Cengage Learning.

The following is a mini-program that demonstrates the use of lists to add, store, remove and access information.

It is a simple high scores program, not exactly useful without a game attached to it, but it illustrates the concepts.

Note the use of choice = None. This is useful to get the program started when we don't yet have a user defined choice.

Run the code to see what it does.

# High Scores 1.0
# Demonstrates list methods
scores = []
choice = None

while choice != "0":
    print(
        """
  High Scores
  0 - Exit
  1 - Show Scores
  2 - Add a Score
  3 - Delete a Score
  4 - Sort Scores
  """
    )
    choice = input("Choice: ")
    print()

    # exit
    if choice == "0":
        print("Good-bye.")
    # list high-score table
    elif choice == "1":
        print("High Scores")
        for score in scores:
            print(score)
    # add a score
    elif choice == "2":
        score = int(input("What score did you get?: "))
        scores.append(score)
    # remove a score
    elif choice == "3":
        score = int(input("Remove which score?: "))
        if score in scores:
            scores.remove(score)
        else:
            print(score, "isn't in the high scores list.")
    # sort scores
    elif choice == "4":
        scores.sort(reverse=True)
    # some unknown choice
    else:
        print("Sorry, but", choice, "isn't a valid choice.")

input("\n\nPress the enter key to exit.")

Note that you could refactor this code by moving each of the choices into a function. Here we have put the removal of a score into a function.

# This demonstrates that when you pass a list, you are accessing the original list, not a copy! 
def remove_score(score, scores):
    if score in scores:
        scores.remove(score)
    else:
        print(score, "isn't in the high scores list.")
.    
.    # rest of code as before
.
    # update choice 3 as follows.
    elif choice == "3":
        score = int(input("Remove which score?: "))
        remove_score(score, scores)

To pass the tests you can just run the original program, however, you should try and write functions for the other choices. It is good practice. It will also aid your understanding of decomposition and abstraction in the next unit.

More on Lists

Please carefully read this lesson, it is very important!

I highly recommend you copy and paste code snippets into Python Tutor.

It visualises what is happening and will really aid your understanding!

1. Copy and Deep Copy

1.1. Copying a List

Copying a list is a bit stickier than you might imagine!

Lists are stored using some things called references.

The variable (name) stores the list location in memory.

list1 = [1,2,3]
list2 = list1 # copies the reference to list1
print(f"List 1 = {list1}")
print(f"List 2 = {list2}")
list2.append(4) # this will update both list1 and list2
print(f"List 1 = {list1}") # prints [1,2,3,4]
print(f"List 2 = {list2}") # prints [1,2,3,4]

The above copies the reference to the list object. We can check this by using the id() function.

id(list1) # something like 140416978254080
id(list2) # will be the same id as above 140416978254080

To copy a list we use the copy() method.

list1 = [1,2,3]
list2 = list1.copy() # copies the list to a new object and store
print(f"List 1 = {list1}")
print(f"List 2 = {list2}")
list2.append(4) # this will update both list1 and list2
print(f"List 1 = {list1}") # prints [1,2,3]
print(f"List 2 = {list2}") # prints [1,2,3,4]

Now you will see that list1 is not updated when you append to list2.

Again we can check this with:

id(list1) # something like 140416978254080
id(list2) # will be a different id e.g. 140404308187456

1.2. Deep Copying a List

There is a caveat. When we have a list of lists, the copy() method will create copies of the lists, but not new copies of the items in the list.

Again, I strongly suggest that you copy this into Python Tutor and visually understand what is happening.

For example,

demo_list = [[1],2,3]
copy_list = demo_list.copy()

print(id(demo_list)) # these ids will be different
print(id(copy_list))

# print item 1 ids
print(id(demo_list[0])) # these ids are the same
print(id(copy_list[0]))

# print item 2 ids
print(id(demo_list[1])) # these ids are the same
print(id(copy_list[1]))

# print item 3 ids
print(id(demo_list[2])) # these ids are the same
print(id(copy_list[2]))

Because the item at index 0 is actually a list and is mutable (see the next section). Changing the items in this list will be reflected in both the orginal list and the copy of the list.

For example,

demo_list = [[1],2,3]
copy_list = demo_list.copy() # do a shallow copy

demo_list[0][0] = "Sam" # change the first item in the first item (this is a list)

print(demo_list) # prints [["Sam"],2,3]
print(copy_list) # prints [["Sam"],2,3]

Yikes, this updated both lists! Be careful.

To fix this you can use the deepcopy() function. You will need to import the copy module.

import copy
demo_list = [[1],2,3]
copy_list = copy.deepcopy(demo_list) # do a deep copy

demo_list[0][0] = "Sam" # change the first item in the first item (this is a list)

print(demo_list) # prints [["Sam"],2,3]
print(copy_list) # prints [[1],2,3]

Try running the above code in Python Tutor to get a better understanding of this.

2. Immutable vs Mutable

Python differs from other programming languages in that everything is an object.

Some of these objects are immutable (cannot be changed) and some are mutable (can be changed).

Immutable and mutable types are common in most languages and how these are dealt with differs.

2.1. Immutable Types

An immutable type is something that once created cannot be changed. Immutable types you have seen in Python are str, float, int and bool.

Consider the following example which uses int. Here we also use the is keyword to see if x and y are the same object in memory.

x = 1
y = x
print(x is y) # prints True. They are the same object in memory

y += 1
print(x) # prints 1
print(y) # prints 2
print(x is y) # prints False. They are now different objects in memory

The first print(x is y) prints True because x and y are both attached to the same immutable object 1.

The reason the second print(x is y) prints False is because to start with x and y are attached to the immutable object 1; however, when we add 1 to y Python creates a new object and reassigns y to 2.

Now x points to the object 1 in memory and y points to the object 2 in memory. Different objects!

2.2. Mutable Types

Lists are mutable. If we do something similar to the above we get a very different result.

Consider the following example which uses list. Here we also use the is keyword to see if list_one and list_two are the same object in memory.

list_one = [1,2,3]
list_two = list_one
list_two.append(4)

print(list_one) # prints [1,2,3,4]
print(list_two) # prints [1,2,3,4]
print(list_one is list_two) # prints True. They are the same object in memory

When we assign list_two = list_one both names are pointing to the same object [1,2,3] in memory.

When we call the append method, we add 4 to the end of the list [1,2,3]. Both list_one and list_two still point to the same list which is now [1,2,3,4]. Hence print(list_one is list_two) prints True.

We can stop this from happening by using a copy.

Here we assign list_two to a copy of the object [1,2,3] in memory. This now means that list_one points to one object [1,2,3] and list_two points to a separate object [1,2,3].

list_one = [1,2,3]
list_two = list_one.copy() # now we create a copy 
list_two.append(4) 

print(list_one) # prints [1,2,3]
print(list_two) # prints [1,2,3,4]
print(list_one is list_two) # prints False. They are now different objects in memory

The append() method is now only called on the object attached to list_two and therefore the object attached to list_one is not updated. Hence print(list_one is list_two) prints False.

3. Passing by Assignment

WARNING: This is really fundamental and can cause major bugs and errors if you don't understand it.

This section is repeated from Unit 5, but it is so fundamental I want to present it again.

When we pass arguments to functions, what we are actually doing is binding objects to the parameters of the function.

3.1 Passing an Immutable Type

In Python, a variable is just a name (label) that can be attached to an object. When we pass an object to a function, the function parameter is attached to the passed object

In the example below x will be attached to whatever is passed into the function add_one(). If you pass in an immutable object, as soon as you try to update the object it will create a new object to reflect the changes.

Remember immutable objects can't be changed.

The following example illustrates this. We pass in a number that is attached to num and then add 1. Because we are trying to change an immutable object, we have to create a new object. However, we don't return anything and reassign, so the original variable num_one is not changed.

def add_one(num):
    num += 1 # add one to the local variable x

num_one = 1
print(num_one) # prints 1 
add_one(num_one)
print(num_one) # prints 1

We can reflect the changes by returning the result and reassigning it to num_one.

def add_one(num):
    num += 1
    return num # return the new object created by adding 1

num_one = 1
print(num_one) # prints 1
num_one = add_one(num_one) # reassign x1 to the result of add_one()
print(num_one) # prints 2

3.2 Passing a Mutable Type

Try running the code snippets in Python Tutor to get a better understanding of the following.

When you pass a mutable object to a function and change it you are updating the original object. That is, doing something to the object passed to the function doesn't create a new copy, it operates on the same object!

Here is a simple example that adds 4 to the end of a list.

def func_one(items):
    """ append 4 to the list. """
    # append adds to the end of the list
    items.append(4)

list_one = [1, 2, 3]
func_one(list_one) # call func_one with list_one
print(list_one) # prints [1,2,3,4] - this has been updated by the function call

The object attached to variable list_one is first passed to func_one() and the variable items is attached to the object.

The function then adds 4 to the end of the list using the append() method. What happens here is both list_one and items point to the mutable list. When it is updated, both are updated as they point to the same object.

Below are some more examples that illustrate passing a list to a function.

def func_one(items):
    """append 4 to the list."""
    # append adds to the end of the list
    items.append(4)

def func_two(items):
    """add the list and [4]"""
    # this creates a new object by adding the lists items and [4]
    items = items + [4]

def func_three(items):
    """add the list and [4]"""
    # again this creates a new object by adding the lists items and [4]
    items = items + [4]
    # however this time we return the new object
    return items

list_one = [1, 2, 3]
func_one(list_one)  # call func_one with list_one
print(list_one)  # prints [1,2,3,4] - this has been updated by the function call

list_one = [1, 2, 3]
func_two(list_one)  # call func_two
print(list_one)  # prints [1,2,3] - this has not been updated by the function call

list_one = [1, 2, 3]
list_two = func_three(list_one)  # call func_three and bind to the variable list_two
print(list_two)  # prints [1,2,3,4] - the new object created by the function call

func_one() operates on the original list, this means you don't need to return anything. You could choose to return the list here, but there is no point.

func_two() adds the original list and the list [4]. This creates a new list but we do not return it and therefore we lose the new list.

func_three() adds the original list and the list [4]. This creates a new list and we return it. This means we can use it outside of our function.

I would experiment with these in Python Tutor to make sure you understand what is going on.

3.3 To Return or Not To Return

Generally, if you are operating on a mutable object and you don't need to keep a copy of the original then there is no need to return anything.

If you need the original list intact then you need to copy the original list before you operate on it and return the modified copy. Review Section 1 on Copy and Deep Copy.


=== TASK ===

Copy and paste the following into a new Python file to get started.

def change_list(my_list):
    pass

if __name__ == "__main__":
    fruit_list = ["Pear", "Orange", "Peach", "Apple"]
    print(fruit_list) # prints ["Pear", "Orange", "Peach", "Apple"]
    new_fruit_list = change_list(fruit_list)
    print(fruit_list) # prints ["Pear", "Orange", "Peach", "Apple"]
    print(new_fruit_list) # prints ["Apple", "Banana", "Peach", "Pear"]

There is a function called change_list() that you need to update.

The function should remove the second item, add "Banana" to the list and then sort the list.

The function should return the modified list without affecting the original list.

An example of how the function should work is given below.

fruit_list = ["Pear", "Orange", "Peach", "Apple"]
print(fruit_list) # prints ["Pear", "Orange", "Peach", "Apple"]
new_fruit_list = change_list(fruit_list)
print(fruit_list) # prints ["Pear", "Orange", "Peach", "Apple"]
print(new_fruit_list) # prints ["Apple", "Banana", "Peach", "Pear"]

Tuples

Tuples are very similar to lists but differ in a two key ways. Tuples are:

  1. Ordered
  2. Immutable (unchangeable)

By ordered we mean that the tuple has an order and it cannot be changed.

By immutable we mean that the tuple cannot be added to, removed from and items cannot be changed.

You define a tuple in much the same way as a list, but with round brackets, not square.

car_tuple = ("Audi", "Mercedes", "Ferrari")

1. Tuple Items

You can access tuple items using indexing, e.g.

print(car_tuple[1]) # prints the 2nd item of the tuple, "Mercedes"

You can also use slicing and negative indexing as per lists.

print(car_tuple[0:2]) # prints the 1st and 2nd items of the tuple, ("Audi", "Mercedes")
print(car_tuple[-1]) # prints the last item of the tuple, "Ferrari"

2. Looping Through a Tuple

You can loop through a tuple just like a list using a for loop.

car_tuple = ("Audi", "Mercedes", "Ferrari")

for car in car_tuple:
    print(car)

# prints..
# Audi
# Mercedes
# Ferrari

You can also use a while loop.

car_tuple = ("Audi", "Mercedes", "Ferrari")

i = 0
while i < len(car_tuple):
    print(car_tuple[i])
    i += 1

# prints..
# Audi
# Mercedes
# Ferrari

3. Unpacking a Tuple

It is often useful to unpack a tuple. This allows you to bind each item of a tuple to a variable.

car_tuple = ("Audi", "Mercedes", "Ferrari")
(x,y,z) = car_tuple # binds x = "Audi", y = "Mercedes" and z = "Ferrari"

3.1. Function Return Values

At first you might think what the point of this is. It comes in very handy in a few circumstances. One of the main ones is with function return values.

Remember that a function can only return one object, but it can return a tuple!

Suppose that we want a function to computer the sum, average and the length of a list.

def num_list_stats(num_list):
    len_list = len(num_list)
    sum_list = sum(num_list)
    avg_list = sum_list/len_list
    return (sum_list, avg_list, len_list)

# main code
test_list = [1,64,71,-3]
(test_sum, test_avg, test_len) = num_list_stats(test_list) # call function and bind return tuple
print(f"Sum of list is {test_sum}")
print(f"Average of list is {test_avg}")
print(f"Length of list is {test_len}")

In the code above num_list_stats takes a list and then computes the sum, average and length of the list and returns these as a 3-tuple.

The calling code unpacks this and binds it to the variables test_sum, test_avg and test_len.

Note that you can do it without the brackets, and it looks like it is returning 3 things, it isn't it is still just a returning a tuple.

def num_list_stats(num_list):
    len_list = len(num_list)
    sum_list = sum(num_list)
    avg_list = sum_list/len_list
    return sum_list, avg_list, len_list

# main code
test_list = [1,64,71,-3]
test_sum, test_avg, test_len = num_list_stats(test_list) # call function and bind return tuple

If you aren't convinced then try this piece of code.

def tuple_test(x,y):
    return x, y

print(tuple_test(1,2))          # prints (1,2)
print(type(tuple_test(1,2)))    # prints <class 'tuple'>

3.2. Zipping Two Tuples

Zipping tuples together is a very useful thing to do when we want to combine tuples together and loop over them.

The zip operation is demonstrated by the following code snippet. Note both must be the same length.

tuple_one = (1,2,3)
tuple_two = ("a","b","c")

for x,y in zip(tuple_one, tuple_two):
    print(x,y)

# prints
# 1 a
# 2 b
# 3 c

You can also zip a tuple and list.

tuple_one = (1,2,3)
list_one = ["a","b","c"]

for x,y in zip(tuple_one, list_one):
    print(x,y)

# prints
# 1 a
# 2 b
# 3 c

4. Tuple Methods

MethodDescription
count()Returns the number of times a specified value occurs in a tuple
index()Searches the tuple for a specified value and returns the position of where it was found

W3Schools - Tuple Methods

5. What if I Need to Change a Tuple?

You can do this by converting (casting) the tuple to a list using the list() and tuple() functions.

car_tuple = ("Audi", "Mercedes", "Ferrari")
temp = list(car_tuple)   # create a tempory copy of the tuple as a list
temp[0] = "Ford"         # Overwrite the 1st item 
car_tuple = tuple(temp)  # Reassign car_tuple as a new tuple using temp
print(car_tuple) # prints ("Ford", "Mercedes", "Ferrari")

=== TASK ===

Copy and paste the following into a new Python file to get started.

def tuple_search(t, search_index):
    pass

if __name__ == "__main__":
    # main code
    spaghetti_soup = ("a", "b", "g", "c", "a", "z", "g", "a", "g", "y")
    print(tuple_search(spaghetti_soup, "g")) # should print (3, 2)

There is a function called tuple_search() that you need to update.

The function should count the number of times an item occurs in the tuple and the index (position) of the first of these occurences.

tuple_search(t, search_item)

For example,

spaghetti_soup = ("a", "b", "g", "c", "a", "z", "g", "a", "g", "y")
tuple_search(spaghetti_soup, "g")

Should return (3, 2)


References

Python Tuple Methods - W3schools

The High Scores 2.0 Program

This is an example program from:

Dawson, M. (2010). Python programming for the absolute beginner, third edition (3rd ed.). Delmar Cengage Learning.

We can extend the functionality of our previous High Scores Program by changing the data structure that we use.

Now instead of storing just the score in a list. We now store the person's name and the score. Do do this we will use tuples inside a list. This is an example of a nested sequence.

scores = [(person_1, score_1), (person_2, score_2), ...]

For example,

scores = [("Sam", 3), (Bradley, 9999), ...]

Clearly I lost!

The following program shows how to use this to extend the listing/adding scores functionality. Run the code to see how it works.

You might want to consider rewriting the whole program for practice.

# High Scores 2.0
# Demonstrates nested sequences
scores = []
choice = None
while choice != "0":
    print(
        """
  High Scores 2.0
  0 - Quit
  1 - List Scores
  2 - Add a Score
  """
    )
    choice = input("Choice: \n")

    # exit
    if choice == "0":
        print("Good-bye.")
    # display high-score table
    elif choice == "1":
        print("High Scores\n")
        print("NAME\tSCORE")
        for entry in scores:
            score, name = entry
            print(name, "\t", score)
    # add a score
    elif choice == "2":
        name = input("What is the player's name?: ")
        score = int(input("What score did the player get?: "))
        entry = (score, name)
        scores.append(entry)  # append entry
        scores.sort(reverse=True)  # sort the list. Highest first
        scores = scores[:5]  # keep only top 5 scores
    # some unknown choice
    else:
        print("Sorry, but", choice, "isn't a valid choice.")

input("\n\nPress the enter key to exit.")

Sets

We will not spend long on sets as their use is somewhat limited, but they can come in very handy at times.

  1. Unordered and unindexed
  2. Items are immutable (unchangeable)
  3. Set is mutable (changable)
  4. Collection of distinct items

By unordered and unindexed we mean that the set does not have an order and therefore does not use indexing. You cannot access an item in a set by an index.

By items are immutable we mean that you cannot change an item in the set, this follows from it being unordered and unindexed. If you can't access an item, you definitely can't change it.

By set is mutable we mean that you can add and remove items from the set.

By collection of distinct items we mean that a set cannot contain duplicates.

You define a set as follows.

car_set = {"Audi", "Porsche", "Ford"}

Note that if you try and define a set with multiple items, it will only include one copy of each distinct item.

car_set = {"Audi", "Porsche", "Ford", "Audi"} # set is {"Audi", "Porsche", "Ford"}

1. Set Items

There are a few things we can do to a set involving items.

1.1 Is an Item in the Set?

The in keyword checks whether an item is in a given set and returns True or False.

"Audi" in car_set  # evaluates to True
"Skoda" in car_set # evaluates to False

Note that in can also be used on lists and tuples.

car_list = ["Audi", "Porsche", "Ford", "Audi"]
car_tuple = ("Audi", "Porsche", "Ford", "Audi")
"Audi" in car_list  # evaluates to True
"Audi" in car_tuple  # evaluates to True

1.2 Add an Item

You can add an item to a set by using the add() method.

car_set. add("Skoda") # set is {"Audi", "Porsche", "Ford", "Skoda"}

1.3 Remove an Item

You can remove an item in a set by using the remove() method.

car_set. remove("Ford") # set is {"Audi", "Porsche", "Skoda"}

2. Looping Through a Set

Just like lists and tuples, you can loop through a set.

car_set = {"Audi", "Porsche", "Ford"}
for car in car_set:
  print(car)

# prints...
# Audi
# Porsche
# Ford

3. Joining Sets

You can join sets using either the union() or intersection() methods.

For a mathematician, this is a very loose definition, but basically the union() method will include all the items that are in both sets, but not any duplicates.

a = {1,2,3}
b = {1,4,5,6}
c = a.union(b) # c now contains {1,2,3,4,5,6}

The intersection includes only the items that appear in both sets.

a = {1,2,3}
b = {1,4,5,6}
c = a.intersection(b) # c now contains {1}

4. Set Methods

There are a number of set methods that you can use, we have seen two in add() and remove(). We will not look at these in detail in this course.

You can find a full list here Python Set Methods - W3schools.

I suggest you use these when you are studying sets in computational mathematics.


=== TASK ===

Copy and paste the following into a new Python file to get started.

def items_in_both(a,b):
    pass

if __name__ == "__main__":
    # main
    # use this code to help check the function
    a = {1,2,3}
    b = {1,4,5,6}
    print(items_in_both(a,b))    # prints {1}

Update the function items_in_both() so that it returns the set that contains only the items that appear in both sets.


References

Python Sets - W3schools

Dictionaries

Dictionaries are a very common in Python are used to store key:value pairs.

In other languages these might be called something else such as a HashMap.

Dictionaries are:

  1. Indexed by Key
  2. Mutable (changeable)
  3. Hetrogeneous

By indexed by key we mean that each value that we store in the dictionary has a unique key that allows you to access it.

By mutable we mean that the items in the dictionary can be changed and we can add/remove key:value pairs

By hetrogeneous we mean that we can mixed different data types for the key:value pairs.

Here is a quick example:

student_dict = {
    1123425: "Ada Lovelace",
    1425243: "Melba Roy Mouton",
    1512414: "Alan Turing"
}
# stores student names by their id.
# Here key:value pair is id: name

Here is another example using different types as keys and values.

test_dict = {
    1: "Hello World",
    "Test": False,
    "Another Key":[1,2,3,4]
}

The only issue here is that keys need to be a hashable type. For now that means don't use a list as a key.

A good analogy to remember dictionaries is well, a dictionary. In a dictionary you look up a word (key) and you can get the corresponding description (value)

1. Dictionary Items

Let's use the student_dict as our example.

student_dict = {
    1123425: "Ada Lovelace",
    1425243: "Melba Roy Mouton",
    1512414: "Alan Turing"
}
# stores student names by their id.
# Here key:value pair is id: name

1.1 Access Dictionary Items

We can access a dictionary item using the key name inside square brackets.

print(student_dict[1123425]) # prints "Ada Lovelace"

When accessing the item using the key name, it returns the corresponding value.

1.2 Change Dictionary Items

We can change a dictionary item using the key name inside square brackets.

student_dict[1123425] = "Guido van Rossum" # changes the value from "Ada Lovelace" to "Guido van Rossum"

print(student_dict) 
# {1123425: 'Guido van Rossum', 1425243: 'Melba Roy Mouton', 1512414: 'Alan Turing'}

1.3 Add Dictionary Items

To add an item to a dictionary you again use the key name with square brackets

It is essentially the same as changing an item, but the key isn't yet in the dictionary.

student_dict[1234567] = "Margaret Hamilton" # adds the key:value pair to the dictionary

print(student_dict) 
# {1123425: 'Guido van Rossum', 1425243: 'Melba Roy Mouton', 1512414: 'Alan Turing', 1234567: 'Margaret Hamilton'}

1.4 Remove Dictionary Items

You can remove a dictionary item by using the del keyword.

del student_dict[1425243] # removes the 1425243: "Melba Roy Mouton" 

print(student_dict) 
# {1123425: 'Guido van Rossum', 1512414: 'Alan Turing', 1234567: 'Margaret Hamilton'}

2. Types of Dictionary Keys

Anything that is immutable can be a dictionary key. e.g., int, str, float.

dictionary1 = {
    1: 100,
    2: 300,
    42: 250
}

dictionary2 = {
    "Abid": 100,
    "Fatima": 300,
    "Sam": 250
}

We can also use a tuple as a key.

dictionary3 = {
    (1,10): 100,
    (11,20): 300,
    (21,30): 250
}

You can access an item using the tuple or the values of the tuple.

print(dictionary3[(1,10)])	# print 100
print(dictionary3[1,10])	# print 100

You can have mixed immutable types in your tuple, e.g.

dictionary4 = {
    (1,'a'): 100,
    (1,'b'): 300,
    (1,'c'): 250
}

print(dictionary4[1,'a'])	# print 100
print(dictionary4[1,'b'])	# print 300

2.1 Lists Aren't Allowed (Mutable)

dictionary4 = {
    [1,'a']: 100,
    [1,'b']: 300,
    [1,'c']: 250
}

This will cause an error!

Traceback (most recent call last):
  File "main.py", line 1, in <module>
    dictionary4 = {
TypeError: unhashable type: 'list'

The reason is that lists are mutable, hence can be changed. A key should never change otherwise how do you access it if you don't know the key. The error is saying we can't hash the type, meaning we can't create a unique identifier in memory because it might change.

Yes you could track it but this would be a bad idea. Just don't let it change!

3. Looping Through a Dictionary

You can loop through a dictionary in a number of ways.

If you want to loop through the keys you can do the following.

student_dict = {
    1123425: "Ada Lovelace",
    1425243: "Melba Roy Mouton",
    1512414: "Alan Turing"
}
for key in student_dict.keys():
    print(key)

# prints...
# 1123425
# 1425243
# 1512414

You can also use this to get the values:

for key in student_dict.keys():
    print(f"Key = {key}, Value = {student_dict[key]}")

# prints...
# Key = 1123425, Value = Ada Lovelace
# Key = 1425243, Value = Melba Roy Mouton
# Key = 1512414, Value = Alan Turing

Another way, perhaps the most common is using the items() method.

for k, v in student_dict.items():
    print(f"Key = {k}, Value = {v}")

Here items returns a list (actually a type called dict_items) of (key, value) pairs that you can then bind to the variables k and v for each iteration of the loop.

3.1 Looping Through a Dictionary with Tuple Keys

We can also loop over a dictionary with tuple keys.

dictionary4 = {
    (1,'a'): 100,
    (1,'b'): 300,
    (1,'c'): 250
}

for key, value in dictionary4.items():
    print(key)
    print(value)

Or to access the first and second items of the key individually.

for key, value in dictionary4.items():
    print(key[0])
    print(key[1])
    print(value)

4. Nested Dictionaries

Nested dictionaries are extremely useful.

For example we might want to store lots of information about each student. Let's store their email as well as their name.

student_dict = {
    1123425: {
        "Name":  "Ada Lovelace",
        "Email": "a.lovelace@unimail.derby.ac.uk"
    },
    1425243: {
        "Name":  "Melba Roy Mouton",
        "Email": "m.mouton@unimail.derby.ac.uk"
    },
    1512414: {
        "Name":  "Alan Turing",
        "Email": "a.turing@unimail.derby.ac.uk"
    }
}
# stores student names by their id.
# Here key:value pair is id: {information dictionary}

Now if you want information about a student you can get the corresponding dictionary.

student1 = student_dict[1123425] # returns {"Name":  "Ada Lovelace", "Email": "a.lovelace@unimail.derby.ac.uk" }
print(student1["Name"]) # prints 'Ada Lovelace'
print(student1["Email"]) # prints a.lovelace@unimail.derby.ac.uk

You can access the email in one line if you like.

print(student1[1123425]["Email"]) # prints a.lovelace@unimail.derby.ac.uk

5. Copying a Dictionary

Just like a list, copying a dictionary is stickier than you might imagine!

Dictionaries are also stored using references.

The variable stores the dictionary location in memory.

dict1 = {"a":1, "d":4}
dict2 = dict1 # copies the reference to dict1
print(f"Dictionary 1 = {dict1}")
print(f"Dictionary 2 = {dict2}")
dict2["c"] = 3
print(f"Dictionary 1 = {dict1}") # prints {'a':1, 'd':4, 'c':3}
print(f"Dictionary 2 = {dict2}") # prints {'a':1, 'd':4, 'c':3}

The above copies the reference to the list object. We can check this by using the id() function.

id(dict1) # something like 140094119425920
id(dict2) # will be the same id as above 140094119425920

To copy a dictionary we must use the copy() method.

dict1 = {"a":1, "d":4}
dict2 = dict1.copy() # copies the reference to dict1
print(f"Dictionary 1 = {dict1}") # prints {'a':1, 'd':4}
print(f"Dictionary 2 = {dict2}") # prints {'a':1, 'd':4}
dict2["c"] = 3
print(f"Dictionary 1 = {dict1}") # prints {'a':1, 'd':4}
print(f"Dictionary 2 = {dict2}") # prints {'a':1, 'd':4, 'c':3}

Now you will see that dict1 is not updated when you add to dict2.

Again we can check using the id() function if they point to the same location:

id(list1) # something like 140094119425920
id(list2) # will be a different id e.g. 140094117695872

6. Dictionary Methods

There are a number of built in methods you can use on a dictionary. You have already seen keys() and items()

MethodDescription
clear()Removes all the elements from the dictionary
copy()Returns a copy of the dictionary
fromkeys()Returns a dictionary with the specified keys and value
get()Returns the value of the specified key
items()Returns a list containing a tuple for each key value pair
keys()Returns a list containing the dictionaries keys
pop()Removes the element with the specified key
popitem()Removes the last inserted key-value pair
setdefault()Returns the value of the specified key. If the key does not exist: insert the key, with the specified value
update()Updates the dictionary with the specified key-value pairs
values()Returns a list of all the values in the dictionary

Python Dictionaris - W3schools


=== TASK ===

Copy the following into a new Python file to get started.

def add_from_list(d, items_to_add):
    pass
  
if __name__ == "__main__":
    test_dict = {
        "a":1,
        "b":2
    }
    print(test_dict) # prints {'a':1, 'b':2}
    items_to_add = [("e", 5), ("z", 26)]
    add_from_list(test_dict, items_to_add)
    print(test_dict) # prints {'a':1, 'b':2, 'e':5, 'z':26}

The function add_from_list(), should take in a dictionary and a list of tuples and then add these as key value pairs to the dictionary. It should not return anything.

For example,

test_dict = {
    "a":1,
    "b":2
}
print(test_dict) # prints {'a':1, 'b':2}
items_to_add = [("e", 5), ("z", 26)]
add_from_list(test_dict, items_to_add)
print(test_dict) # prints {'a':1, 'b':2, 'e':5, 'z':26}

HINT: You can loop through a list of paired values as follows. You might want to try this code out if you don't quite understand it.

for x,y in [(1,2), (5,1), (2,3)]:
    print(x,y)

References

Python Dictionaries - W3schools

MP: The Geek Translator Program

This is an example program from:

Dawson, M. (2010). Python programming for the absolute beginner, third edition (3rd ed.). Delmar Cengage Learning.

The following is a mini-program that demonstrates the use of dictionaries to add, store, remove and access information.

It is a simple word translator program.

My main issue with this program is that it is hard to read. I would generally consider moving the functionality into functions to make it clearer what the main body of the program does. It would be worth rewriting this as an exercise.

Run the code to see what it does.

# Geek Translator
# Demonstrates using dictionaries
geek = {
    "404": "clueless. From the web error message 404, meaning page not found.",
    "Googling": "searching the Internet for background information on a person.",
    "Keyboard Plaque": "the collection of debris found in computer keyboards.",
    "Link Rot": "the process by which web page links become obsolete.",
    "Percussive Maintenance": "the act of striking an electronic device to make it work.",
    "Uninstalled": "being fired. Especially popular during the dot-bomb era.",
}

choice = None
while choice != "0":
    print(
        """
  Geek Translator
  0 - Quit
  1 - Look Up a Geek Term
  2 - Add a Geek Term
  3 - Redefine a Geek Term
  4 - Delete a Geek Term
  5 - List Known Terms
  """
    )
    choice = input("Choice: ")
    print()
    # exit
    if choice == "0":
        print("Good-bye.")
        # get a definition
    elif choice == "1":
        term = input("What term do you want me to translate?: ")
        if term in geek:
            definition = geek[term]
            print("\n", term, "means", definition)
        else:
            print("\nSorry, I don't know", term)
    # add a term-definition pair
    elif choice == "2":
        term = input("What term do you want me to add?: ")
        if term not in geek:
            definition = input("\nWhat's the definition?: ")
            geek[term] = definition
            print("\n", term, "has been added.")
    # redefine an existing term
    elif choice == "3":
        term = input("What term do you want me to redefine?: ")
        if term in geek:
            definition = input("What's the new definition?: ")
            geek[term] = definition
            print("\n", term, "has been redefined.")
        else:
            print("\nThat term doesn't exist! Try adding it.")
        # delete a term-definition pair
    elif choice == "4":
        term = input("What term do you want me to delete?: ")
        if term in geek:
            del geek[term]
            print("\nOkay, I deleted", term)
        else:
            print("\nI can't do that!", term, "doesn't exist in the dictionary.")
    elif choice == "5":
        for key, value in geek.items():
            print(f"Term: {key}")
            print(f"Definition: {value}\n")
    else:
        print("\nThat term already exists! Try redefining it.")

The Frequency Count Program

We would like to create a program that counts the frequency of numbers that have been entered by the user.

For example, the program would run as follows.

Please enter a whole number:
3
Please enter a whole number:
2
Please enter a whole number:
2
Please enter a whole number:
6
Please enter a whole number:
63
Please enter a whole number:
q
The numbers seen were [3, 2, 2, 6, 63]
The count for 2 is: 2
The count for 3 is: 1
The count for 6 is: 1
The count for 63 is: 1

1. The Simple Problem

Let's start with a simpler problem.

We will create a program that asks the user for numbers and displays the count seen of each numbers 1 - 5 and the count for everything else.

For example, the program would run as follows.

Please enter a whole number:
3
Please enter a whole number:
2
Please enter a whole number:
2
Please enter a whole number:
6
Please enter a whole number:
63
Please enter a whole number:
q
The numbers seen were [3, 2, 2, 6, 63]
The count for 1 is: 0
The count for 2 is: 2
The count for 3 is: 1
The count for 4 is: 0
The count for 5 is: 0
The count for everything else is: 2

1.1. Solving Using a List

To answer this problem we can use a list.

number_list = []  # define a list to store numbers seen
count_list = [0, 0, 0, 0, 0, 0]  # defint a list to count the frequency of numbers
input_string = input("Please enter a whole number:\n")

while input_string != "q":
    number_list.append(int(input_string))  # add number seen to list
    # check if number between 1 and 5
    if int(input_string) > 0 and int(input_string) < 6:
        count_list[int(input_string) - 1] += 1  # increment number count by 1
    else:
        count_list[5] += 1  # increment everything else by 1
    input_string = input("Please enter a whole number:\n")

# print out the count of the numbers 1 to 5
print(f"The numbers seen were {number_list}")
for i in range(5):
    print(f"The count for {i+1} is: {count_list[i]}")

# print out the count of everything else
print(f"The count of everything else is: {count_list[5]}")

2. The Full Problem.

The issue with using a list to solve the full problem is that we don't know how many different numbers will be entered. In fact even worse, there are an infinite choice of numbers.

If you use a list you need to know how big it should be and therefore how many different types of numbers you expect to see.

This was fine when we said the numbers 1 - 5 and everything else. This is just counting 6 things. So we know the list should be of length 6.

2.1 Using a Dictionary

We can now use a dictionary to solve the full problem.

If you start with an empty dictionary, then you just need to add a new entry whenever you see a new number, then you can increment this by 1 if you see the number again.

number_list = []
count_dict = {}
input_string = input("Please enter a whole number:\n")

while input_string != "q":
    number_list.append(int(input_string))
    # check if the key exists in the dictionary
    if int(input_string) in count_dict.keys():
        count_dict[int(input_string)] += 1  # update entry
    else:
        count_dict[int(input_string)] = 1  # add new entry
    input_string = input("Please enter a whole number:\n")

# print out the count of the numbers
print(f"The numbers seen were {number_list}")
for key, value in sorted(count_dict.items()):
    print(f"The count for {key} is: {value}")

List/Dictionary Comprehension

List and dictionary comprehension is an incredibly powerful technique for writing compact code. It basically allows you to generate complicated lists or dictionaries in one line of code.

List and dictionary comprehension is something that takes time to get used to and I think the easiest way to understand it is to see it in action.

In this lesson you will see simple code snippets and then see the same code rewritten using list/dictionary comprehension.

You should note how compact this code looks, but how as the complexity builds up it can be harder to read.

I suggest that you copy the code into Python Tutor to make sure you understand it.

1. List Comprehension

We will start with the example given on W3schools.

# filter list for words with "a" in.
fruits = ["apple", "banana", "cherry", "kiwi", "mango"]
newlist = []

for x in fruits:
    if "a" in x:
        newlist.append(x)

print(newlist) # prints ['apple', 'banana', 'mango']

We can rewrite this using list comprehension.

# using list comprehension
# filter list for words with "a" in.
fruits = ["apple", "banana", "cherry", "kiwi", "mango"]
newlist = [x for x in fruits if "a" in x]
print(newlist) # prints ['apple', 'banana', 'mango']

Notice that list comprehension is in the following form.

[<expression> for <item> in <iterable> if <condition> == True]

The return value is a new list, leaving the old list unchanged.

1.1 Even Numbers

The following is a simple program that generates the even numbers between 1 and 100.

# generate even numbers between 1 and 100
even_num_list = []
for x in range(1,51):
    even_num_list.append(2*x)

print(even_num_list) # prints [2,4,6, ...,98,100]

We can rewrite this using list comprehension.

# using list comprehension
# generate even numbers between 1 and 100
even_num_list = [2*x for x in range(1,51)]
print(even_num_list) # prints [2,4,6, ...,98,100]

Here we didn't use a condition so our list comprehension was in the form.

[<expression> for <item> in <iterable>]

An alternative program that generates the even numbers uses the % modulus operator.

# generate even numbers between 1 and 100
even_num_list = []
for x in range(1,101):
    if x % 2 == 0:
        even_num_list.append(x)

print(even_num_list) # prints [2,4,6, ...,98,100]

We can rewrite this using list comprehension.

# using list comprehension
# generate even numbers between 1 and 100
even_num_list = [x for x in range(1,101) if x % 2 == 0]
print(even_num_list) # prints [2,4,6, ...,98,100]

Here we did use a condition so our list comprehension was in the form.

[<expression> for <item> in <iterable> if <condition> == True]

1.2 Length of Words

The following program takes a list of words and then calculates the length of each of the words.

# compute the length of each word in the list
words = ["Python", "is", "a", "cool", "programming", "language"]
len_words = []
for w in words:
    len_words.append(len(w))

print(len_words) # prints [6, 2, 1, 4, 11, 8]

We can rewrite this using list comprehension.

# using list comprehension
# compute the length of each word in the list
words = ["Python", "is", "a", "cool", "programming", "language"]
len_words = [len(s) for s in words]
print(len_words) # prints [6, 2, 1, 4, 11, 8]

We can also take a sentence and use the split() method to generate a list of words from a string and then do the above.

# compute the length of each word in the sentence
text = "Python is a cool programming language"
len_words = []
for w in text.split(" "):    
    len_words.append(len(w))

print(len_words) # prints [6, 2, 1, 4, 11, 8]

We can rewrite this using list comprehension.

# using list comprehension
# compute the length of each word in the sentence
text = "Python is a cool programming language"
len_words = [len(s) for s in text.split(" ")]
print(len_words) # prints [6, 2, 1, 4, 11, 8]

2. Nested List Comprehension

What about nested for loops? List comprehension has you covered.

2.1 Length of Words in a List of Sentences

This is a program that takes a list of sentences and creates a list containing the length of each word in each sentence.

# compute the length of each word in a list of sentences
sentence_list = ["Python is a cool programming language", "I love to program", "Programming is so cool"]
len_words = []
for sentence in sentence_list:
    for word in sentence.split(" "):
        len_words.append(len(word))

print(len_words) # prints [6, 2, 1, 4, 11, 8, 1, 4, 2, 7, 11, 2, 2, 4]

We can rewrite this using list comprehension.

# using list comprehension
# compute the length of each word in a list of sentences
sentence_list = ["Python is a cool programming language", "I love to program", "Programming is so cool"]
len_words = [len(word) for sentence in sentence_list for word in sentence.split(" ")]
print(len_words) # prints [6, 2, 1, 4, 11, 8, 1, 4, 2, 7, 11, 2, 2, 4]

Which is in the form:

[<expression> for <inner_list> in <outer_list> for <item> in <inner_list>]

The output produced a single list with the length of the words.

What if we want the list to contain a separate list of word lengths for each of the sentences? e.g. [[6, 2, 1, 4, 11, 8], [1, 4, 2, 7], [11, 2, 2, 4]]

# compute the length of each word in a each of the sentences
sentence_list = ["Python is a cool programming language", "I love to program", "Programming is so cool"]
len_words = []
for sentence in sentence_list:
    temp = []
    for word in sentence.split(" "):
        temp.append(len(word))
    len_words.append(temp)

print(len_words) # prints [[6, 2, 1, 4, 11, 8], [1, 4, 2, 7], [11, 2, 2, 4]]

We can rewrite this using list comprehension.

# using list comprehension
# compute the length of each word in each of the sentences
sentence_list = ["Python is a cool programming language", "I love to program", "Programming is so cool"]
len_words = [[len(word) for word in sentence.split(" ")] for sentence in sentence_list]
print(len_words) # prints [[6, 2, 1, 4, 11, 8], [1, 4, 2, 7], [11, 2, 2, 4]]

Which is in the form:

[[<expression> for <item> in <inner_list>] for <inner_list> in <outer_list> if <condition> == True]

That is, produce a list from each of the inner lists of the outer list.

2.2 Nested with a Condition

What about adding a condition so that we only store the length of the words that are less than 4.

# compute the length of each word less than 4 in a list of sentences
sentence_list = ["Python is a cool programming language", "I love to program", "Programming is so cool"]
less_than_4 = []
for sentence in sentence_list:
    for word in sentence.split(" "):
        if(len(word) < 4):
            less_than_4.append(len(word))

print(less_than_4) # prints [2, 1, 1, 2, 2, 2]

We can rewrite this using list comprehension.

# using list comprehension
# compute the length of each word less than 4 in a list of sentences
sentence_list = ["Python is a cool programming language", "I love to program", "Programming is so cool"]
less_than_4 = [len(word) for sentence in sentence_list for word in sentence.split(" ") if len(word) < 4]
print(less_than_4) # prints [2, 1, 1, 2, 2, 2]

3. Dictionary Comprehension

We can also do comprehension using dictionaries.

Consider the following example:

# compute the length of each word in the list and store it with the word
words = ["Python", "is", "a", "cool", "programming", "language"]
len_words = {}
for s in words:
    len_words[s] = len(s)
    
print(len_words) # prints {'Python': 6, 'is': 2, 'a': 1, 'cool': 4, 'programming': 11, 'language': 8}

We can write this using dictionary comprehension as follows:

# compute the length of each word in the list and store it with the word
words = ["Python", "is", "a", "cool", "programming", "language"]
len_words = {s:len(s) for s in words}
print(len_words) # prints {'Python': 6, 'is': 2, 'a': 1, 'cool': 4, 'programming': 11, 'language': 8}

Here for each s in the list words we store the key:value pair s:len(s)

The general form is:

{<key>:<value> for <item> in <iterable> if <condition> == True}

The return value is a new dictionary, leaving the list we used to generate the dictionary unchanged.

We could also change this to just store the words that are less than length 4.

# compute the length of each word in the list and store it with the word
words = ["Python", "is", "a", "cool", "programming", "language"]
less_than_four = {s:len(s) for s in words if len(s) < 4}
print(less_than_four) # prints {'is': 2, 'a': 1}

4. Nested Dictionary Comprehension

What about nested for loops? Dictionary comprehension also has you covered.

# compute the length of each word in the list of sentences and store it with the word
sentence_list = ["Python is a cool programming language", "I love to program", "Programming is so cool"]
len_words = {}
for sentence in sentence_list:
    for word in sentence.split(" "):
        len_words[word] = len(word)

print(len_words)
# prints {'Python': 6, 'is': 2, 'a': 1, 'cool': 4, 'programming': 11, 'language': 8, 'I': 1, 'love': 4, 'to': 2, 'program': 7, 'Programming': 11, 'so': 2}

We can rewrite this using dictionary comprehension.

# using dictionary comprehension
# compute the length of each word in the list of sentences and store it with the word
sentence_list = ["Python is a cool programming language", "I love to program", "Programming is so cool"]
len_words = {word:len(word) for sentence in sentence_list for word in sentence.split(" ")}
print(len_words) 
# prints {'Python': 6, 'is': 2, 'a': 1, 'cool': 4, 'programming': 11, 'language': 8, 'I': 1, 'love': 4, 'to': 2, 'program': 7, 'Programming': 11, 'so': 2}

Which is in the form:

newlist = {<key>:<value> for <inner_list> in <outer_list> for <item> in <inner_list>}

We could also compute the length of each word in the sentences that is less than 4.

# compute the length of each word in the list of sentences that is less that 4 and store it with the word
sentence_list = ["Python is a cool programming language", "I love to program", "Programming is so cool"]
less_than_4 = {}
for sentence in sentence_list:
    for word in sentence.split(" "):
        if len(word) < 4:
            less_than_4[word] = len(word)

print(less_than_4) # prints {'is': 2, 'a': 1, 'I': 1, 'to': 2, 'so': 2}

We can rewrite this using dictionary comprehension.

# using dictionary comprehension
# compute the length of each word in the list of sentences that is less that 4 and store it with the word
sentence_list = ["Python is a cool programming language", "I love to program", "Programming is so cool"]
less_than_4 = {word:len(word) for sentence in sentence_list for word in sentence.split(" ") if len(word) < 4}
print(less_than_4) # prints {'is': 2, 'a': 1, 'I': 1, 'to': 2, 'so': 2}

Which is in the form:

newlist = {<key>:<value> for <inner_list> in <outer_list> for <item> if <condition> == True}

5. Comprehension on Dictionaries

Note that you can do comprehension using any iterable such as a range, list or dict.

5.1 Range

We already saw this at the start with the even numbers.

# using list comprehension
# generate even numbers between 1 and 100
even_no_list = [x for x in range(1,101) if x % 2 == 0]
print(even_no_list)

5.2 Dictionary

We could get the list of student names from a dictionary of students.

student_dict = {
    1123425: "Ada Lovelace",
    1425243: "Katherine Johnson",
    1512414: "Alan Turing"
}

student_names = [value for key, value in student_dict.items()]
print(student_names) # prints ['Ada Lovelace', 'Katherine Johnson', 'Alan Turing']

Note we could just get this using student_dict.values().

The following takes a dictionary of students and adds their email address to their data.

# automatically generate student emails and store them in a nested dictionary.
def email_from_name(name):
    [first_name, surname] = name.split(" ")
    return f"{first_name[0]}.{surname}.unimail.derby.ac.uk"

student_dict = {
    1123425: "Ada Lovelace",
    1425243: "Katherine Johnson",
    1512414: "Alan Turing"
}

student_info_dict = {key:{"Name":value, "Email":email_from_name(value)} for key, value in student_dict.items()}
print(student_info_dict) 
# prints {1123425: {'Name': 'Ada Lovelace', 'Email': 'A.Lovelace.unimail.derby.ac.uk'}, 1425243: {'Name': 'Katherine Johnson', 'Email': 'K.Johnson.unimail.derby.ac.uk'}, 1512414: {'Name': 'Alan Turing', 'Email': 'A.Turing.unimail.derby.ac.uk'}}

=== TASK ===

Copy and paste the following into a new Python file to get started.

def sum_div_3(n):
    """you should update div_by_3_list to generate the list of numbers divisible by 3 and return their sum.

    e.g. for n = 10 it should look at 1,2,3,4,5,6,7,8,9,10 and the list should contain [3,6,9]. The function should then return the sum of this list, in this case 18"""
    div_by_3_list = []
    return sum(div_by_3_list)


if __name__ == "__main__":
    print(sum_div_3(10))  # should print 18
    print(sum_div_3(12))  # should print 30
    print(sum_div_3(100))  # should print 1683

Use list comprehension to find the sum of all numbers that are divisable by 3 between 1 and an upper limit n. If you are struggling, write this in a more "traditional" format first and then convert it to list comprehension.

You should update the function sum_div_3(n) so that it returns the correct sum.

For example, sum_div_3(12) should sum up 3,6,9 and 12 which totals to 30.


Unit 7 - Modules and Files

Modules are essentially a collection of functions that you can then include in your project.

They are a means of letting you organise functions into useful groups and better structure your programs.

To date, our programs have been small enough that we have cared little for this. As our programs grow in size, structuring them into seperate files becomes essential.

No one wants to read single file of 10000 lines of code!

We will also take a look at how to read and write to files to allow our programs to load and save simple data.

In this unit we will look at:

  1. Module Basics
  2. Creating Modules
  3. Reading to Files
  4. Writing to Files
  5. Serialisation with JSON

References

W3Schools - Python Modules

Module Basics

Modules are files that can be imported into your project so you can use pre-built functionality.

Python comes with a number of built-in modules and you can also install python libraries which contain modules that other programmers have installed, but are not part of standard Python.

We will use three modules to demonstrate the basics. math, datetime and random.

1. Importing a Module

You ALWAYS import modules at the top of your python files!

# import the built-in maths module
import math

print(math.pi)
print(math.sqrt(2.78))

If you want to see what is in math you can type help(math) into the terminal or use Google!

import datetime

# Uses the now method in the datetime class (more on this later)
print(datetime.datetime.now())
import random

print(random.randint(3, 9)) # rand number between 3 and 9

mylist = ["apple", "banana", "cherry"]
print(random.choice(mylist)) # randomly picks from mylist

The main point here is you haven't had to do much to get some serious functionality!

Generally, unless you feel the need, don't recode something that already exists!

1.1 Importing Part of A Module

We could have chosen to just import the randint() function,

# only import a given function
from random import randint

print(randint(3, 9)) # rand number between 3 and 9

or multiple functions.

# only import a given function
from random import randint, choice

print(randint(3, 9)) # rand number between 3 and 9

mylist = ["apple", "banana", "cherry"]
print(choice(mylist)) # randomly picks from mylist

I would generally avoid doing this! It is harder to tell when a function has come from an external module.

1.2 Renaming a Module

Sometimes it is useful to give a module an alias (shorten a module name). Again this is something that is done for some modules, if in doubt on how to import a module, just use Google!

# I wouldn't do this!
import datetime as dt

print(dt.datetime.now())

Ultimately getting used to using modules takes time and experience, but you can always look up how to do these things.

Normally if I want to do something in Python, I google it and look at some code, there is normally a module either pre-built or as part of an external library that will help.

2. Useful Built-in Modules

You can find a complete list of built-in modules via the Python Docs, or a slightly friendlier list of the most common ones via W3Schools.

Again, Google is your friend. If you can use a built-in module instead of a 3rd party one then you should

3. Libraries

Libraries in Python are just a collection of related modules.

The built-in modules are part of the Python standard library.

You can install other libraries and use their modules, we have already seen the use of a 3rd party library (a very common one) called Matplotlib. And we used the pyplot module.

# import LIBRARY.MODULE as NAME
import matplotlib.pyplot as plt

Here we import the pyplot module from the matplotlib library and give it an alias (name).

We also import the numpy module as np.

import matplotlib.pyplot as plt
import numpy as np

x = np.arange(1,100,0.1)
y = np.sin(x)
plt.plot(x,y)
plt.show()

If you run this code it will give you a plot of the sine function.

Sine function plot


=== TASK ===

Import the os module

Amend the function get_current_working_directory() so that it returns the current working directory as a string.

HINT: Search for "current working directory" on the following URL - Python Docs - os Module

Implement the functions encode_string() and decode_string() using the base64 module so that they encode and decode a string using base64.

You can find out more via the following URLs. You will be required to read at least one of these links to implement the functions.

Base64 Encoding

Encoding and Decoding Base64 Strings in Python - G4G

Copy and paste this into a new Python file to get started.

def get_current_working_directory():
  pass

def encode_string(str_to_encode):
  """ Encodes a string using base64"""
  pass

def decode_string(base64_str_to_decode):
  """ Decodes a string encoded in base64"""
  pass
  

if __name__ == "__main__":
  print(get_current_working_directory())
  print(encode_string("Python is a cool programming language!"))
  # should print UHl0aG9uIGlzIGEgY29vbCBwcm9ncmFtbWluZyBsYW5ndWFnZSE=
  str_to_decode = "UHl0aG9uIGlzIGEgY29vbCBwcm9ncmFtbWluZyBsYW5ndWFnZSE="
  print(decode_string(str_to_decode))
  # should print Python is a cool programming language!

References

1. Python Docs - Built-in Modules

2. W3schools - Python Built-in Modules

3. Matplotlib - Plotting Library

4. Numpy

Creating a Module

To create your own module you just need to create a .py file that includes your code. Generally, this will include functions.

Later we will look at including classes.

Modules can include both functions and variables.

1. Creating a Module

Create a file called calculator1.py.

Includes two functions and a variable:

# calculator1.py

# terrible approximation of pi
pi = 3.14

def add(x,y):
  return x + y

def mul(x,y):
  return x * y

Create another file in the same directory called main.py. We can then use calculator.py in our main.py via an import.

# main.py
import calculator1 as calc

print(calc.add(3,4)) # use the add function from calc
print(calc.mul(3,4)) # use the mul function from calc
print(calc.pi)       # use the pi variable 

For now, it's enough that the module is in the same folder as your main.py. Later we will discuss ways to organise your project.

2. if name == "__main__"

We have used this during this module already, but now it is time to explain why.

Now create two new files called calculator2.py and calculator3.py.

calculator2.py should contain the following code.

# calculator2.py
# terrible approximation of pi
pi = 3.14

def add(x,y):
  return x + y

def mul(x,y):
  return x * y

print(f"These are lines printed inside {__name__}")
print("For example we might like to test the add function")
print(add(3, 4))

calculator3.py should contain the following code.

# calculator3.py
# terrible approximation of pi
pi = 3.14

def add(x,y):
  return x + y

def mul(x,y):
  return x * y

if __name__ == "__main__":
  print(f"These are lines printed inside {__name__}")
  print("For example we might like to test the add function")
  print(add(3, 4))

The difference is that calculator3.py has this code contained within the if __name__ == "__main__" block.

__name__ is a special reserved name in Python which is used to keep track of the names of the files that are being run and imported.

The file that you run, normally main.py but it can be any file, takes on the name __main__.

Try running all of these files and look at the differences in the output.

Note if you want to run them manually from the terminal you can do by opening the terminal and typing ls. This will list the files in the current directory.

Type python main.py to run the main file.

Type python calculator2.py to run the calculator2.py file.

Type python calculator2.py to run the calculator3.py file.

You will not that __name__ for both calculator2.py and calculator3.py is __main__. This is because it is the starting (main) file being run by Python.

OK, back to if __name__ == "__main__".

What this does is tell python only to run that block of code if you are running calculator3.py.

Why do this?

Well, try importing calculator2.py and using it.

import calculator2 as calc

print(calc.add(3,4)) # use the add function from calc
print(calc.mul(3,4)) # use the mul function from calc
print(calc.pi)       # use the pi variable 

This will print out the following:

These are lines printed inside calculator2.py
For example, we might like to test the add function
7
7
12
3.14

Oh, dear! It printed out the code that is contained at the bottom of the file as well, really we just wanted to use the functions.

Now try it with calculator3.py:

import calculator3 as calc

print(calc.add(3,4)) # use the add function from calc
print(calc.mul(3,4)) # use the mul function from calc
print(calc.pi)       # use the pi variable 

This will print out the following:

7
12
3.14

Much better! Now it just imported the functions for us to use.

Try running calculator3.py again.

This will run the file and print out the following.

These are lines printed inside __main__
For example, we might like to test the add function
7

Notice now that we are running the file itself, so the if __name__ == "__main__" block runs!


=== TASK ===

Create a new file called hello.py

Your module should include two functions called hello() and goodbye() which take in a name and returns a string.

For example, hello() should operate as follows.

hello("Joe") # returns "Hello Joe"
print(hello("Joe")) # prints Hello Joe

And, goodbye() should operate as follows.

goodbye("Joe") # returns "Goodbye Joe"
print(goodbye("Joe")) # prints Goodbye Joe

I have included this code so that you can use in main.py to test your module.

# main.py
import hello

def main():
  print(hello.hello("Joe")) # prints Hello Joe
  print(hello.goodbye("Joe")) # prints Goodbye Joe

if __name__ == "__main__":
  main()

If you have done this correctly, the output of your program should be:

Hello Joe
Goodbye Joe

The Calculator Program

This is a simple demonstration of a program that acts like a calculator.

The main point of this program is to demonstrate that all the functionality of the calculator that does that hard work is stored in the module calc.py.

We then import calc.py using the import statement:

import calc

This allows the main program to then use the functions that are defined (written) in main.py

The tests should just pass if you run them.

Main File

# main.py
# importing module calc (calc.py)
import calc

def main():
  choice = None
  print("Welcome to the Calculator...")
  print(f"Did you know the value of Pi is approximately {calc.pi}")
  while choice != "0":
    print("""
  What would you like to do?
  1. Add
  2. Subtract
  3. Multiply
  4. Divide
  5. Power
  6. Nth Root
    """)
  
    choice = input("")
    if choice in ["1", "2", "3", "4", "5"]:
      x = float(input("Please enter the first number:\n"))
      y = float(input("Please enter the second number:\n"))
      if choice == "1":
        ans = calc.add(x,y)
      elif choice == "2":
        ans = calc.subtract(x,y)
      elif choice == "3":
        ans = calc.multiply(x,y)
      elif choice == "4":
        ans = calc.divide(x,y)
      elif choice == "5":
        ans = calc.power(x,y)
      if ans:
        print(f"Answer: {ans}")
      else:
        print("Division by 0 is illegal")
    elif choice == "6":
      x = float(input("Please enter the first number:\n"))
      n = int(input("Please enter a whole value for n (nth root):\n"))
      ans = calc.root(x,n)
      print(f"Answer: {ans}")
    else:
      print("Invalid Option")

if __name__ == "__main__":
  main()

Calc Module

# calc.py

pi = 3.14159

def add(x, y):
  return x + y
  
def subtract(x, y):
  return x - y

def multiply(x,y):
  return x*y
  
def divide(x,y):
  if not y == 0:
    return x/y
  else:
    return None

def power(x,y):
  return x**y

def root(x,y, epsilon=0.001):
  if x < 0 and y % 2 == 0:
    return None
  low = min(-1.0, x)
  high = max(1.0, x)
  ans = (high + low) / 2.0
  # check if the answer is close enough
  while abs(ans**y - x) >= epsilon:
     if ans**y < x:
         low = ans
     else:
         high = ans
     ans = (high + low) / 2.0
  return ans

Reading Files

Reading and writing to files allows us to start using persistent storage to remember data for our programs.

In this lesson, we will look at reading from a simple text file (.txt).

1. Opening a file

We can open a file using the following.

open(path_to_file, mode)
ModeDescription
'r'Open text file for reading text
'w'Open text file for writing text
'a'Open text file for appending text

e.g.

# open file in write mode
 f = open("example.txt", "r")

Here f is now a file object that represents the file. We can now use this to read the contents of the file, write to the file or append to the contents of the file.

1.1 Relative and Absolute Paths

In the above example we are using a relative path and Python will open the file based on what is known as the current working directory. This is normally the directory that the Python file (.py) is run from.

It can change though. To check you can run the following (try it out).

import os
print(os.getcwd()) # This uses the os module and returns the current working directory.

We could have given an absolute path e.g. C:\Users\Bob\example.txt if we were running our code on a windows machine and we wanted to read the file from a different directory. If you are using an absolute path on windows, because it uses a \ (backslash) in its paths, you should but an r in front of it. This will tell Python not to treat the \ as an escape character, i.e. treat it as a raw string.

# open file in write mode
 f = open(r"C:\Users\Bob\example.txt", "r")

Alternatively you can escape the escape characters.

# open file in write mode
 f = open("C:\\Users\\Bob\\example.txt", "r")

2. Reading a file

Reading Methods

Here are some select methods for reading.

MethodDescription
read()Returns the file content.
read(size)Read the specified number of bytes.
readline()Returns one line from the file. If you call this multiple times it will return the next line and internally keep track of which line is next.
readlines()Returns a list of the lines in the file.
close()Closes the file. This is extremely important for freeing up resources. Always close your file.

You can see other file methods via this link - Python File Methods

Read the whole contents

We can read the whole contents of the file using the read() method.

file = open("example.txt", "r")
lines = file.read()
file.close()

print(lines)

You will note the close() method here. This closes the file. You should always close your file when done. Here are some reasons.

  • It can mean that you have too many things open, and thus more used space in the RAM, this may slow down your program.

  • Changes are not written to the file until it is closed. If you don't explicitly close then you may not see the edits in another part of your program.

  • If you have too many files open, you could run into limits which will throw an error.

  • You become reliant on the garbage collector - though the file in theory will be automatically closed when it is closed is now not up to you.

Using the context manager with

We can make sure the file is closed by using the with keyword.

I would strongly advise this.

# this loads all of the lines in the file into a list
with open('example.txt', "r") as file:
  # read all the lines and put in a list of containing each line
  lines = file.readlines()

# print the list of lines
print(lines)

Read the file line by line

We can also read the file line by line. The following code reads the whole content of the file and puts each line into a list.

We can then iterate over the list of lines.

# this loads all of the lines in the file into a list
with open('example.txt', "r") as file:
  # read all the lines and put in a list containing each line
  lines = file.readlines()

# for each of the lines in the list
for l in lines:
  # print the line
  print(l)
  # print(l.strip()) # if you want to stop python printing a new line after each line

This is fine when we have a small file, but if we have a large volume of data, we may not wish to load the entire file into memory.

Don't load the whole file into memory

One way to combat this is to read the file line by line and do something with each line. Now we are not loading the whole file into memory, just a line at a time.

# this reads the file line by line, without reading it all into memory at once
with open('example.txt', "r") as file:
  # read the first line as a string
  line = file.readline()
  # while line is not empty
  while line:
    # print line
    print(line)
    # read the next line
    line = file.readline()

FileNotFoundError

Opening files is a potential source of errors in your program. If the file does not exist and your try to read from it, it will throw an error.

# this loads all of the lines in the file into a list
with open('example1.txt', "r") as file:
  # read all the lines and put in a list containing each line
  lines = file.readlines()

As example1.txt does not exist, this will throw a FileNotFoundError.

We can deal with this error by using a try..except

try:
  with open("example1.txt", "r") as file:
    lines = file.read()
    print(lines)
except FileNotFoundError:
  print("File not found")

=== TASK ===

Amend the function number_of_lines() so that it reads the contents of the file stored at filename and counts the number of lines in the file. It should then return this number.

Copy and paste the following into a new Python file to get started.

def number_of_lines(filename):
  """ Write your code here """
  pass

def main():
  print(number_of_lines("example.txt")) # prints 3 as example.txt contains 3 lines
  print(number_of_lines("test.txt"))    # prints 25 as test.txt contains 25 lines

if __name__ == "__main__":
  main()

Writing to Files

1. Writing To Files

We can write to files in two ways. Either by writing a string or by writing a list of lines.

WARNING - Writing will overwrite the entire contents of the file.

We can write a string to the contents of a file using the write method.

# write to the file example.txt
with open("example.txt", "w") as file:
  file.write("Write some content to the file")

The contents of the file will now be:

Write some content to the file

We can also write a list of strings to the file using the writelines() method.

string_list = ["This is a line", "This is another line"]

# write to the file example.txt
with open("example.txt", "w") as file:
  file.writelines(string_list)

The contents of the file will now be:

This is the line
This is another line

2. Appending To Files

Appending will append to the end of the file.

We can append it to a file as follows:

# append some content to the file example.txt
with open("example.txt", "a") as file:
  file.write("\nThis is some extra content to append\n")

For example, if the contents of the file are,

This is the line
This is another line

then after the append, the contents will be:

This is the line
This is another line
This is some extra content to append

Be careful

The following code snippet does the following:

  1. Writes to the file and prints it out
  2. Appends to the file and prints it out
  3. Writes to the file and prints it out (this now overwrites the content produced by 1 and 2)

You should try this code and make sure you understand what it is doing.

import os

# this is a relative path
filepath = "example.txt"
# if you do not use the r for the absolute path on windows, the \ are interpreted as an escape character.
# filepath = r"C:\Users\Bob\example.txt"

print("The current working directory (the default path for reading/writing/appending) is:\n")
print(os.getcwd())
print()

print("\n-----Writing some content-----\n")
# write to the file example.txt
with open(filepath, "w") as file:
  file.write("Write some content to the file")

# read the contents of example.txt
with open(filepath) as file:
  lines = file.read()

print("-----Reading content after first write-----\n")
print(lines)

print("\n-----Appending some content-----\n")
# append some content to the file example.txt
with open(filepath, "a") as file:
  file.write("\nThis is some extra content to append\n")

# read the contents of example.txt
with open(filepath) as file:
  lines = file.read()

print("\n-----Reading content after append-----\n")
print(lines)

print("\n-----Writing some content-----\n")
# write some content to the file example.txt. 
# This will now overwrite the previous content
with open(filepath, "w") as file:
  file.write("Oh dear we have overwritten all the content in example.txt")

# read the contents of example.txt
with open(filepath) as file:
  lines = file.read()

print("-----Reading content after second write-----")
print(lines)

=== TASK ===

Write a function called write_nums() that outputs the first positive n numbers to a txt file called nums.txt

For example,

write_nums(10)

Should output the first 10 numbers to the file nums.txt

1
2
3
4
5
6
7
8
9
10
def write_nums(n):
  pass

if __name__ == "__main__":
  write_nums(10) 
  # writes out the first 10 positive numbers to nums.txt

Serialisation with JSON

OK, so imagine we have a simple student records system that uses a dictionary for storing the data. What if we would like to save this so that at a later date we can load it?

For example, we might have the following data in a dictionary.

std_dict = {
    "100456789": ["Sam O'Neill", "Computer Science"],
    "100123456": ["Roisin Hunt", "Computer Games `Modelling and Animation"],
    "100654321": ["Tom Hughes-Roberts", "Computer Games Programming"],
    "100987654": ["Bradley Davis", "Computer Science"],
    "100345678": ["Rich Conniss", "Mathematics"],
    "100876543": ["Patrick Merritt", "Computer Games Programming"]
}

1. Saving and Reading a Dictionary with Standard Write

We could try and do this by saving the dictionary to a txt file, for example:

std_dict = {
    "100456789": ["Sam O'Neill", "Computer Science"],
    "100123456": ["Roisin Hunt", "Computer Games `Modelling and Animation"],
    "100654321": ["Tom Hughes-Roberts", "Computer Games Programming"],
    "100987654": ["Bradley Davis", "Computer Science"],
    "100345678": ["Rich Conniss", "Mathematics"],
    "100876543": ["Patrick Merritt", "Computer Games Programming"]
}

# save the dictionary to the file std.txt
with open('std.txt', 'w') as file:
  for (k,v) in std_dict.items():
    file.write(f"{k}:{v}\n")

This will save the following in std.txt

100456789:["Sam O'Neill", 'Computer Science']
100123456:['Roisin Hunt', 'Computer Games `Modelling and Animation']
100654321:['Tom Hughes-Roberts', 'Computer Games Programming']
100987654:['Bradley Davis', 'Computer Science']
100345678:['Rich Conniss', 'Mathematics']
100876543:['Patrick Merritt', 'Computer Games Programming']

We could then attempt to load this back in using something like:

# try and load the dictionary back into memory from std.txt
load_dict = {}

with open('std.txt', 'r') as file:
  for line in file.readlines():
    item = line.split(":")
    load_dict[item[0]] = item[1]

print(load_dict)

This prints out:

{'100456789': '["Sam O\'Neill", \'Computer Science\']\n', '100123456': "['Roisin Hunt', 'Computer Games `Modelling and Animation']\n", '100654321': "['Tom Hughes-Roberts', 'Computer Games Programming']\n", '100987654': "['Bradley Davis', 'Computer Science']\n", '100345678': "['Rich Conniss', 'Mathematics']\n", '100876543': "['Patrick Merritt', 'Computer Games Programming']\n"}

This is not quite what we had in mind, in fact, we would need to make some alterations to the code to get it to be the original dictionary.

But it gets worse, what if we have a different data structure, maybe we have a dictionary of dictionaries etc? In this case, we would now need to modify the code again!

This leads us to use serialisation which allows us to convert a python object into something we can then store and then at a later date just reverse the process.

We can serialise using many formats, JSON, pickle, XML etc. We will just look at JSON. In general, this is used for transmitting data in web applications.

If you were building a real student record system you would store the data in a database.

2. Saving and Reading a Dictionary with JSON Serialisation

Here is how we can do this in JSON though.

We can first save the dictionary as JSON in the file std_dict.json, we will also indent the file to make it more readable.

import json

std_dict = {
    "100456789": ["Sam O'Neill", "Computer Science"],
    "100123456": ["Roisin Hunt", "Computer Games `Modelling and Animation"],
    "100654321": ["Tom Hughes-Roberts", "Computer Games Programming"],
    "100987654": ["Bradley Davis", "Computer Science"],
    "100345678": ["Rich Conniss", "Mathematics"],
    "100876543": ["Patrick Merritt", "Computer Games Programming"]
}

with open('std_dict.json', 'w') as f:
    json.dump(std_dict, f, indent=4)

This will output the following to std_dict.json

{
    "100456789": [
        "Sam O'Neill",
        "Computer Science"
    ],
    "100123456": [
        "Roisin Hunt",
        "Computer Games `Modelling and Animation"
    ],
    "100654321": [
        "Tom Hughes-Roberts",
        "Computer Games Programming"
    ],
    "100987654": [
        "Bradley Davis",
        "Computer Science"
    ],
    "100345678": [
        "Rich Conniss",
        "Mathematics"
    ],
    "100876543": [
        "Patrick Merritt",
        "Computer Games Programming"
    ]
}

We can then load it back into memory.

import json

with open('std_dict.json', 'r') as f:
    new_dict = json.load(f)

print(new_dict)

Which prints out:

{'100456789': ["Sam O'Neill", 'Computer Science'], '100123456': ['Roisin Hunt', 'Computer Games `Modelling and Animation'], '100654321': ['Tom Hughes-Roberts', 'Computer Games Programming'], '100987654': ['Bradley Davis', 'Computer Science'], '100345678': ['Rich Conniss', 'Mathematics'], '100876543': ['Patrick Merritt', 'Computer Games Programming']}

Much better!

=== TASK ===

This should be a nice easy one.

Create a load() function that takes in a filename and loads the data stored in the filename.

Copy and paste the following into a new Python file to get started.

def load(filename):
  pass

if __name__ == "__main__":
  print(load("items.json"))

items.json data from W3Schools

Unit 8 - Decomposition, Abstraction, Recursion and Lambda (Advanced)

This unit is about advanced function concepts. I would like you to have at least understood:

  • Decomposition and Abstraction
  • *args and **kwargs

The rest are more difficult and may take some time to understand. Please do not worry if these don't make sense at first.

In this unit we will cover:

  • Decomposition and Abstraction
  • *args and **kwargs
  • Decorators
  • Recursion 101
  • Memoization (Advanced)
  • Lambda Functions, Map and Filter

Decomposition and Abstraction

As well as this lesson, I also highly recommend you read the following - https://cs.stanford.edu/people/nick/py/python-style-decomposition.html


The following definitions are given in [1] (see References).

Decompostion creates struture. It allows us to break a problem into parts that are reasonably self-contained and that may be reused in different settings.

An analogy for this is a car. There are a number of different parts to a car that can be maintained separately. If the car breaks, we probably only have to fix one small part. The car is decomposed into many smaller parts.

Abstraction hides detail. It allows us to use a piece of code as if it were a black-box - that is, something whose interior details we cannot see, don't need to see and shouldn't even want to see.

An analogy for this is also a car, this time though we don't care how the car works. We just need to know how to drive it, e.g. use a steering wheel, clutch, accelerator and brake.

1. Decomposition with Functions

We will see other ways to decompose a program with modules and classes, but for now we will consider functions.

Take the following program.

# program to format a list of strings
name_list = ["sam", "JOE", "Emily", "richard"]
new_name_list = []
# first change the name to lower case, then change the first character to upper
for name in name_list:
    lower_name = name.lower()
    formatted_name = f"{lower_name[0].upper()}{lower_name[1:]}"
    new_name_list.append(formatted_name)
# print names
for name, new_name in zip(name_list, new_name_list):
    print(f"The formatted name for {name} is {new_name}.")
# prints...
# The formatted name for sam is Sam.
# The formatted name for JOE is Joe.
# The formatted name for Emily is Emily.
# The formatted name for richard is Richard.

This is actually a series of jobs that get done.

  1. convert the list of strings to a list of lower case strings
  2. convert the list of lower case strings to a list with the inital capitalised
  3. print the formatted strings

We can write this as a decomposed program using functions.

def string_list_to_lower(items):
    lower_items = []
    for s in items:
        lower_items.append(s.lower())
    return lower_items

def string_list_capitalise_initial(items):
    lower_items = []
    for s in items:
        lower_items.append(f"{s[0].upper()}{s[1:]}")
    return lower_items

def print_formatted(items, formatted_items):
    for s, format_s in zip(items, formatted_items):
        print(f"The formatted name for {s} is {format_s}.")
  
# main program
name_list = ["sam", "JOE", "Emily", "richard"]
# convert the list of strings to a list of lower case strings
lower_name_list = string_list_to_lower(name_list)
# convert the list of lower case strings to a list with the inital capitalised
formatted_list = string_list_capitalise_initial(lower_name_list)
# print the formatted strings
print_formatted(name_list, formatted_list)

Now each of the functions is a smaller self-contained part, that could be reused in a different setting.

Note that this is actually a larger program, decomposition doesn't necessarily mean that the program is smaller.

It does though help with maintainability and seperation of concerns.

Here is a version where the formatting is done in one function, but the function now does 2 things.

def format_list(items):
    formatted_items = []
    for s in items:
        formatted_items.append(f"{s[0].upper()}{s[1:].lower()}")
    return formatted_items

def print_formatted(items, formatted_items):
    for s, format_s in zip(items, formatted_items):
        print(f"The formatted name for {s} is {format_s}.")
  
# main program
name_list = ["sam", "JOE", "Emily", "richard"]
# format_strings
formatted_list = format_list(name_list)
# print the formatted strings
print_formatted(name_list, formatted_list)

And just for fun, here it is using list comprehension...

def format_list(items):
    return [f"{s[0].upper()}{s[1:].lower()}" for s in items]

def print_formatted(items, formatted_items):
    for s, format_s in zip(items, formatted_items):
        print(f"The formatted name for {s} is {format_s}.")
  
# main program
name_list = ["sam", "JOE", "Emily", "richard"]
# format_strings
formatted_list = format_list(name_list)
# print the formatted strings
print_formatted(name_list, formatted_list)

If you inspect all the main programs that use functions, you will see they are about 3-4 lines long and that it is easy to see which bit does what.

Easier to test and easier to debug!

2. Abstraction with Functions

Again, we will see other ways to abstract a program with modules and classes, but for now we will consider functions.

The essence of abstraction is that you only need know what a function does, but not how it works.

Take for example the following function.

def string_list_capitalise_initial(items):
    formatted_items = []
    for s in items:
        formatted_items.append(f"{s[0].upper()}{s[1:]}")
    return formatted_items

I don't really care how it works, I care that I understand what it does and how to use it. Here it is written with a docstring.

def string_list_capitalise_initial(items):
    """ Takes a list of strings and Capitalises the first character.
    e.g. ["test", "case"] -> ["Test" "Case"]
       ["heLLo", "WorLD"] -> ["HeLLo", "WorLD"]
    
    Parameters
    ----------
    items: list
      list of strings.
    
    Returns
    -------
    list:
      list of transformed strings.
    """
    formatted_items = []
    for s in items:
        formatted_items.append(f"{s[0].upper()}{s[1:]}")
    return formatted_items

In many cases this sort of docstring might be left out depending on the programmer; however it is clear what it does and how to use it. It is also important that the function name and parameters makes sense!

The real point it I can use this function without looking at it's internal workings, it is a black-box!

Ideally a function should do one thing only. This makes them easier to test and reuse. It’s hard to define a length restriction but going over 15 lines should make you pause and reflect.

This is an example of something called the single-responsibility principle (SRP).


=== TASK ===

Rewrite the code below so that it uses the functions specified in the remainder of the TASK.

# main program
# list of strings
str_list = ["ABBA", "snake", "Peep", "this", "kayak"]
pal_list = []
for s in str_list:
  lower_s = s.lower()
  if len(lower_s) > 1:
    is_pal = True
    start_index = 0
    end_index = len(lower_s) - 1
    while start_index <= end_index and is_pal:
      if not lower_s[start_index] == lower_s[end_index]:
        is_pal = False
      start_index += 1
      end_index -= 1

    if is_pal:
      pal_list.append(s)

print(pal_list) # prints ['ABBA', 'Peep', 'kayak']

You will note that there are specifications written for each of the functions.

When decomposed properly, your main program should just be 3 lines long.

Copy the following into a new Python file to get started. You can always delete the docstrings to make it easier to read your code.

def is_palindrome(s):
  """ Tests if a string is a palindrome, ignore upper/lower case

  e.g. ABBA returns True
  AbBa returns True
  snake returns False

  Parameters
  ----------
  s: str
    String to test as palindrome

  Returns
  -------
  bool:
    Return True if s in a palindrome; False otherwise.
  """
  pass

def filter_palindromes(items):
  """ Filters a list for palindromes.

  e.g. ["ABBA", "snake", "Peep", "this"] returns ["ABBA", "Peep"]

  Parameters
  ----------
  items: list
    List of strings.

  Returns
  -------
  list:
    Filtered list of palindromes
  """
  pass

if __name__ == "__main__":
  # main program when decomposed
  str_list = ["ABBA", "snake", "Peep", "this"]
  pal_list = filter_palindromes(str_list)
  print(pal_list) # prints ['ABBA', 'Peep']

HINT: You should be calling the is_palindrome() function from inside the filter_palindromes() function.


References

[1] Guttag, J.V. (2021) Introduction to Computation and Programming Using Python, third edition: With Application to Computational Modeling. The MIT Press.

[2] https://cs.stanford.edu/people/nick/py/python-style-decomposition.html

MP: The Frequency Count Program 2.0

Consider a similar program to the Frequency Count Program 1.0.

# print the results with the dictionary sorted.

number_list = []
count_dict = {}
input_string = input("Please enter a whole number: ")

while input_string != "q":
  number_list.append(int(input_string))
  if int(input_string) in count_dict.keys():
    count_dict[int(input_string)] += 1
  else:
    count_dict[int(input_string)] = 1
  input_string = input("Please enter a whole number: ")

# print out the count of the numbers
print(f"The numbers seen were {number_list}")
for key, value in sorted(count_dict.items()):
  print(f"The count for {key} is: {value}")

For example, the program will work as follows:

Please enter a whole number: 3
Please enter a whole number: 5
Please enter a whole number: 2
Please enter a whole number: 6
Please enter a whole number: 7
Please enter a whole number: 1
Please enter a whole number: 10
Please enter a whole number: 63
Please enter a whole number: q
The numbers seen were [3, 5, 2, 6, 7, 1, 10, 63]
The count for 1 is: 1
The count for 2 is: 1
The count for 3 is: 1
The count for 5 is: 1
The count for 6 is: 1
The count for 7 is: 1
The count for 10 is: 1
The count for 63 is: 1

Decomposition

We can reorder this program to do the three tasks separately.

  1. Collect the numbers
  2. Do the counting
  3. Print out to the count of the numbers to the user
# print the results with the dictionary sorted.

# collect the numbers
number_list = []
input_string = input("Please enter a whole number: ")

while input_string != "q":
  number_list.append(int(input_string))
  input_string = input("Please enter a whole number: ")

# do the counting
count_dict = {}
for num in number_list:
  if num in count_dict.keys():
    count_dict[num] += 1
  else:
    count_dict[num] = 1

# print out the count of the numbers
print(f"The numbers seen were {number_list}")
for key, value in sorted(count_dict.items()):
  print(f"The count for {key} is: {value}")

This program now has three distinct parts. That is, it has been decomposed into three separate parts.

We can now go further and wrap each of these parts in a function.

Decompostion with Functions

def get_numbers():
  """ Get a sequence of numbers from the user """
  number_list = []
  input_string = input("Please enter a whole number: ")

  while input_string != "q":
    number_list.append(int(input_string))
    input_string = input("Please enter a whole number: ")

  return number_list

def frequency_count(num_list):
  """ Count the frequency of the numbers """
  count_dict = {}
  for num in num_list:
    if num in count_dict.keys():
      count_dict[num] += 1
    else:
      count_dict[num] = 1

  return count_dict

def print_frequency_count(count_dict):
  """Print out the count of the numbers"""
  for key, value in sorted(count_dict.items()):
    print(f"The count for {key} is: {value}")

def main():
  number_list = get_numbers() # get numbers from user
  freq_count_dict = frequency_count(number_list) # get freq count
  print(f"The numbers seen were {number_list}")
  print_frequency_count(freq_count_dict) # print feedback

if __name__ == '__main__':
  main()

You can see here that we now have three functions that have the responsibility for single parts of our program. These are then called in sequence by the main() function.

  1. get_numbers() returns the numbers entered by the user
  2. frequency_count() takes in a list of numbers and counts the frequency. Returns a dictionary.
  3. print_frequency_count() takes in a dictionary containing the frequency count. Prints out the frequency count to the terminal.

Abstraction

This is now much more manageable and I don't care how these functions work, just that they do! You could replace any of these functions with something that has the same inputs and outputs and the main() program would still work.

Here is an example.

def get_numbers():
  return [1,1,1,2,2,4,5,7,35]

The whole program will still work even though we changed get_numbers(). This is because it still just returns a list of numbers. This is the essence of abstraction. Functions can be treated as black-boxes.

We can even replace frequency_count() with dictionary comprehension using a list method and some set magic.

def frequency_count(num_list):
  return {item:num_list.count(item) for item in set(num_list)}

I'll leave you to try and work this out, but the point again is that we don't care how it works, we just care it does! Black-box!

*args and **kwargs

This lesson will explain what *args and **kwargs are in Python.

1. *args

Let's consider the following function.

def mul(x, y):
  return x * y

This is a simple enough function that takes in two numbers. However, what if you would like a function that can multiply any number of numbers. e.g. 3 * 4 * 5 * 6

One way of doing this is with a list.

def mul(num_list):
  total = 1
  for num in num_list:
    total *= num    # multiply total by num
  return total

list_of_nums = [3,4,5,6]
print(mul(list_of_nums)) # prints 360

This though requires that we create and pass a list of numbers, we can do it another way using *args.

def mul(*args):
  total = 1
  for num in args:
    total *= num
  return total

print(mul(3,4,5)) # prints 60
print(mul(3,4,5,6)) # prints 360

Here we use *args to represent any number of positional arguments. These get packed into what is known as an iterable (in this case it is a tuple) which we can then loop over. The * operator in front of args is known as the unpacking operator.

Note that args is just a name, we could call this something else like numbers.

For example:

def mul(*numbers):
  total = 1
  for num in numbers:
    total *= num
  return total

print(mul(3,4,5)) # prints 60
print(mul(3,4,5,6)) # prints 360

However, it is convention to use args, other programmers will understand better if you stick to this convention!

Here is another example that finds the maximum from a number of numbers.

def maximum(*args):
  # set current max to first item in args
  current_max = args[0]
  # iterate over the rest of args
  for x in args[1:]:
    if x > current_max:
      current_max = x
  return current_max

print(max(1,4,2,7,4)) # prints 7
print(max(1, 4)) # prints 4

2. **kwargs

**kwargs is very similar to *args but instead of being positional arguments, it takes in keyword (named) arguments.

**kwargs will be stored in a dictionary. So you can iterate over the keys(), values() or both using items().

In the example below we iterate over the values. In this case the price of each item.

def bill(**kwargs):
  total = 0
  for price in kwargs.values():
    total += price
  return total

print(bill(bread=5, milk=1.5, orange_juice=1)) # prints 
print(bill(banana=3, pasta=3))
print(bill(bread=5, pasta=3, orange_juice=1, milk=1.5))

In the next example we iterate over both the item and the price using the items() method. Note we have renamed the keyword arguments in the function as bill_items. Again we can call it what we want, we just need to put the ** unpacking operator in front.

def bill(**bill_items):
  total = 0
  for key, value in bill_items.items():
    print(f"Adding {key} at price {value}")
    total += value
  return total

print(bill(bread=5, milk=1.5, orange_juic=1))
print(bill(banana=3, pasta=3))
print(bill(bread=5, pasta=3, orange_juice=1, milk=1.5))

# prints ....
# Adding bread at price 5
# Adding milk at price 1.5
# Adding orange_juic at price 1
# 7.5
# Adding banana at price 3
# Adding pasta at price 3
# 6
# Adding bread at price 5
# Adding pasta at price 3
# Adding orange_juice at price 1
# Adding milk at price 1.5
# 10.5

Again, it is convention to use kwargs, so stick to that.

3. Combining *args and **kwargs

We won't labour on this point, but you can use both in a function, but you need to get the order correct.

Normally we have that positional arguments come before keyword arguments. e.g.

def my_func(x,y,z=10):
  pass

For *args and **kwargs**, *args must come after any positional arguments and **kwargs before any keyword arguments.

However, I would just stick to this order which is common practice:

  1. Positional Arguments
  2. Keyword Arguments
  3. *args arguments
  4. **kwargs arguments

Here are some example function definitions that work and don't work. Try copying them into main.py and running.

3.1 Correct Usage

# This is fine
def my_func(x, y, *args, **kwargs):
    pass
# This is fine
def my_func(x, y, *args):
    pass
# This is fine
def my_func(x, y, **kwargs):
    pass
# This is fine, we now include a keyword argument z
def my_func(x, y, z=10 *args, **kwargs):
    pass

3.2 Incorrect Usage

# This is NOT fine, *args before **kwargs
def my_func(x, y, **kwargs, *args):
    pass
# This is NOT fine, *args before **kwargs
def my_func(x, y, z=10, **kwargs, *args):
    pass
# This is NOT fine, positional arguments come before *args and **kwargs
def my_func(*args, **kwargs, x, y)):
    pass
# This is NOT fine, keyword arguments come before *args and **kwargs
def my_func(x, y, *args, **kwargs, z=10):
  pass

=== TASK ===

Create a function called initials that takes in a persons names and then returns the initials. You should pass the names using *args.

For example for James Marshall Hendrix it should return J.M.H.

Or, for John Ronald Reuel Tolkien it should return J.R.R.T (one *args to rule them all).

Copy this code into a new Python file to get started.

def initials(*args):
  pass

if __name__ == "__main__":
  print(initials("James", "Marshall", "Hendrix")) # should print the return value of "J.M.H"
  print(initials("John", "Ronald", "Reuel", "Tolkien")) # should print the return value of "J.R.R.T"
  

Lambda Functions, Map and Filter

1. Lambda Functions

Sometimes it is useful to generate a small function on the fly. Lamda functions let us do this. We can also bind them to a variable.

Consider the following code.

def increment(x):
  return x+1
  
print(increment(5)) # prints 6

We can also write this as a lambda function.

increment = lambda x: x+1
print(increment(5)) # prints 6

Notice how we have one x after the lambda, this is the function parameter. We then have the return value which is x + 1.

We can also do this for functions with multiple parameters.

def sum(x,y):
  return x+y

print(sum(5,9)) # prints 14

Can be written as a lambda function as follows.

sum = lambda x,y: x+y
print(sum(5,9)) # prints 14

One more simple example. Our is_even() function:

def is_even(x):
  return x % 2 == 0

print(is_even(4)) # prints True
print(is_even(5)) # prints False

This can be also be written as a lambda function.

is_even = lambda x: x % 2 == 0
print(is_even(4)) # prints True
print(is_even(5)) # prints False

2. Map

map() is an extremely useful function for working on lists.

It takes two arguments, a function and an iterable (normally a list).

Syntax

map(function, iterable)

Parameter Values

ParameterDescription
functionRequired. The function to execute for each item
iterableRequired. A sequence, collection or an iterator object. You can send as many iterables as you like, just make sure the function has one parameter for each iterable.
def is_even(x):
  return x % 2 == 0

even_nums = map(is_even, [1,2,3,6,26,73,25,99])
# note that map returns a map object and to print we need to cast to a list.
print(list(even_nums)) # prints [False, True, False, True, True, False, False, False]

It essentially applies the function to each item in the list and then returns a new mapped object of the results. To see the contents of this turn it into a list.

Now we could have done this with a lambda function!

even_nums = map(lambda x: x % 2 == 0, [1,2,3,6,26,73,25,99])
# note that map returns a map object and to print we need to cast to a list.
print(list(even_nums)) # prints [False, True, False, True, True, False, False, False]

Wow, so we just tested a list of numbers to see if they are even using a function that we created on the fly! Phew!

If this seems abit out there (esoteric) it is because it is more advanced and creeping towards the weird and worderful world of functional programming.

If you never use these techniques it really doesn't matter, you don't have to and you will still be a good programmer

Here is another example of using map() on two lists.

def add_item(x, y):
    return x + y

list1 = [1, 2, 3]
list2 = [4, 5, 6]

result = list(map(add_item, list1, list2))
print(result)  # Output: [5, 7, 9]

Here we added each item of the list1 and list2 to get a new list of the added items.

Filter

filter() is another extremely useful function for working on lists.

It takes two arguments, a function (must return a bool) and a list.

Syntax

filter(function, iterables)

Parameter Values

ParameterDescription
functionRequired. The function to execute for each item. Returns a bool
iterableRequired. A sequence, collection, or iterator to filter.

The filter() function returns a filter object. To see the results, you need to convert it to a list or another iterable type.

Filtering Even Numbers

Here, we filter even numbers from a list using both a named function and a lambda function:

def is_even(x):
  return x % 2 == 0

even_nums = filter(is_even, [1,2,3,6,26,73,25,99])
# note that filter returns a filter object and to print we need to cast to a list.
print(list(even_nums)) # prints [2,6,26]

Again we could have done this with a lambda function!

even_nums = filter(lambda x: x % 2 == 0, [1,2,3,6,26,73,25,99])
# note that map returns a filter object and to print we need to cast to a list.
print(list(even_nums)) # prints [2,6,26]

Again, really neat and pretty funky.

Filtering Based on Multiple Lists

You can filter items using multiple lists by combining them with ``zip()```:

list1 = [3, 6, 1, 8, 5]
list2 = [2, 5, 4, 7, 2]

# Keep items from list1 if they are greater than the corresponding items in list2
result = filter(lambda pair: pair[0] > pair[1], zip(list1, list2))
print([x[0] for x in result])  # Output: [6, 8, 5]

Filtering and Mapping Strings

As a final example we will take a list of string, filter out those less than 5 and then convert them to upper case. Try copying these into main.py to see the output.

str_one = "We've known each other for so long Your heart's been aching, but you're too shy to say it (say it) Inside, we both know what's been going on (going on) We know the game and we're gonna play it"

# first we remove the brackets.
str_one = str_one.replace("(", "")
str_one = str_one.replace(")", "")

# next create a list of words by splitting on " " (space)
string_list = str_one.split(" ")

# filter the strings. Keep those less that 5
filtered_list = filter(lambda s: len(s) < 5, string_list)

# map the upper function over the list of filtered words
upper_filtered_list = map(lambda s: s.upper(), filtered_list)

# prints the final result, need to convert to a list
print(list(upper_filtered_list))

and in one line...

string_list = "We've known each other for so long Your heart's been aching, but you're too shy to say it (say it) Inside, we both know what's been going on (going on) We know the game and we're gonna play it"

upper_filtered_list = map(lambda s: s.upper(), filter(lambda s: len(s) < 5, string_list.replace("(", "").replace(")", "").split(" "))) # note this is a single line

print(list(upper_filtered_list))

Now although that it quite impressive, it lacks readability. If you saw this for the first time, you would have to spend time working out what it does.

We won't do any more on this, but it is important to have seen them as they will appear when you see Python code.

I wasn't taught them and for years I'd either just copy and paste or ignore them. Really you should know what a bit of code does.


=== TASK ===

For a list of strings, filter out the strings that are odd in length and then reverse each of these strings and return the resulting list.

You can reverse a string using:

str_one = "test"
print(str_one[::-1])

A function has been created for you that takes a string and returns a reversed string.

You should update the task_answer() function so that it returns the correct list.

Copy and paste the following into a new Python file to get started.

def reverse_string(str_to_reverse):
  return str_to_reverse[::-1]

def task_answer(string_list):
  """ you should filter out odd length strings and reverse these here.
  
  return the result as a list """
  pass

if __name__ == "__main__":
  str_list = ["this", "IS", "a", "list", "of", "a", "few", "strings"]
  result = task_answer(str_list)
  print(result) # should print ['a', 'a', 'wef', 'sgnirts']

Recursion 101

In computer science, it refers to solving a problem by solving a smaller version of the same problem that is solved by solving a smaller version of the same problem that is solved by solving a smaller version of the same problem that is solved by solving a smaller version of the same problem …and so on… until we reach a problem that is small enough to be trivial to solve.

This is often done by having a function invoke itself until the problem is trivial to solve without invoking itself.

You can write all recursive functions iteratively (with loops). You will see this.

In general, if you can, write things with loops.

1. Factorial

One of the simplest recursive methods computes the factorial of a number, where given a number n, n factorial (usually written as n!) is computed as:

n! = n × (n-1) × (n-2) ×.. × 1

E.g:

5! = 5 × 4 × 3 × 2 × 1 = 120

NOTE: for those of you familiar with maths. 0! = 1 and this is normally the base case. We have ignored this and are stopping at 1.

If we use computing speak and write this as a function fact(n):

# note this is not working code, just more familiar notation
fact(n) = n * (n-1) * ... * 1

E.g:

fact(5) = 5 * (5-1) * ... * 1 = 5*4*3*2*1 = 120

Which we could choose to implement in python write using a for loop.

def fact(n):
  total = 1
  for i in range(1, n+1):
    total = total * i
  return total

print(fact(5)) # prints 120 = 5*4*3*2*1

If we look carefully at

fact(n) = n * (n-1) * ... * 1

we will notice something seemingly unremarkable, but it has deep consequences!

fact(5) = 5*4*3*2*1

Take a look at

4*3*2*1

this is also fact(4), therefore we can rewrite fact(5) as follows:

fact(5) = 5*fact(4)

We now have a function defined in terms of itself!

In general,

fact(n) = n*fact(n-1)
Where, 
fact(1) = 1

So, when we call fact(5) the following happens:

fact(5) = 5*fact(5-1)

which calls

fact(4) = 4*fact(4-1)

which calls

fact(3) = 3*fact(3-1)

which calls

fact(2) = 2*fact(1) 

which calls

fact(1)=1

We reach the easily evaluated case of fact(1)

We can then unwind all of this.

fact(1)=1

means that

fact(2) = 2*fact(1) = 2*1 = 2

which means

fact(3) = 3*fact(2) = 3*2 = 6

which means

fact(4) = 4*fact(3) = 4*6 = 24

and finally

fact(5) = 5*24 = 120

fact(1) is known as the base case. This is normal a case we know how to evaluate easily. If you think about it, if we didn’t have a base case, we would have just kept on going!!!

Written as a python program is simple. We just need to put in our general recursion and base case.

fact(n) = n*fact(n-1)
Where, 
fact(1) = 1

Which becomes

def fact(n):
  if n < 2:
    return 1 # return base case
  return n*fact(n-1)

print(fact(5)) # prints 120

Note that we could put in a float or a number less than 0 and get erroroneous answers, but for ease of reading we assume whole numbers of 0 and above.

You can visualise what is happening on Python Tutor. Paste in the code and call fact(4) which will return 24.

2. Summing a List of Numbers

We can also consider summing a list of numbers, e.g. 5,4,2,6.

Let’s just define a function called sum_list() that takes a list of numbers and adds them up.

Then by definition of additon.

sum_list([3,4,9]) = 3 + sum_list([4,9])
sum_list([3,4,9]) = 3 + sum_list([4,9])
             = 3 + 4 + sum_list([9])
             = 3 + 4 + 9 + sum_list([])
             = 3 + 4 + 9 + 0 = 16

Here we can clearly see the base case is the sum of an empty list, which is just 0. i.e. sum_list([]) = 0.

In general,

sum_list(number_list) = item_one + sum_list(rest_of_list)
Where,
sum_list(empty_list) = 0

def sum_list(num_list):
  # test for base case
  if len(num_list) == 0:
    return 0
  # return head + sum of rest of list
  return num_list[0] + sum_list(num_list[1:])

print(sum_list([3,4,9])) # prints 16

To me that is pretty darn beautiful!

Note that you can also do this in one less recursive call by noting the following:

sum_list([3,4,9]) = 3 + sum_list([4,9])
             = 3 + 4 + sum_list([9])
             = 3 + 4 + 9

i.e. a list with 1 item can also be considered a base case. We don't have to go all the way to the empty list.

sum_list(number_list) = item_one + sum_list(rest_of_list)
Where,
sum_list(single_item_list) = single_item_list

def sum_list(num_list):
  # test for base case
  if len(num_list) == 1:
    return numlist[0]
  # return head + sum of rest of list
  return num_list[0] + sum_list(num_list[1:])

print(sum_list([3,4,9])) # prints 16

Of course you can just use a loop.

def sum_list(num_list):
  total = 0
  for num in num_list:
    total += num
  return total

print(sum_list([3,4,9])) # prints 16

3. Fibonacci Numbers

No introduction to recursion would be complete without a look at the Fibonacci numbers.

These are a sequence of numbers that you get by adding the previous two numbers of the sequence. You start with 1 and 1.

1,1                # add 1 and 1
1,1,2              # add 1 and 2
1,1,2,3            # add 2 and 3
1,1,2,3,5          # add 3 and 5
etc...
1,1,2,3,5,8,13,21,34

If you define the nth fibonacci number of the sequence as fib(n) then

# the next number is the sum of the previous 2 in the sequence!
fib(n) = fib(n-1) + fib(n-2)
where,
fib(0) = 1 and fib(1) = 1

Here we have the recursive formula and the two base cases fib(0) and fib(1).

We can easily put this into python.

def fib(n):
  if n < 2:
    return 1

  return fib(n-1) + fib(n-2)

# Two ways of printing the first 6 terms
for n in range(6):
  print(f"fib({n}) = {fib(n)}")
# prints 
# fib(0) = 1
# fib(1) = 1
# fib(2) = 2
# fib(3) = 3
# fib(4) = 5
# fib(5) = 8

print([fib(n) for n in range(6)]) # prints [1,1,2,3,5,8]

I recommend that you look at this in Python Tutor to see an animated version of what is happening.

4. Why Bother?

You can write all recursive methods iteratively (with loops), but:

  • it does require you to track the information
  • sometimes it is just a lot easier to solve a problem recursively.

You'll see a lovely example of this in the exercise for the lesson on Memoization.

In general, if you can, write things with loops.


===TASK ===

Create the functions string_list_concat() and string_list_concat_with_space() so that it takes a list of strings as input and concatenates (joins) them into one string as per the function docstring.

YOUR FUNCTION SHOULD USE RECURSION AND USE THE MINIMAL AMOUNT OF RECURSIVE CALLS.

HINT: Section 2 on summing numbers is an excellent place to start. You should consider how this task is similar to the recursive program given in Section 2.

Copy and paste the following into a new Python file to get started.

def string_list_concat(string_list):
  """ Concatenates a list of strings into a single string
  eg. ["This", "is", "a", "list", "of", "strings"] 
  returns
  "Thisisalistofstrings"
  """
  pass

def string_list_concat_with_space(string_list):
  """ Concatenates a list of strings into a single string
  eg. ["This", "is", "a", "list", "of", "strings"] 
  returns
  "This is a list of strings"
  """
  pass

if __name__ == "__main__":
  str_list = ["This", "is", "a", "list", "of", "strings"]
  print(string_list_concat(str_list)) # prints the return value of "Thisisalistofstrings"
  print(string_list_concat_with_space(str_list)) # prints the return value of "This is a list of strings"

Memoization (Advanced)

We won't go into great detail here, but we will look at improving the efficiency of the Fibonacci numbers. This is a reasonably advanced topic.

There are two ways of doing this.

One is simply the bottom-up approach. Instead of writing recursively, we just write this with a loop.

The other is by making recursion more efficient.

Sometimes a recursive algorithm is just easier to write, so looking for improvements on speed and memory are important.

To demonstrate this, try the following:

import timeit

def fib(n):
  if n < 2:
    return 1

  return fib(n-1) + fib(n-2)

start_time = timeit.default_timer()
# call the function
fib(35)
end_time = timeit.default_timer()

print(end_time - start_time) # You will get about 5 seconds for this. That is slow!!!

You will get about 5 seconds for this. That is slow!!!

import timeit

def fib_loop(n):
  if n < 2:
    return 1
    
  fib0 = 1
  fib1 = 1
  while n > 2:
    temp = fib1
    fib1 = fib1 + fib0
    fib0 = temp
    n -= 1
  return fib1

start_time = timeit.default_timer()
# call the function
fib_loop(35)
end_time = timeit.default_timer()

print(end_time - start_time) # You will get a fraction of a second for this. That is much faster!!!

You will get a fraction of a second for this. That is much faster!!!

In fact as soon as you go beyond about 36 it starts to take a long long time.

This is the essence of something called computational efficiency. Sometimes our algorithms are just plain bad and slow!

It doesn't matter if we have the worlds fastest machine, in some cases, it just won't complete.

What is Going On?

The recursive approach continues to call the function until it hits a base case. In the cases of the fibonacci, this is actually a tree of function calls.

Fib Tree

What you should notice is that there are multiple calls to the fib() function for fib(2), fib(1) and fib(0). This takes time.

The key to making this faster is that when we make a call to a function with a given value, e.g. fib(1), we store the result in a dictionary and then when we try to call it again, instead of using recursion, we just look up the result in the dictionary.

Fib Tree Pruned

I would read the following article for a more in depth explanation.

Real Python Memoization

Memoization

To code this in Python we can save a fib number everytime we see it and then if we see it again we just look it up, we don't do the expensive function call!

Here is the code. The function fib_memo uses a dictionary memo to keep track of the fib numbers. I strongly recommend that you paste this into Python Tutor.

import timeit

def fib_memo(n, memo):
  # if not seen add to dictionary
  if not n in memo.keys():
    # add unseen to dictionary
    if n < 2:
      memo[n] = 1
    else:
      memo[n] = fib_memo(n-1, memo) + fib_memo(n-2, memo)
  
  return memo[n]

fib_nums = {}
start_time = timeit.default_timer()
# call the function
fib_memo(35, fib_nums)
end_time = timeit.default_timer()

print(end_time - start_time) # You will get a fraction of a second for this. That is much faster!!!

Analysing all 3 Approaches

The program below will output the following plot using Python's plotting library matplotlib.

Analysis Approaches

You can clearly see here that the the recursive approach is very poor after only 36.

Thus, you can use the other approaches to generate much much larger Fibonacci numbers.

import timeit
import matplotlib.pyplot as plt

def fib(n):
  if n < 2:
    return 1

  return fib(n-1) + fib(n-2)

def fib_loop(n):
  if n < 2:
    return 1
    
  fib0 = 1
  fib1 = 1
  while n > 2:
    temp = fib1
    fib1 = fib1 + fib0
    fib0 = temp
    n -= 1
  return fib1

def fib_memo(n, memo):
  # if not seen add to dictionary
  if not n in memo.keys():
    # add unseen to dictionary
    if n < 2:
      memo[n] = 1
    else:
      memo[n] = fib_memo(n-1, memo) + fib_memo(n-2, memo)
  
  return memo[n]

def time_function(func, n, memo=None):
  start_time = timeit.default_timer()
  if not memo == None:
    func(n, memo)
  else:
    func(n)  
  end_time = timeit.default_timer()
  return end_time - start_time


fib_nums = {}
cases = range(1,36)
times_fib = []
times_fib_loop = []
times_fib_memo = []
for n in cases:
  print(f"Computing times for n={n}")
  times_fib.append(time_function(fib, n))
  times_fib_loop.append(time_function(fib_loop, n))
  times_fib_memo.append(time_function(fib_memo, n, memo=fib_nums))

plt.plot(cases, times_fib)
plt.plot(cases, times_fib_loop)
plt.plot(cases, times_fib_memo, '--')
plt.xlabel("Fib Number (n)")
plt.ylabel("Time (s)")
plt.legend(["Fib Recursive", "Fib Loop", "Fib Recursive Memo"])
plt.show()

=== TASK ===

Please note that this is an advanced topic and you are by no means expected to be able to do this straight away. In fact you shouldn't worry if you can't complete it. If you are struggling then I would move on.

We have a grid. Your goal is to walk through the grid from start to finish by only moving right or down.

Grid Walk

How many paths are there from the start to the finish given that we can move right or down?

For the two by two grid given in the example we have the following paths.

RRDD
RDRD
RDDR
DRRD
DRDR
DDRR

To solve this we can turn to recursion.

If we look at the number of paths at the end (2,2) we can note that they will actually be the sum of the number of paths to (1,2) and (2,1).

Similarly the number of paths from (1,2) are the sum of the number of paths to (0,2) and (1,1).

The number of paths on the edge, such as (0,2) are the just the number of paths from (0,1) (that is you can only get to (0,2) from (0,1).

Once we get back to the start (0,0) there is just 1 path, that is, the start.

We actually have the following:

if we are not on an edge
  no_paths(x,y) = no_paths(x-1,y) + no_paths(x,y-1)
  
if we are on an edge left x = 0
  no_paths(0,y) = no_paths(0, y-1)
  
if we are on an edge left y = 0
  no_paths(x,0) = no_paths(x-1, 0)
  
if we are at the start (0,0)
  no_paths(0,0) = 1

We can now write this as a recursive function as follows:

def no_paths(x,y):
  if x == 0 and y == 0:
    return 1
  elif x == 0:
    return no_paths(0, y-1)
  elif y == 0:
    return no_paths(x-1, 0)
  else:
    return no_paths(x-1, y) + no_paths(x, y-1)

print(no_paths(1,1)) # prints 2
print(no_paths(2,2)) # prints 6
print(no_paths(1,2)) # prints 3

Copy and paste the following into a new Python file to get started.

You need to fix the paths_memo() function so that it uses memoization. This will mean that it will run extremely quickly, where as trying to run larger numbers for paths will take a long time or never end.

import timeit

def no_paths(x,y):
  if x == 0 and y == 0:
    return 1
  elif x == 0:
    return no_paths(0, y-1)
  elif y == 0:
    return no_paths(x-1, 0)
  else:
    return no_paths(x-1, y) + no_paths(x, y-1)

def no_paths_memo(x, y, memo):
  pass


if __name__ == "__main__":
  # This times the paths method
  start_time = timeit.default_timer()
  # walk a grid 13 by 13. This takes approximately 2.5 seconds
  print(f"Number of paths for a 13x13 grid is {no_paths(12,12)}")  
  end_time = timeit.default_timer()
  print(f"Time taken: {end_time - start_time}")
  
  # This times the paths_memo method
  start_time = timeit.default_timer()
  # walk a grid 13 by 13. This should take a fraction of a second
  print(f"Number of paths for a 13x13 grid is {no_paths_memo(12,12, {})}")  
  end_time = timeit.default_timer()
  print(f"Time taken: {end_time - start_time}")

References

Real Python Memoization

Closures and Decorators (Advanced)

Please note that this is a long lesson because there are a lot of details that you need to understand closures and decorators.

In this lesson we will learn about:

  • Global variables
  • Nested functions
  • Nonlocal variables
  • Higher order functions
  • Closures
  • Decorated functions

1. Closures

1.1 Global Variables

I have deliberately not introduced global variables until now because in 99.9% of time if you are using a global variable you have designed your program badly.

I am introducing them so that we will understand nonlocal variables in a couple of sections.

Consider the following code,

def add_five(x):
    x = x + 5
    print(f"Value of x in add_five() is {x}")

x = 10
print(f"Value of x before add_five() is {x}")
add_five(x)
print(f"Value of x after add_five() is {x}")

will output:

Value of x before add_five() is 10
Value of x in add_five() is 15
Value of x after add_five() is 10

This is because the x in add_five() is local to the function add_five(). Modifying it does not change the x defined in the global scope (outside the function).

We can alter this by adding the global keyword in front of x in the function and not passing it as a parameter as follows:

def add_five():
    global x
    x = x + 5
    print(f"Value of x in add_five() is {x}")

x = 10
print(f"Value of x before add_five() is {x}")
add_five()
print(f"Value of x after add_five() is {x}")

The output now is:

Value of x before add_five() is 10
Value of x in add_five() is 15
Value of x after add_five() is 15

Now we are modifying the global variable x, it does not belong to the scope of the add_five() function.

1.2 Nested Functions

Python allows you to nest a function within a function. Here is an example.

def outer():
    
    print("Hi I am in the outer function")
    
    def inner():
        print("Hi I am in the inner function")
    
    # make a call to the inner function
    inner()
    print("Hi I am again in the outer function")

outer()

This will print out the following:

Hi I am in the outer function
Hi I am in the inner function
Hi I am in again in the outer function

1.3 Nonlocal variables

A nonlocal variable works very much like a global variable, but it is restricted to the scope of the outer function.

Consider the following example,

def outer():
    x = 10
    print(f"Hi I am in the outer function, the value of x is {x}")
    
    def inner():
        print(f"Hi I am in the inner function, the value of x is {x}")
    
    # make a call to the inner function
    inner()
    print(f"Hi I am again in the outer function, the value of x is {x}")

outer()

this will result in,

Hi I am in the outer function, the value of x is 10
Hi I am in the inner function, the value of x is 10
Hi I am in again in the outer function, the value of x is 10

as the inner() function has access to the x defined in the scope of the outer() function.

Now consider the following example,

def outer():
    x = 10
    print(f"Hi I am in the outer function, the value of x is {x}")
    
    def inner():
        x = 15
        print(f"Hi I am in the inner function, the value of x is {x}")
    
    # make a call to the inner function
    inner()
    print(f"Hi I am again in the outer function, the value of x is {x}")

outer()

this prints out the following:

Hi I am in the outer function, the value of x is 10
Hi I am in the inner function, the value of x is 15
Hi I am in again in the outer function, the value of x is 10

Notice how once the inner() function had ended, the value of x is 10, this is because the x that is defined at the beginning of outer() is not the same as the one that is used at the beginning of inner().

They are different, when we ran the code x = 15 it defined a new x in the scope of inner().

Now consider the following:

def outer():
    x = 10
    print(f"Hi I am in the outer function, the value of x is {x}")
    
    def inner():
        nonlocal x 
        x = 15
        print(f"Hi I am in the inner function, the value of x is {x}")
    
    # make a call to the inner function
    inner()
    print(f"Hi I am again in the outer function, the value of x is {x}")

outer()

Here we use the nonlocal keyword to let the inner() function know that we are working with the x that belongs to the outer() function.

The output is,

Hi I am in the outer function, the value of x is 10
Hi I am in the inner function, the value of x is 15
Hi I am in again in the outer function, the value of x is 15

and you can see that we actually changed the value of the x belonging to outer().

1.3.1 Being More Explicit

We can be more explicit by using the outer function name when declaring our x variable.

For example,

def outer():
    # declare the variable using the function name.
    outer.x = 10
    print(f"Hi I am in the outer function, the value of x is {outer.x}")
    
    def inner():
        x = 15
        print(f"Hi I am in the inner function, the value of x belonging to inner is {x}")
        print(f"Hi I am in the inner function, the value of x belonging to outer is {outer.x}")
    
    # make a call to the inner function
    inner()
    print(f"Hi I am again in the outer function, the value of x is {outer.x}")

outer()

will print out:

Hi I am in the outer function, the value of x is 10
Hi I am in the inner function, the value of x belonging to inner is 15
Hi I am in the inner function, the value of x belonging to outer is 10
Hi I am in again in the outer function, the value of x is 10

Now it is clear that the inner function is not overwriting the value of outer.x, but we can still access it within the inner function.

We could of course overwrite outer.x from within the inner function:

def outer():
    # declare the variable using the function name.
    outer.x = 10
    print(f"Hi I am in the outer function, the value of x is {outer.x}")
    
    def inner():
        outer.x = 15
        print(f"Hi I am in the inner function, the value of x is {outer.x}")
    
    # make a call to the inner function
    inner()
    print(f"Hi I am again in the outer function, the value of x is {outer.x}")

outer()

This prints out the following:

Hi I am in the outer function, the value of x is 10
Hi I am in the inner function, the value of x is 15
Hi I am in again in the outer function, the value of x is 15

1.4 Higher Order Functions

In Python, fucntions are first class objects. This means that you can do the same things with them as you do with other objects like:

  • Assign to a variable
  • Pass them into other functions
  • Return them from other functions
  • Store them in data strucutres like lists and dictionaries

For example the following assigns a function to a variable.

def add(x,y):
    return x + y

# assign the function add to the name my_add
my_add = add

print(my_add(5,3)) # prints 8
print(add(5,3)) # prints 8

This example returns a function from another function.

# this function returns a function
def another_function():
    def add(x,y):
        return x + y
    return add

# assign the return function to my_add
my_add = another_function()

# call the function using my_add
print(my_add(5,2))

Finally the following example passes a function into another function and then calls it.

def print_function(func, *args):
    # print the return values of the passed in function
    print(func(*args))

def add(x,y):
    return x + y

# pass the add function to print_function
print_function(add, 3, 4) # prints 7

1.5 Closures

What is a closure?

The following is the definition given on Geeks For Geeks:

A Closure is a function object that remembers values in enclosing scopes even if they are not present in memory.

  • It is a record that stores a function together with an environment: a mapping associating each free variable of the function (variables that are used locally but defined in an enclosing scope) with the value or reference to which the name was bound when the closure was created.

  • A closure—unlike a plain function—allows the function to access those captured variables through the closure’s copies of their values or references, even when the function is invoked outside their scope.

When do we have closures?

The criteria that must be met to create a closure in Python are summarised in the following points.

  • We must have a nested function (function inside a function).
  • The nested function must refer to a value defined in the enclosing function.
  • The enclosing function must return the nested function.

This last statement is important. We can return a function, just like a variable. In Python functions are first class objects and we pass them around just like variables, lists etc.

Programiz Python Closures

1.4.1 A Simple Example

Here is an example of a closure that returns a function that will multiply a number by a set number, e.g. 3 or 5.

def create_adder(n):
    def adder(x):
        return n + x
    return adder


# Creates a function that is adds 3 to the input
add3 = create_adder(3)

# Output: 12
print(add3(9))

# Creates a function that is a adds 5 to the input
add5 = create_adder(5)

# Output: 8
print(add5(3))

# Output: 10
print(add5(add3(2)))

We can see here that create_adder() is a function that returns another function using the enclosing functions n.

So add3 = create_adder(3) is actually creating and returning the following function.

def add3(x):
    return 3 + x

# Output: 12
print(add3(9))

2. Decorators

Decorators are basically a closure, whereby we pass in a function and add additional functionality around the function and then return another (decorated) function.

Functions are callable and implement the special __call__() method. Other objects such as methods (see object orientation and classes) implement this method as well.

Anything that is callable can be decorated.

2.1 Decorated Function

Here is a simple example which takes in a function and adds some additional print statements around the function.

def pretty_print(f):
    def inner():
        print("Let's print out this function in a pretty way")
        print("---------------------------------------------")
        # invoke the passed in function
        f()
        print("---------------------------------------------")
    return inner

def print_hello():
    print("Hello")

# pass pretty_print the print_hello() function
# this returns a new decorated function
pretty_hello = pretty_print(print_hello)

# invoke the decorated function
pretty_hello() 

Prints out the following:

Let's print out this function in a pretty way
---------------------------------------------
Hello
---------------------------------------------

Here we are passing pretty_print() the print_hello() function and assigning it to the name pretty_hello.

We can then call this newly decorated function.

2.2 Using the @ Symbol for Decorating

It turns out that this is such a common pattern that Python has a special way of marking a function to be decorated by another function.

To do this we write @decorator_name above the function to be decorated. decorator_name is the name of the function that does the decorating.

For example we can amend the code above as follows:

def pretty_print(f):
    def inner():
        print("Let's print out this function in a pretty way")
        print("---------------------------------------------")
        f()
        print("---------------------------------------------")
    return inner


def print_hello():
    print("Hello")

# decorates the function using the @ symbol
@pretty_print
def pretty_hello():
    print_hello()

pretty_hello() 

Prints out the following:

Let's print out this function in a pretty way
---------------------------------------------
Hello
---------------------------------------------

2.3 Decorating with Parameters

We can also decorate functions that take in parameters, good job because it would be very limiting if we couldn't.

Here is an excellent example modified from Programiz.

We decorate a standard division so that if we try to divide by 0 we get infinity (division by zero is actually an error, but some calculators display infinity because of something known as limits.)

import math

def smart_divide(func):
    # note that the parameters of inner match the parameters of func
    def inner(a, b):
        print("I am going to divide", a, "and", b)
        if b == 0:
            print("Whoops! cannot divide")
            return math.inf

        return func(a, b)
    return inner


@smart_divide
def divide(a, b):
    return a/b

if __name__ == "__main__":
  print(divide(3,2))
  print(divide(3,0))

The above prints out:

I am going to divide 3 and 2
1.5
I am going to divide 3 and 0
Whoops! cannot divide
inf

The following logs (via print statements) the result of a simple add() function.

def logger_add(func):
    # note that the parameters of inner match the parameters of func
    def inner(x, y):
        print(f"I am going to add {x} and {y}")
        result = func(x,y)
        print(f"The answer is {result}")
    return inner

@logger_add
def add(x, y):
    return x + y

add(3,4)
# prints
# I am going to add 3 and 4
# The answer is 7

You may notice that the parameters of inner() function in logger_add() match the parameters of the function passed in, in this case add().

We can get around this by using *args to take in a unspecified number of arguments.

Here is an example.

def logger_add(func):
    # note that the parameters of inner match the parameters of func
    def inner(*args):
        print(f"I am going to add {args}")
        result = func(*args)
        print(f"The answer is {result}")
    return inner

@logger_add
def add_two(x, y):
    return x + y

  
@logger_add
def add_three(x, y, z):
    return x + y + z

add_two(3,4)
# prints
# I am going to add (3, 4)
# The answer is 7

add_three(3,4,5)
# prints
# I am going to add (3, 4, 5)
# The answer is 12

You can also accepts any number of arguments and keyword arguments.

def universal_decorator(func):
    def inner(*args, **kwargs):
        print("I can decorate all functions")
        func(*args, **kwargs)
    return inner

@universal_decorator
def funky_func(x, y, z=1):
    print(x)
    print(y)
    print(z)

# call the decorated function
funky_func(3,4,z=10)

The above will print out:

I can decorate all functions
3
4
10

=== TASK ===

If you have read everything and understand it, then this should be reasonably straight forward.

Create a logger for a function called logger(). (I would use the examples below as your starting point.)

Your logger should decorate a function and do the following:

  • print some stuff
  • call the function
  • print some more stuff

It should work on any function with any number of arguments or keyword arguments.

Note you will need to look up how to get access to a functions name. You can read the following to help - Function Name

The following examples illustrate how the logger decorator should work. Currently the decorator does nothing but return the same function.

Example 1

def logger(func):
    # write your code here
    return func

@logger
def add_three(x, y, z):
    print(x + y + z)

if __name__ == "__main__":
    add_three(3,4,5)

The above code should print the following:

Logging function...
The functions name is add_three
Calling the function...
12
Ending logging...

Example 2

def logger(func):
    # write your code here
    return func

@logger
def hello():
    print("hello")

if __name__ == "__main__":
    hello()

The above code should print the following:

Logging function...
The functions name is hello
Calling the function...
hello
Ending logging...

Example 3

def logger(func):
    # write your code here
    return func

@logger
def test_keyword(a=1, b=2, c=3):
    print(a+b+c)

if __name__ == "__main__":
    test_keyword(a=2)

The above code should print the following:

Logging function...
The functions name is test_keyword
Calling the function...
7
Ending logging...

To get started copy and paste the following into a new Python file. Currently the logger function just returns the function undecorated.

def logger(func):
    # write your code here
    return func

@logger
def funky_func(x, y, z=1):
    print(x)
    print(y)
    print(z)

@logger
def add_three(x, y, z):
    return x + y + z

@logger
def hello():
    print("hello")

@logger
def test_keyword(a=1, b=2, c=3):
    print(a + b + c)

if __name__ == "__main__":
    funky_func(3, 4, z=10)
    print()
    funky_func(3, 4)
    print()
    add_three(3, 4, 5)
    print()
    hello()
    print()
    test_keyword()

It should output the following if implemented correctly.

Logging function...
The functions name is funky_func
Calling the function...
3
4
10
Ending logging...

Logging function...
The functions name is funky_func
Calling the function...
3
4
1
Ending logging...

Logging function...
The functions name is add_three
Calling the function...
12
Ending logging...

Logging function...
The functions name is hello
Calling the function...
hello
Ending logging...

Logging function...
The functions name is test_keyword
Calling the function...
6
Ending logging...

Unit 9 - Object Oriented Programming 1 (OOP)

This week, we begin object-oriented programming. Object-oriented programming is sometimes considered difficult, but the concept is simple:

Instead of writing one big monolithic program with a bunch of functions, you write a bunch of simple, small, self-contained programs, each with their own functions. Then you make the small, self-contained programs communicate with each other by invoking each other’s functions, which collectively does the same thing that one big monolithic program would do.

Done properly, a bunch of little programs should be easier to write, easier to debug, and easier to maintain than one big program. Also, you might be able to re-use some of the little programs in other applications or other parts of the same application.

At least, that’s the idea. Whether object-oriented programming actually is easier than writing programs only modularised with functions is a matter of some debate (and largely beyond the scope of this module), but object-oriented programming is pervasive in the industry and will likely remain so for a long time, so it makes sense to learn it.

To summarise what will be covered:

  • A class defines instances, also known as objects, of that class. The relationship between a class and its instances is 0-to-many; you have a class and zero or more instances of it. This relationship can be understood in different ways:
    • A class represents a category; an instance is in item in that category. For example, my little buddies Calvin and Indi are instances of the class Cat.

    • A class is like the source code for a small, self-contained program. An instance of a class is that small program loaded into memory and ready to run.

    • A class can represent a data type. Just as built-in types like int, float, and str are data types (and 3, 3.4, “fish” notionally instances thereof), you can create your own data types using classes and create instances of them. For example, you might create a Book class with instances “20,000 Leagues Under the Sea” and “The Hobbit”.

    • A class is like a blueprint; instances are things made from the blueprint.

  • You can make an instance do things by running, also known as invoking, its methods. A method is simply a function associated with a particular class, and thus all its instances. Collectively, methods represent the possible behaviour of a class, and thus all its instances.
  • An instance usually contains data. This is stored in instance variables (sometimes called member variables) or fields. The data in instance variables is often described as representing the state of an instance. If the data in an instance has changed, we say the instance’s state has changed. In Python, the preferred mechanism for reading or changing instance data is via attributes or properties. We can also use methods to read or change instance variables. In languages like Java, the only way to read or change instance variables is via methods. In languages like C#, using properties is preferred, but methods can also be used to read or update instance variables.
  • We say that an instance “can take care of itself”. In other words, you can ask an instance to do things (by invoking its methods), but you don’t have to know or care how the instance does it. You communicate with the instance – i.e., tell it to do things, or retrieve or change its data – through public methods and (in Python and some but not all other object-oriented languages) properties. The internal implementation details of the instance are kept hidden (i.e. private). This hiding of the internal implementation and data is often known as encapsulation.

If you’ve not done object-oriented programming before, this may seem complicated and confusing, but it will become clearer over the next few weeks – and beyond if you take Programming II.

Classes, Methods and Objects

Instead of writing one big monolithic program with a bunch of functions, you write a bunch of simple, small, self-contained programs, each with its own functions. Then you make the small, self-contained programs communicate with each other by invoking each other’s functions, which collectively does the same thing that one big monolithic program would do.

Done properly, a bunch of little programs should be easier to write, easier to debug, and easier to maintain than one big program. Also, you might be able to reuse some of the little programs in other applications or other parts of the same application.

At least, that’s the idea. Whether object-oriented programming is easier than writing programs only modularised with functions is a matter of some debate (and largely beyond the scope of this module), but object-oriented programming is pervasive in the industry and will likely remain so for a long time, so it makes sense to learn it.

1. Defining a Class and a Method

Before we define our first class, let's take a quick look back at types.

1.1 Types and Objects

So far we have been using built-in types that Python gives us like int, float, str, bool. An object was an instance of one of these types, e.g. 5 is an object of type int.

Python lets us check what type an object is with the type() function.

x = 5
print(type(x))
<class 'int'>

Now, what if we want to build our own types? This is exactly where classes come in, object orientation is a way of representing types and this is what we are about to learn.

Let's start by making a simple class that represents a pet. Yes, that is right, a pet, you can represent whatever you like, you are the architect!

This class will be a blueprint (template) that will let us create many pets.

We will start by giving our Pet class a single method (this is like a function, but it belongs to the class) called talk() which will just allow an instance of a pet to say Hi.

The following code does this and also create two instances of Pet and invokes (calls) the talk() method for each instance of the class (object). You will also notice that talk is defined with the word self as the first parameter. This will be explained in the next lesson.

class Pet:
    """ A pet class """

    # This is a method, it belongs to the instance of the pet created
    def talk(self):
        print("Hi, I am an instance of pet")

if __name__ == "__main__":
    # create an instance of Pet
    pet_one = Pet()
    # create another instance of Pet
    pet_two = Pet()
    # call the method talks on the object pet_one
    pet_one.talk()
    # call the method talks on the object pet_two
    pet_two.talk()
Hi, I am an instance of pet
Hi, I am an instance of pet

OK, so nothing really magic here yet...

1.1 Type of a Class

Before we move on, back to types, let's check what type our Pet objects are.

pet_one = Pet()
print(type(pet_one))
<class 'Pet'>

Ah so the type of pet_one is the Pet class, this now starts to shed some light on other built-in types such as int, float, str and also why we got the class word in front of our type.

Internally Python uses classes to build its built-in types.

=== TASK ===

Create a class to represent a person.

Your person class should be called Person and have one method called wave() which should print out Hi, I am a person and I am waving!

Constructors and Attributes

1. Constructors

Constructors are a special method that you can define that is called when you first create an instance of a class. In Python it is given the name __init__() (for initialise the object), again we put the word self as the first parameter.

class Pet:
    """ A pet class """
    def __init__(self):
        print("Creating a new pet")

    def talk(self):
        print("Hi, I am an instance of pet")

if __name__ == "__main__":
    # create an instance of Pet, which will invoke the constructor
    pet_one = Pet()
    # create another instance of Pet, which will invoke the constructor
    pet_two = Pet()
    pet_one.talk()
    pet_two.talk()
Creating a new pet
Creating a new pet
Hi, I am an instance of pet
Hi, I am an instance of pet

OK, again that is all very nice, but why!?

2. Attributes

Attributes are names (variables) that we can define in classes, this is where the real power starts to show.

It allows us to create instances of the class with different data for the attributes. Hence we can now have different instances (objects) of Pet that have different names.

2.1 Initialising Attributes

We do this by creating a name attribute that our constructor __init__() takes as a parameter and then sets for the instance of the class using self.

It is key to note the use of self. In both __init__() and talk() it is the first parameter. You don't have to call it self, but it is convention.

self gives us access to the data for the given instance. That is self.name contains different data for the instance pet_one and the instance pet_two.

The class now looks as follows:

class Pet:
    """ A pet class """
    # take 1 parameter into the constructor - name
    def __init__(self, name):
        print("Creating a new pet")
        # set the instance attribute to the one passed into the constructor
        self.name = name 

    def talk(self):
        """ An instance method that says hi """
        print(f"Hi, my name is {self.name}")

if __name__ == "__main__":
    # create an instance of Pet, which will invoke the constructor
    pet_one = Pet("Fidget")
    # create another instance of Pet, which will invoke the constructor
    pet_two = Pet("Rex")
    pet_one.talk()
    pet_two.talk()

This now prints out:

Creating a new pet
Creating a new pet
Hi, my name is Fidget
Hi, my name is Rex

We can extend this so that our pets have an age.

class Pet:
    """ A pet class """
    def __init__(self, name, age):
        self.name = name
        self.age = age 

    def talk(self):
        """ An instance method that says hi """
        print(f"Hi, my name is {self.name} and I am {self.age} years old.")

if __name__ == "__main__":
    pet_one = Pet("Fidget", 12)
    pet_two = Pet("Rex", 5)
    pet_one.talk()
    pet_two.talk()

Prints out:

Hi, my name is Fidget and I am 12 years old.
Hi, my name is Rex and I am 5 years old.

2.2 Keyword Attributes

We can also add attributes that have a default value by using a keyword argument in our constructor __init__().

Here we add an attribute for the number of legs a Pet has. We will assume a Pet has 4 legs unless overridden.

class Pet:
    """ A pet class """
    def __init__(self, name, age, no_legs=4):
        self.name = name
        self.age = age 
        self.no_legs = no_legs

    def talk(self):
        """ An instance method that says hi """
        print(f"Hi, my name is {self.name} and I am {self.age} years old.")
        print(f"I have {self.no_legs} legs.")

if __name__ == "__main__":
    pet_one = Pet("Fidget", 12)
    pet_two = Pet("Cuckoo", 5, 2)
    pet_one.talk()
    pet_two.talk()

Prints out:

Hi, my name is Fidget and I am 12 years old.
I have 4 legs.
Hi, my name is Cuckoo and I am 5 years old.
I have 2 legs.

2.3 Accessing Attributes

We can also access the name and age via the class instance (object).

print(pet_one.name) # prints Fidget
print(pet_one.age)  # prints 12

2.4 Editing Attributes

All attributes are public in Python and can be changed. We will talk about how to make something private later (in Python it is more obfuscated and not really private).

Here we amend the age attribute of pet_one.

class Pet:
    """ A pet class """
    def __init__(self, name, age):
        self.name = name
        self.age = age 

    def talk(self):
        print(f"Hi, my name is {self.name} and I am {self.age} years old.")

if __name__ == "__main__":
    pet_one = Pet("Fidget", 12)
    pet_one.talk()
    pet_one.age = 13
    pet_one.talk()
Hi, my name is Fidget and I am 12 years old.
Hi, my name is Fidget and I am 13 years old.

=== TASK ===

Create a class called Book.

Your class should have the following attributes:

Attribute NameDescription
titleTitle of the book
authorAuthor of the book
pricePrice of the book

And optional parameters:

Attribute NameDescriptionDefault Value
no_pagesNumber of pages in the bookNone
yearYear first publishedNone

It should also have a method called description() - HINT: Remember self.

When called, description() should return the description.

Here is an example of how Book should work.

Example 1

book_one = Book("1984", "George Orwell", 6.99)
print(book_one.description()) 

Should print:

Title: 1984
Author: George Orwell
Price: £6.99

Example 2

book_one = Book("1984", "George Orwell", 6.99, no_pages=328)
print(book_one.description()) 

Should print:

Title: 1984
Author: George Orwell
Price: £6.99
No. of Pages: 328

Example 3

book_one = Book("1984", "George Orwell", 6.99, year=1949)
print(book_one.description()) 

Should print:

Title: 1984
Author: George Orwell
Price: £6.99
Year Published: 1949

Example 4

book_one = Book("1984", "George Orwell", 6.99, no_pages=328, year=1949)
print(book_one.description()) 

Should print:

Title: 1984
Author: George Orwell
Price: £6.99
Year Published: 1949
No. of Pages: 328

Magic Methods

In Python methods that start with a double underscore (dunder) and end with a double underscore are known as magic methods or dunder methods.

These methods are not supposed to be used by the programmer, but they are used by Python for special operations on the class.

For example, when you add two numbers of type int, python invokes (calls) the __add__() method for the int class.

If you type dir(int) into the terminal you will get a list of all the method names defined in the int class:

['__abs__', '__add__', '__and__', '__bool__', '__ceil__', '__class__', '__delattr__', '__dir__', '__divmod__', '__doc__', '__eq__', '__float__', '__floor__', '__floordiv__', '__format__', '__ge__', '__getattribute__', '__getnewargs__', '__gt__', '__hash__', '__index__', '__init__', '__init_subclass__', '__int__', '__invert__', '__le__', '__lshift__', '__lt__', '__mod__', '__mul__', '__ne__', '__neg__', '__new__', '__or__', '__pos__', '__pow__', '__radd__', '__rand__', '__rdivmod__', '__reduce__', '__reduce_ex__', '__repr__', '__rfloordiv__', '__rlshift__', '__rmod__', '__rmul__', '__ror__', '__round__', '__rpow__', '__rrshift__', '__rshift__', '__rsub__', '__rtruediv__', '__rxor__', '__setattr__', '__sizeof__', '__str__', '__sub__', '__subclasshook__', '__truediv__', '__trunc__', '__xor__', 'as_integer_ratio', 'bit_length', 'conjugate', 'denominator', 'from_bytes', 'imag', 'numerator', 'real', 'to_bytes']

You can see that int has an __add__() method, this is the method that is actually invoked (called) when you use the + operator. e.g. 5 + 3.

We can even do this manually (try it in the terminal):

x = 5
x.__add__(3) # will return a new int object 8

What Python does is provide you with the + operator because it makes sense too, but really it is doing the above.

NOTE: You should never invoke (call) a magic method yourself, they are used by other methods or special symbols like +.

1. __init__() Method

We have already seen this method. This is the method that gets invoked when an instance of a class is created.

For example,

class Person:
  """ A simple person class """
  def __init__(self, name):
    self.name = name

if __name__ == "__main__":
  person_one = Person("Sarah") # Here we call __init__("Sarah") i.e. name = "Sarah"

Here Person("Sarah") creates an instance of the class by passing __init__() the value Sarah, this is then bound to name and in turn, this is then set as the instance attribute self.name.

2. __str__() Method

The __str__() method is the method called when we try and print an object. It returns a str (string).

class Person:
  """ A simple person class """
  def __init__(self, name):
    self.name = name

if __name__ == "__main__":
  person_one = Person("Sarah") # Here we call __init__("Sarah") i.e. name = "Sarah"
  print(person_one) # prints something like <__main__.Person object at 0x7fdf15fe3640>

The default printing of an object is just its name and the memory location of the object.

Python knows to call the __str()__ method, but we can do it manually:

print(person_one.__str__()) # prints something like <__main__.Person object at 0x7fdf15fe3640>

We can choose to overwrite this to print something more sensible.

class Person:
  """ A simple person class """
  def __init__(self, name):
    self.name = name

  def __str__(self):
    desc = "Instance of Person\n\n"
      desc +=  f"Name: {self.name}"
    return desc

if __name__ == "__main__":
  person_one = Person("Sarah") # Here we call __init__("Sarah") i.e. name = "Sarah"
  print(person_one)

Prints out:

Instance of Person

Name: Sarah

3. __eq__() Method

The __eq__() method is the method called when we use the == operator.

We can make use of this for our Person class. While this is a bit odd, it demonstrates how to use it. We will say an instance of a Person is equal to another instance if the name and age are the same in both instances.

class Person:
  """ A simple person class """
  def __init__(self, name, age):
    self.name = name
    self.age = age

  # test if the name and age are the same.
  def __eq__(self, other):
    # test if this instance (self) has the same name and age as other.
    if self.name == other.name and self.age == other.age:
      return True
    else:
      return False
    

if __name__ == "__main__":
  person_one = Person("Sarah", 27) 
  person_two = Person("Sarah", 29) 
  person_three = Person("Sarah", 27)
  print(person_one == person_two) # prints False because age is different
  print(person_one == person_three) # prints True because name and age are the same

4. __add()__ Method

The last method we will look at (we also started with it) is __add__() which is called when we use the + operator.

Again we can make use of this for our Person class. Again this is a bit odd, but it demonstrates how to use it. We will say that we add an instance of a Personwith another instance by adding the names and the ages and creating a new person.

Clearly this is nonsense, but it shows how we can add two instances of the same class and get a new instance of a the same class.

If we think about adding two ints, 1+2 will create a new int with value 3. This is essentially what we are doing here.

class Person:
  """ A simple person class """
  def __init__(self, name, age):
    self.name = name
    self.age = age

  # add the ages of this instance and other
  def __add__(self, other):
    name = self.name + other.name
    age = self.age + other.age
    return Person(name, age)   # clearly this is nonsense, but you are returning a new Person object by adding the other two person objects

if __name__ == "__main__":
  person_one = Person("Sarah", 27) 
  person_two = Person("Bob", 35) 
  print(person_one + person_two) # prints 62

List of all Built-in Methods

You can find a comprehensive list of magic methods via the following link:

Tutorials Teacher - Magic Methods

=== TASK ===

Create a class called Point.

Point should have two attributes:

Attribute NameDescription
xx-coordinate of the point
yy-cordinate of the point

Make sure the attributes are named as per the table.

You should override the __init__(), __str__(), __add__(), __sub__() and __mul__() methods.


__init__() Method

You should be able to create a new point as follows.

point_one = Point(3,2) # creates a point with x=3, y=2

__str__() Method

You should be able to print out a point as follows.

point_one = Point(3,2) # creates a point with x=3, y=2
print(point_one)

Should print:

Instance of Point

x: 3
y: 2

__add__() Method

You should be able to add two points as follows. NOTE: This should return a new Point object.

point_one = Point(3,2) # creates a point with x=3, y=2
point_two = Point(5,3) # creates a point with x=5, y=3
point_three = point_one + point_two # creates a new instance of point (object) x=8, y=5, i.e. Point(8,5)
print(point_three)

Should print:

Instance of Point

x: 8
y: 5

__sub__() Method

You should be able to subtract two points as follows. NOTE: This should return a new Point object.

point_one = Point(3,2) # creates a point with x=3, y=2
point_two = Point(5,3) # creates a point with x=5, y=3
point_three = point_one - point_two # creates a new instance of point (object) x=-2, y=-1, i.e. Point(-2,-1)
print(point_three)

Should print:

Instance of Point

x: -2
y: -1

__mul__() Method

You should be able to multiply two points as follows. NOTE: This should return a new Point object.

point_one = Point(3,2) # creates a point with x=3, y=2
point_two = Point(5,3) # creates a point with x=5, y=3
point_three = point_one * point_two # creates a new instance of point (object) x=15, y=6, i.e. Point(15,6)
print(point_three)

Should print:

Instance of Point

x: 15
y: 6

__eq__() Method

You should be able to test if two points are equal as follows. NOTE: This should return a new bool object.

point_one = Point(3,2) # creates a point with x=3, y=2
point_two = Point(5,2) # creates a point with x=5, y=2
point_three = Point(3,2) # creates a point with x=3, y=2
print(point_one == point_two) # prints False because x is different
print(point_one == point_three) # prints True because x and y are the same

Getting Started

You can copy and paste the following into a new Python file to get started.

class Point:
  """ A simple representation of a point in 2d space"""
  pass


if __name__ == "__main__":
  point_one = Point(3,2)
  print(point_one)
  print()
  point_two = Point(5,3)
  print(point_two)
  print()
  point_three = point_one + point_two
  print(point_three)
  print()
  point_four = point_one - point_two
  print(point_four)
  print()
  point_five = point_one * point_two
  print(point_five)
  print()
  print(point_one == point_two) 

If your class is implemented correctly it will print out the following.

Instance of Point

x: 3
y: 2

Instance of Point

x: 5
y: 3

Instance of Point

x: 8
y: 5

Instance of Point

x: -2
y: -1

Instance of Point

x: 15
y: 6

False

References

Tutorials Teacher - Magic Methods

Class Attributes vs Instance Attributes

So far our attributes have belonged to the instance of a class (object).

We can do something else and make an attribute that belongs to the class (type) itself.

1. Instance Attributes

Instance attributes are those that belong to the instance of a class (object). An instance attribute is attached to the instance, by convention, using the word self.

There are used to access the data within a given instance of the class.

You will also hear them referred to as member variables or member fields, especially in other languages.

For example:

class Pet:
  """ A pet class """

  def __init__(self, name, age):
    self.name = name
    self.age = age

if __name__ == "__main__":
  pet_one = Pet("Fidget", 12)
  pet_two = Pet("Rex", 5)

  # access the pet_one instance attribute name
  print(pet_one.name)  # prints Fidget
  print(vars(pet_one)) # print pet_one instance attributes
  print(vars(pet_two)) # print pet_two instance attributes

Prints out:

Fidget
{'name': 'Fidget', 'age': 12}
{'name': 'Rex', 'age': 5}

You will notice that we printed out vars(pet_one) and vars(pet_two).

vars() returns the __dict__ (dictionary mapping) attribute of the given object. __dict__ is a dictionary that stores the attributes associated with the instance of the class (object). So pet_one.__dict__ stores the attributes associated with pet_one and pet_two.__dict__ stores the attributes associated with pet_two.

You can see that both contain name and age but they have different data because they are separate instances of the class Pet.

2. Class Attributes

As well as having attributes that belong to instances of the class, we can also have attributes that belong to the class.

This simple example now defines a no_pets attribute that belongs to the class, it will track how many instances have been created, i.e. the number of pets.

Notice that it is not attached using self.

class Pet:
  """ A pet class """
  # define a class variable
  no_pets = 0  # number of pets

  def __init__(self, name, age):
    self.name = name
    self.age = age
    # increment class attribute by 1
    Pet.no_pets += 1


if __name__ == "__main__":
  pet_one = Pet("Fidget", 12)
  pet_two = Pet("Rex", 5)

  # access the class attribute directly
  print(Pet.no_pets)  # prints 2

  pet_three = Pet("Indie", 7)

  # access the class attribute via instance
  print(pet_one.no_pets) # prints 3
  print(pet_two.no_pets) # prints 3
  # access the class attribute directly
  print(Pet.no_pets)  # prints 3

Prints out:

2
3
3
3

Each time a new instance is created, the __init__() method (constructor) is called. This then increments the class attribute by 1 using Pet.no_pets += 1. Notice that instead of using self, we used the class name Pet.

The example above demonstrates that you can access a class attribute via either an instance or the class itself.

We can list all the attributes and methods of the class using vars().

print(vars(Pet))

You will see this has an attribute no_pets, confirming it is a class attribute. You can look up the mappingproxy, but it is sort of a wrapped read-only version of the dictionary. Just see it as a dictionary unless you are overly interested.

mappingproxy({'__module__': '__main__', '__doc__': ' A pet class ', 'no_pets': 3, '__init__': <function Pet.__init__ at 0x7ff0bd764b80>, '__dict__': <attribute '__dict__' of 'Pet' objects>, '__weakref__': <attribute '__weakref__' of 'Pet' objects>})

3. Instance Namespace vs Class Namespace

Beneath the hood, Python is using something called namespaces.

"A namespace is a mapping from names to objects. Most namespaces are currently implemented as Python dictionaries, but that’s normally not noticeable in any way (except for performance), and it may change in the future.

Examples of namespaces are the set of built-in names (containing functions such as abs(), and built-in exception names); the global names in a module; and the local names in a function invocation. In a sense, the set of attributes of an object also forms a namespace.

The important thing to know about namespaces is that there is no relation between names in different namespaces; for instance, two different modules may both define a function maximize without confusion — users of the modules must prefix it with the module name."

Python Scopes and Namespaces

In the following example, we access the class attribute no_pets via the instance and the class.

class Pet:
  """ A pet class """
  # define a class variable
  no_pets = 0  # number of pets

  def __init__(self, name):
    self.name = name
    # increment class attribute by 1
    Pet.no_pets += 1


if __name__ == "__main__":
  pet_one = Pet("Fidget", 12)
  # access the class attribute via instance
  print(pet_one.no_pets)
  # access the class attribute directly
  print(Pet.no_pets)  # prints 3

What is happening here is that when we try and access it using pet_one.no_pets, Python looks in the instance namespace, that is it looks in pet_one.__dict__. When it fails to find this, it then looks in the class namespace, that is Pet.__dict__.

The instance namespace always takes precedence over the class namespace.

Do not do the following, it is just an example to illustrate a point.

We can do the following, define a class attribute and an instance attribute with the same name, e.g. name.

class Pet:
  """ A pet class """
  # define a class variable
  name = "Pet"  # number of pets

  def __init__(self, name):
    self.name = name
    # increment class attribute by 1


if __name__ == "__main__":
  pet_one = Pet("Fidget")
  # access the class attribute via instance
  print(pet_one.name)
  # access the class attribute directly
  print(Pet.name) 

Which will print:

Fidget
Pet

This is because the instance pet_one looks at its namespace first and finds name, so it uses that.

You can check by printing out the instance and the class namespaces.

print(vars(pet_one))

Prints outs,

{'name': 'Fidget'}

and,

print(vars(Pet))

prints out:

mappingproxy({'__module__': '__main__', '__doc__': ' A pet class ', 'name': 'Pet', '__init__': <function Pet.__init__ at 0x7fa1c82aeaf0>, '__dict__': <attribute '__dict__' of 'Pet' objects>, '__weakref__': <attribute '__weakref__' of 'Pet' objects>})

=== TASK ===

Create a class called Circle.

Your class should have one instance attribute named radius.

It should also have a class attribute called pi set to a value of 3.14159.

print(Circle.pi) # prints 3.14159

Also, create two methods called area() and circumference().

area() Method

You can calculate the area using pi * radius**2. Remember that pi will be a class attribute and radius an instance attribute, so you will have to amend the formula appropriately.

circle_one = Circle(10)    # create an instance of a Circle with radius 10
print(circle_one.area())
circle_two = Circle(5)     # create an instance of a Circle with radius 5
print(circle_two.area()) 
314.159
78.53975

circumference() Method

You can calculate the area using 2 * pi * radius. Again, remember that pi will be a class attribute and radius an instance attribute, so you will have to amend the formula appropriately.

circle_one = Circle(10)    # create an instance of a Circle with radius 10
print(circle_one.circumference())
circle_two = Circle(5)     # create an instance of a Circle with radius 5
print(circle_two.circumference()) 
62.8318
31.4159

Getting Started

You can get started by copying and pasting the following into a new Python file.

class Circle:
  pass

if __name__ == "__main__":
  c1 = Circle(10)        # create an instance of a Circle with radius 10
  print(c1.area())
  print(c1.circumference())
  
  c2 = Circle(5)        # create an instance of a Circle with radius 5
  print(c2.area())
  print(c2.circumference())

Instance Methods vs Static Methods

To date we have been using instance methods, these methods are bound to an instance of a class (object) and can access the attributes attached to that instance.

In this lesson, we will take a look at another type, static methods.

Instance Methods

Instance methods are methods that are bound to a given instance of a class (object) and therefore have access to the instance data, the data attached to self.

The first parameter, by convention, should be self which is a reference to the instance.

For example,

class Pet:
  """ A pet class """

  def __init__(self, name, age):
    ## instance attributes
    self.name = name
    self.age = age

  # instance method, self should be the first parameter
  def talk(self):
    print(f"Hi, my name is {self.name} and I am {self.age} years old.")

if __name__ == "__main__":
  pet_one = Pet("Fidget", 17)
  pet_one.talk()

will print out:

Hi, my name is Fidget and I am 17 years old.

We can also have other parameters in a method, but self is still the first parameter. For example, we can add another parameter n to talk(), this will actually be the first parameter of the method call.

For example,

class Pet:
  """ A pet class """

  def __init__(self, name, age):
    ## instance attributes
    self.name = name
    self.age = age

  # instance method, self should be the first parameter
  def talk(self, n):
    for _ in range(n):
      print(f"Hi, my name is {self.name} and I am {self.age} years old.")

if __name__ == "__main__":
  pet_one = Pet("Fidget", 17)
  pet_one.talk(3) # call the method pet_one with n=3

will print out:

Hi, my name is Fidget and I am 17 years old.
Hi, my name is Fidget and I am 17 years old.
Hi, my name is Fidget and I am 17 years old.

We say talk() takes 1 parameter, self is not included.

Static Methods

A static method is a method that does not have access to the instance attributes, but it can access a class attribute (read-only). It is bound to the class. Therefore, we do not need an instance of the class to use the method

Here we have a single class attribute no_pets that tracks the number of pets (instances) of Pet created. We also create a static method called print_no_pets, which uses the class attribute.

class Pet:
  """ A pet class """
  # define a class attribute
  no_pets = 0
  def __init__(self, name, age):
    self.name = name
    self.age = age
    # increment class attribute by 1
    Pet.no_pets += 1

  @staticmethod
  def print_no_pets():
    print(f"There are {Pet.no_pets} pets.")

if __name__ == "__main__":
  # call the static method on the class
  Pet.print_no_pets() # prints There are 0 pets.
  pet_one = Pet("Fidget", 12)
  pet_two = Pet("Rex", 5)
  pet_three = Pet("Indie", 7)
  # call the static method on the class
  Pet.print_no_pets() # prints There are 3 pets.
  # call the static method via the instance 
  pet_one.print_no_pets() # prints There are 3 pets.

You will notice that we have used the decorator @staticmethod. This tells the method that the first parameter should not be treated as the instance reference (normally self).

We also did not need an instance of the class to call the method, but we could also call the method using an instance. This is the same as with class attributes, please read the part about instance and class namespaces in the Class Attribute lesson.

Here is another example of a Mathematics class, normally we would do this as a module in Python, but in other languages such as Java and C#, classes like this with static methods are quite common. Normally referred to as a utility (helper) class.

It is also a pretty useless class as all of these are built into Python.

The point is we do not need an instance of the class to use it!

class Mathematics:
  @staticmethod
  def add(x,y):
    return x + y
  
  @staticmethod
  def sub(x,y):
    return x - y

  @staticmethod
  def pow(x,n):
    return x**n

if __name__ == "__main__":
  print(Mathematics.add(10,5)) # prints 15
  print(Mathematics.sub(10,5)) # prints 5
  print(Mathematics.pow(10,3)) # prints 100

=== TASK ===

Create a class called Person. This should have a first_name, surname and age attribute.

Create a static method called format_name_comma(). The reason we want this to be static is we can imagine that it has a use even when we don't have an instance of the class.

You should be able to invoke format_name_comma() as follows:

print(Person.format_name_comma("Joe", "Bloggs")) # prints Bloggs, Joe

You should also implement the __str__() method which should return the formatted name using the instance attributes first_name and surname. Really you should reuse the static method format_name_comma() from within __str__().

You should then be able to run the following code.

person_one = Person("Joe", "Bloggs", 27)
print(person_one) # prints Bloggs, Joe

Getting Started

You can copy and paste the following into a new Python file to get started.

class Person:
  pass

if __name__ == "__main__":
  print(Person.format_name_comma("Joe", "Bloggs")) # prints Bloggs, Joe
  person_one = Person("Joe", "Bloggs", 27)
  print(person_one) # prints Bloggs, Joe

The Critter Program

The following is a very simple program that demonstrates creating an instance of a class.

The class also contains:

  • __init__() - constructor for the class.
  • total - a class attribute which tracks the number of instances of the class created.
  • status - a static method that prints out how many instances of the class are created using the class attribute total.
class Critter:
  """A virtual critter class"""
  total = 0

  def __init__(self, name):
   print("A critter has been born!")
   self.name = name
   Critter.total += 1

  # this stops the first argument being the instance
  @staticmethod
  def status():
    print("\nThe total number of critters is", Critter.total)

def main():
  print("Accessing the class attribute Critter.total:", end=" ")
  print(Critter.total)
  print("\nCreating critters.")
  crit_one = Critter("critter 1")
  crit_two = Critter("critter 2")
  crit_three = Critter("critter 3")
  Critter.status()
  print("\nAccessing the class attribute through an object:", end= " ")
  print(crit_one.total)
  input("\n\nPress the enter key to exit.")
  

if __name__ == "__main__":  
  main()

Encapsulation

In this lesson, we will learn about encapsulation and how to implement it in Python. Python differs from C-style languages in its implementation of this concept.

We will explore:

  • Encapsulation
  • Private Attributes and Private Methods
  • Getters and Setters
  • Properties

The Python way to do things now is properties, so please read the guidance at the end of the lesson on how to design classes.

1. Encapsulation

We say that an instance of a class (object) “can take care of itself”. In other words, you can ask an instance to do things (by invoking its methods), but you don’t have to know or care how the instance does it. You communicate with the instance – i.e., tell it to do things, or retrieve or change its data – through public methods and (in C# and some but not all other object-oriented languages) properties. The internal implementation details of the instance are kept hidden (i.e. private). This hiding of the internal implementation and data is often known as encapsulation.

Python has its interpretation and implementation of this concept. Different, but neither right nor wrong!

You should imagine your class as having publicly exposed things that let you interact with an instance. This is known as the classes API (application programming interface).

1.1 An Insightful Analogy

An analogy is that you have 5 senses that allow you to interact with the world, for example, I can communicate with you with sound (ear) or visually (eyes), but I can't directly access your brain. In a way, your senses are like public methods exposed to the outside world, but your brain and other internal parts are private, they are used internally by your body.

Public and Private Methods

2. Private Attributes and Private Methods

In Python there is no such thing as privacy, everything is public, and you can always access it. So no point in this section right?

Well not quite. Python has two ways of doing this.

  1. Use one leading underscore only for non-public methods and instance variables.
  2. To avoid name clashes with subclasses, use two leading underscores to invoke Python’s name-mangling rules.

No 2. does not make sense in this unit as we have not yet introduced inheritance. We will come back to this when we do.

2.1 Single Leading Underscore (Attribute)

By convention when you want to indicate that an attribute or method should only be used within a class itself, you name it with a single leading underscore.

This is directly from the official Python documentation.

"a name prefixed with an underscore (e.g. _spam) should be treated as a non-public part of the API"

Python Docs - Classes

Here is an example that represents a student that uses a single leading underscore to indicate that _first_name, _surname and _id are private and should not be accessed or edited outside the class. We leave age and email as public as we want to access these from outside the class.

We also include a public method (no leading underscore) that returns the full name of the student.

class Student:
  def __init__(self, first_name, surname, age, email, id):
    self._first_name = first_name
    self._surname = surname
    self.age = age
    self.email = email
    self._id = id  
  
  def get_full_name(self):
    return f"{self._first_name} {self._surname}"
    
if __name__ == "__main__":
  student_one = Student("Ada", "Lovelace", 36, "a.lovelace@derby.ac.uk", "10010101")

  # DO NOT DO THIS! You can, but the convention is this is now a non-public (private) attribute
  print(student_one._id) # prints 10010101
  print(student_one._first_name) # prints Ada

  # DO NOT DO THIS! You can, but now you are messing with something that should only be messed with inside the class!
  student_one._id = "12345678" # overwrites the non-public (private) attribute 

  print(student_one.get_full_name())

Here the public API for the class is the instance attributes age and email and the instance method get_full_name().

Question: Should age be public?

3. Getters and Setters

How do we give access to our private attributes then?

The answer in most languages is to use getters (accessors) and setters (mutators). These are methods that get the value of our private attribute and set the value of our private attribute. They do this internally.

The following is an example of a class that represents a food item on a menu for a restaurant.

Here we are happy to let the title be updated and thus we leave it public. However, we have a condition on price, that is it cannot be less than a pound.

We can create a getter and setter method that manages a non-public (private) attribute _price.

We can access _price via the method get_price() and we can set the _price via the method set_price().

set_price() takes in a single parameter value (remember self is just the instance reference) and checks the value to see if it is less than 1, if it is it raises a ValueError.

class FoodItem:
  def __init__(self, title, price):
    self.title = title
    # use the setter to set the _price
    self.set_price(price)

  # getter
  def get_price(self):
    print("Getting value...")
    return self._price

  # setter
  def set_price(self, value):
    print("Setting value...")
    if value < 1:
        raise ValueError("Cannot set a price less than 1")
    self._price = value

if __name__ == "__main__":
  food_one = FoodItem("Smoked Salmon on Toast", 8.99)
  print(food_one.get_price())
  food_one.set_price(6.99) # getting cheaper
  print(food_one.get_price())
  food_one.set_price(0.5) # too cheap, raises an error!
  print(food_one.get_price())

will print out:

Setting value...
Getting value...
8.99
Setting value...
Getting value...
6.99
Setting value...
Traceback (most recent call last):
  File "main.py", line 24, in <module>
    food_one.set_price(-2) # too cheap, raises an error!
  File "main.py", line 16, in set_price
    raise ValueError("Cannot set a negative price")
ValueError: Cannot set a negative price

This demonstrates the nature of encapsulation, we control the private variables through the public getters and setters. This means that if we change things in the future. People interacting with our class can still just call get_price() and set_price().

For example, we decide to lower our limit on the price to 0. We update our set_price() method as follows:

  # setter
  def set_price(self, value):
    print("Setting value...")
    if value < 0:
        raise ValueError("Cannot set a negative price")
    self._price = value

The code would now print out:

Setting value...
Getting value...
8.99
Setting value...
Getting value...
6.99
Setting value...
Getting value...
0.5

4. Properties

Python allows us to do something much neater using properties.

Here we can create a property with our getter and setter methods that act like an attribute.

class FoodItem:
  def __init__(self, title, price):
    self.title = title
    # use the property (actually calls the method set_price) to set the _price
    self.price = price

  # getter
  def get_price(self):
    print("Getting value...")
    return self._price

  # setter
  def set_price(self, value):
    print("Setting value...")
    if value < 1:
        raise ValueError("Cannot set a price less than 1")
    self._price = value

  # creating a property object, this is key
  price = property(get_price, set_price)


if __name__ == "__main__":
  food_one = FoodItem("Smoked Salmon on Toast", 8.99)
  # we can now use price like an attribute
  # it is actually calling get_price()
  print(food_one.price)
  # we can now use price like an attribute
  # it is actually calling set_price()
  food_one.price = 6.99 # getting cheaper
  print(food_one.price)
  food_one.price = 0.5 # too cheap, raises an error!
  print(food_one.price)
  

Now food_one.price looks and acts like an attribute returning a value, but it is calling get_value(). Similarly, food_one.price = 6.99 is acting like an attribute, but it is really calling set_value(6.99).

4.1 The Python Way (Decorators)

Python lets us do this in a more Pythonic way with a decorator.

We call our getter and setter the same as the attribute. For example, our private attribute is _price, so our getter and setter are both called price. We also put @property above the getter and @price.setter above the setter.

Make sure you look at the code in if __name__ == "__main__" as we can now access our getters and setters using .price as if they are attributes! e.g. food_one.price = 10.99. As far as the person using the class is concerned, they just use them as attributes with no idea of the internal working.

The beauty of this is that you can internally change your properties (getter/setter methods) and not affect the code that uses them. They still access them with the attribute name, e.g. .price.

class FoodItem:
  def __init__(self, title, price):
    self.title = title
    # use the property (actuall calls the method set_price) to set the _price
    self.price = price

  # getter use the name of the attribute
  @property
  def price(self):
    print("Getting value...")
    return self._price

  # setter uses the name of the attribute
  @price.setter
  def price(self, value):
    print("Setting value...")
    if value < 0:
        raise ValueError("Cannot set a negative price")
    self._price = value

if __name__ == "__main__":
  food_one = FoodItem("Smoked Salmon on Toast", 8.99)
  print(food_one.price)
  food_one.price = 6.99 # getting cheaper
  print(food_one.price)
  food_one.price = 0.5 # too cheap, raises an error!
  print(food_one.price)

What is really cool is that you can use the += and -= operators.

Try this out:

if __name__ == "__main__":
  food_one = FoodItem("Smoked Salmon on Toast", 8.99)
  print(food_one.price)
  food_one.price += 1 # getting cheaper
  print(food_one.price)
  food_one.price += -10
  print(food_one.price)

You will not get the following.

Setting value...
Getting value...
8.99
Getting value...
Setting value...
Getting value...
9.99
Getting value...
Setting value...
Traceback (most recent call last):
  File "main.py", line 26, in <module>
    food_one.price += -10
  File "main.py", line 18, in price
    raise ValueError("Cannot set a negative price")
ValueError: Cannot set a negative price

We can also create properties that don't directly manage a single attribute. For example,

class Student:
  def __init__(self, first_name, surname, age, email, id):
    self._first_name = first_name
    self._surname = surname
    self.age = age
    self.email = email
    self._id = id  

  # this is now a property that we can access with .full_name
  @property
  def full_name(self):
    return f"{self._first_name} {self._surname}"
    
if __name__ == "__main__":
  student_one = Student("Ada", "Lovelace", 36, "a.lovelace@derby.ac.uk", "10010101")

  # DO NOT DO THIS! You can, but the convention is this is now a non-public (private) attribute
  print(student_one._id) # prints 10010101
  print(student_one._first_name) # prints Ada

  # DO NOT DO THIS! You can, but now you are messing with something that should only be messed with inside the class!
  student_one._id = "12345678" # overwrites the non-public (private) attribute 

  # print the full name via the property
  print(student_one.full_name)

5. Guidance for Designing Classes

The following guidance is given on Real Python.

Because of properties, Python developers tend to design their classes’ APIs using a few guidelines:

  • Use public attributes whenever appropriate, even if you expect the attribute to require functional behaviour in the future.
  • Avoid defining setter and getter methods for your attributes. You can always turn them into properties if needed.
  • Use properties when you need to attach behaviour to attributes and keep using them as regular attributes in your code.
  • Avoid side effects in properties because no one would expect operations like assignments to cause any side effects. By side effects we mean slow operations, you expect an assignment like food_one.price = 10.99 to be instantaneous. For slow operations use getters and setters.

=== TASK ==

Create a class called Position that manages the position x and y.

Your constructor should take in the initial position of x and of y and upper limits for x and y and then use properties to manage x and y so that they cannot be set above these limits. Note you will need a property (getter/setter) for both x and y.

If an attempt to assign a value above the limit is made then it should raise a ValueError.

Use the following examples as a reference. Note the message of the ValueError in both.

p = Position(0,0,10,10) # x=0, y=0, upper limits of 10 and 10
print(f"x={p.x} and y={p.y}") # prints x=0 and y=0
p.x = 2
print(f"x={p.x} and y={p.y}") # prints x=2 and y=0
p.y += 3 
print(f"x={p.x} and y={p.y}") # prints x=2 and y=3
p.x = 11 # raises ValueError: x cannot be bigger than 10
p = Position(0,0,10,15) # x=0, y=0, 
print(f"x={p.x} and y={p.y}") # prints x=0 and y=0
p.x = 2
print(f"x={p.x} and y={p.y}") # prints x=2 and y=0
p.y += 3 
print(f"x={p.x} and y={p.y}") # prints x=2 and y=3
p.y += 13 # raises ValueError: y cannot be bigger than 15

Getting Started

You can copy and paste the following into a new Python file to get started.

class Position:
  pass

if __name__ == "__main__":
  p = Position(0,0,10,10) # x=0, y=0, 
  print(f"x={p.x} and y={p.y}") # prints x=0 and y=0
  p.x = 2
  print(f"x={p.x} and y={p.y}") # prints x=2 and y=0
  p.y += 3 
  print(f"x={p.x} and y={p.y}") # prints x=2 and y=3
  p.x = 11 # raises ValueError: x cannot be bigger than 10

References

Python Docs - Classes

Real Python

Programiz - Python Properties

The Critter Caretaker Program

This program is from Chapter 8: Python Programming for the Absolute Beginner, Third Edition - Michael Dawson. It has some minor amendments.

It extends the previous critter program to allow us to interact with the critter. The idea is that the critter can get hungry and bored which makes them frustrated or mad.

We can then feed or play with our critter to improve its hunger and boredom to improve its mood.

Our new Critter class adds instance attributes hunger and boredom to each critter, this allows them to have state. We can then manage and interact with this state through our program.

Create a file called critter.py and copy in the following:

#critter.py
# A virtual pet to care for
class Critter(object):
  """A virtual pet"""

  def __init__(self, name, hunger=0, boredom=0):
    self.name = name
    self.hunger = hunger
    self.boredom = boredom

  def __str__(self):
    desc = ""
    desc += f"Name: {self.name}\n"
    desc += f"Hunger: {self.hunger}\n"
    desc += f"Boredom: {self.boredom}\n"
    return desc

  # indicate that the method should only be used internally
  # using a single leading underscore
  def _pass_time(self):
    self.hunger += 1
    self.boredom += 1

  # indicate that the method should only be used internally
  # using a single leading underscore
  def _mood(self):
    unhappiness = self.hunger + self.boredom
    if unhappiness < 5:
      m = "happy"
    elif 5 <= unhappiness <= 10:
      m = "okay"
    elif 11 <= unhappiness <= 15:
      m = "frustrated"
    else:
      m = "mad"
    return m

  def talk(self):
    print(f"I'm {self.name} and I feel {self._mood()} now.\n")
    self._pass_time()

  def eat(self, food=5):
    print("Brruppp. Thank you.")
    self.hunger -= food
    if self.hunger < 0:
      self.hunger = 0
    self._pass_time()

  def play(self, fun=5):
    print("Wheee!")
    self.boredom -= fun
    if self.boredom < 0:
      self.boredom = 0
    self._pass_time()

You will see a bunch of instance methods that allow us to interact with our critter. You should spend some time trying to understand what this class is doing.

Our main.py file contains the following:

from critter import Critter

def main():
  crit_name = input("What do you want to name your critter?: ")
  crit = Critter(crit_name)

  choice = None
  while choice != "0":
    print \
    ("""
    Critter Caretaker
    0 - Quit
    1 - Listen to your critter
    2 - Feed your critter
    3 - Play with your critter
    4 - Print Critter Stats (does not pass time)
    """)
  
    choice = input("Choice: ")
    print()
    # exit
    if choice == "0":
      print("Good-bye.")
    # listen to your critter
    elif choice == "1":
      crit.talk()
    # feed your critter
    elif choice == "2":
      crit.eat()
    # play with your critter
    elif choice == "3":
      crit.play()
    # print critter stats
    elif choice == "4":
      print(crit)
    # some unknown choice
    else:
      print(f"\nSorry, but {choice} isn't a valid choice.")

if __name__ == "__main__":
  main()

The Critter Zoo Program

This program extends the previous critter caretaker program to allow us to manage lots of critters, we create a critter zoo!

We can then feed or play with our critters to improve their hunger and boredom to improve their mood.

Our new CritterZoo class has an attribute called critters which is a dictionary that stores each of the Critter instances.

We can then interact with individual Critter instances through this dictionary.

In the next unit, we will see a different way of doing this by extending Python's built-in dictionary type dict.

Create a file called critter.py and copy in the following:

# critter.py
# A virtual pet to care for
class Critter:
  """A virtual pet"""

  total = 0

  def __init__(self, name, hunger=0, boredom=0):
    self.name = name
    self.hunger = hunger
    self.boredom = boredom
    Critter.total += 1

  def __str__(self):
    desc = ""
    desc += f"Name: {self.name}\n"
    desc += f"Hunger: {self.hunger}\n"
    desc += f"Boredom: {self.boredom}\n"
    return desc
  
  def pass_time(self):
    self.hunger += 1
    self.boredom += 1
    if self.hunger > 16:
      self.hunger = 16
    if self.boredom > 16:
      self.boredom = 16

  # indicate that the method is internal (non-public)
  def _mood(self):
    unhappiness = self.hunger + self.boredom
    if unhappiness < 5:
      m = "happy"
    elif 5 <= unhappiness <= 10:
      m = "okay"
    elif 11 <= unhappiness <= 15:
      m = "frustrated"
    else:
      m = "mad"
    return m

  def talk(self):
    print(f"I'm {self.name} and I feel {self._mood()} now.\n")

  def eat(self, food=5):
    print("Brruppp. Thank you.")
    self.hunger -= food
    if self.hunger < 0:
      self.hunger = 0

  def play(self, fun=5):
    print("Wheee!")
    self.boredom -= fun
    if self.boredom < 0:
      self.boredom = 0

class CritterZoo:
  """ A critter zoo"""
  def __init__(self, no_critters):
    self.critters = {}
    for i in range(no_critters):
      crit = Critter(f"Critter {i+1}")
      self.critters[f"Critter {i+1}"] = crit

  def __str__(self):
    returnstring = ""
    for critter in self.critters.values():
      returnstring += critter.__str__()
    return returnstring
  
  def feed_critter(self, name):
    self.critters[name].eat()
    self._pass_time()

  def play_with_critter(self, name):
    self.critters[name].play()
    self._pass_time()

  def list_critter_names(self):
    for name in self.critters.keys():
      print(name)

  def list_critter_moods(self):
    for critter in self.critters.values():
      critter.talk()
    self._pass_time()

  # indicate that the method is internal (non-public)
  def _pass_time(self):
    for critter in self.critters.values():
      critter.pass_time()

Take some time to look at the CritterZoo class and some of the amendments made to the Critter class. For, example pass_time() is now public and is called from within the CritterZoo class so that we can pass the time for all of the critters in one go.

Our main.py file contains the following:

# main.py
# Critter Zoo
from critter import CritterZoo

def main():
  no_critters = input("How many critters would you like?: ")
  zoo = CritterZoo(int(no_critters))

  choice = None
  while choice != "0":
    print \
    ("""
    Critter Caretaker
    0 - Quit
    1 - Listen to your critters
    2 - Feed a critter
    3 - Play with a critter
    4 - List critters (does not pass time)
    """)
  
    choice = input("Choice: ")
    print()
    # exit
    if choice == "0":
      print("Good-bye.")
    # listen to your critter
    elif choice == "1":
      zoo.list_critter_moods()
    # feed your critter
    elif choice == "2":
      print("Which critter do you want to feed?")
      zoo.list_critter_names()
      crit_num = input("Please enter the number you wish to feed.\n")
      zoo.feed_critter(f"Critter {crit_num}")
    # play with your critter
    elif choice == "3":
      print("Which critter do you want to play with?")
      zoo.list_critter_names()
      crit_num = input("Please enter the number you wish to play with.\n")
      zoo.play_with_critter(f"Critter {crit_num}")
    # list critters
    elif choice == "4":
      print(zoo)
    else:
      print(f"\nSorry, but {choice} isn't a valid choice.")

if __name__ == "__main__":
  main()

Unit 10 - Object Oriented Programming 2 (OOP)

The following unit is about some more advanced features of object-oriented programming (OOP).

It will cover:

  1. Sending and Receiving Messages
  2. Combining Objects
  3. Inheritance
  4. Polymorphism
  5. Modules and Classes

There will also be a follow along project that builds an OOP version of Blackjack.

Sending and Receiving Messages

So far we have had classes that sort of don't interact with each other.

Now we will start to get these classes to combine into a sort of ecosystem of objects that talk to each other.

We will start with a simple example based on the Critter Zoo Program.

We will first create a simple class called Critter which will contain instance attributes name and no_feed (number of times fed), and an instance method feed().

class Critter:
  """ A very simple critter class"""
  def __init__(self, name):
    self.name = name
    self.no_feeds = 0

  def __str__(self):
    desc = ""
    desc += "Instance of Critter\n"
    desc += f"Name: {self.name}\n"
    desc += f"Fed: {self.no_feeds} times\n"
    return desc

  def feed(self):
    print("Yummy! Thanks! Brruppp...")
    self.no_feeds += 1

We will also create a class called Caretaker which represents a person who looks after critters.

class Caretaker:
  """ A very simple critter class"""
  def __init__(self, name):
    self.name = name

  def __str__(self):
    desc = ""
    desc += "Instance of Caretaker\n"
    desc += f"Name: {self.name}\n"
    return desc

  def feed_criter(self, critter):
    critter.feed()

The key method here is feed_critter(), this is now a method that takes in an instance of Critter and then feeds the little thing by calling the instance method feed().

You can think about it like this, feed_critter() takes in a Critter instance and then sends a message to the Critter instance by invoking (calling) its method feed(). The Critter instance in turn receives the message because its method has been invoked, it will then run whatever code is in feed().

Here is the whole program in action to show you how it works.

class Critter:
  """ A very simple critter class"""
  def __init__(self, name):
    self.name = name
    self.no_feeds = 0

  def __str__(self):
    desc = ""
    desc += "Instance of Critter\n"
    desc += f"Name: {self.name}\n"
    desc += f"Fed: {self.no_feeds} times\n"
    return desc

  def feed(self):
    print("Yummy! Thanks! Brruppp...")
    self.no_feeds += 1

class Caretaker:
  """ A very simple critter class"""
  def __init__(self, name):
    self.name = name

  def __str__(self):
    desc = ""
    desc += "Instance of Caretaker\n"
    desc += f"Name: {self.name}\n"
    return desc

  def feed_criter(self, critter):
    critter.feed()

if __name__ == "__main__":
  alan = Critter("Alan")
  ava = Critter("Ava")
  
  print(alan) # print out alan
  print(ava) # print out ava
  
  bob = Caretaker("Bob")
  bob.feed_criter(alan) # feed alan
  bob.feed_criter(alan) # feed alan
  bob.feed_criter(alan) # feed alan
  bob.feed_criter(ava) # feed ava
  
  print()

  print(alan) # print out alan
  print(ava) # print out ava
  

And the output.

Instance of Critter
Name: Alan
Fed: 0 times

Instance of Critter
Name: Ava
Fed: 0 times

Yummy! Thanks! Brruppp...
Yummy! Thanks! Brruppp...
Yummy! Thanks! Brruppp...
Yummy! Thanks! Brruppp...

Instance of Critter
Name: Alan
Fed: 3 times

Instance of Critter
Name: Ava
Fed: 1 times

We can see that we have asked the instance of Caretaker, bob, to do the feeding. We pass in either alan or ava, and bob will feed them by invoking (calling) their feed() method.

=== TASK ===

Create a Plumber class and a Roomba class.

You might want to look at a Goomba...

An instance of Plumber should be able to squash the Roomba.

The Roomba class should have an attribute name and an instance method called squish(). It should also have a non-public attribute _squashed which should be set to False when the instance is created (i.e. in the __init__() method).

Invoking the method squish() should set _squashed to True.

You should also override the __str__() method so that it prints out Hi my name is NAME and I am feeling fine or Hi my name is NAME and I am squashed depending on the value of _squashed.

hetti = Roomba("Hetti")
print(hetti) # Hi my name is Hetti and I am feeling fine
hetti.squish() 
print(hetti) # Hi my name is Hetti and I am squashed

The Plumber class should have a name and an instance method called squash().

The squash() method for the Plumber class should accept a Roomba as a parameter. squash() should then invoke (call) the Roomba's instance method squish().

Here is an example of how the two classes should work.

hetti = Roomba("Hetti")
olga = Roomba("Olga")
bob = Roomba("Bob")
print(hetti) # Hi my name is Hetti and I am feeling fine
print(olga) # Hi my name is Olga and I am feeling fine
print(bob) # Hi my name is Bob and I am feeling fine

merio = Plumber("Merio")
merio.squash(olga) # ask merio to squash olga

print(hetti) # Hi my name is Hetti and I am feeling fine
print(olga) # Hi my name is Olga and I am squashed
print(bob) # Hi my name is Bob and I am feeling fine

Which would output the following to the terminal.

Hi my name is Hetti and I am feeling fine
Hi my name is Olga and I am feeling fine
Hi my name is Bob and I am feeling fine
Hi my name is Hetti and I am feeling fine
Hi my name is Olga and I am squashed
Hi my name is Bob and I am feeling fine

Getting Started

You can get started by copying and pasting the following into main.py.

class Roomba:
  pass

class Plumber:
  pass

def main():
  hetti = Roomba("Hetti")
  olga = Roomba("Olga")
  bob = Roomba("Bob")
  print(hetti) # Hi my name is Hetti and I am feeling fine
  print(olga) # Hi my name is Olga and I am feeling fine
  print(bob) # Hi my name is Bob and I am feeling fine
  
  merio = Plumber("Merio")
  merio.squash(olga)
  
  print(hetti) # Hi my name is Hetti and I am feeling fine
  print(olga) # Hi my name is Olga and I am squashed
  print(bob) # Hi my name is Bob and I am feeling fine

if __name__ == "__main__":
  main()

Composition and Aggregation

This lesson briefly introduces the fundamental concept of association in object-oriented programming that allows us to create connections between objects.

We often refer to classes as composite data types in that they are made up of lots of different data types, whether they are primitive data types like int, float, str, bool or user-defined data types (classes in Python).

There are two types of associations:

  1. Composition
  2. Aggregation

1. Composition

Composition is one of the fundamental concepts of object-oriented programming (OOP). It is often confused with inheritance which we will talk about in the next unit.

The idea of composition is that you can make up an object from other objects. Think about a car, a car is an object, but it has an engine, it has a gearstick, and it has some seats.

In a sense, the car is now a composition of all these other objects. Those in turn might also be composed of other objects, an engine certainly has many parts.

A good rule of thumb is to say out loud "a car has an engine" and see if it makes sense. Yes, a car does have an engine. If you can say X has a Y, and if Y cannot exist without X, then you should probably use composition. If Y can exist without X, then you probably need aggregation. It is often said to have ownership over the other object.

A good example of this is a room in a house. Here X is the house and Y is the room. Clearly a house has a room but a room cannot exist without the house.

Here we define two classes, an Engine class which has a horse_power attribute of type int and a Car which has an engine attribute of type Engine.

So an instance of Car is now made up of an instance of another class.

Importantly we pass a horse_power value to the constructor of Car and then construct the instance of an Engine within the Car instance. Crucially this means that the Engine instance belongs to the Car instance, it is said to be tightly coupled. This means if you delete the Car instance, the Engine instance is deleted as well.

Here is the example:

class Car:
  def __init__(self, horse_power):
    self.engine = Engine(horse_power)

  def __str__(self):
    return f"A car that has: \n{self.engine}"

class Engine:
  def __init__(self, horse_power):
    self.horse_power = horse_power

  def __str__(self):
    return f"Engine with {self.horse_power} bhp"


if __name__ == "__main__":
  car_one = Car(200)
  print(car_one.engine) # prints Engine with 200 bhp
  print(car_one) 
  # prints
  # A car that has: 
  # Engine with 200 bhp
  del car_one

When we run the code, we create an instance of Car which in turn has a reference to an instance of Engine, you can see this visualised using Python Tutor. When we delete the instance car_one, both are removed from memory.

Before deletion of the Car instance car_one.

Before deletion of car_one

After deletion of the Car instance car_one.

After deletion of car_one

2. Aggregation

Aggregation is very similar to composition except that the object is not explicitly tied to the instance, it is loosely coupled.

You can think of it using the object, but not owning it.

Here we pass an instance of an Engine to the Car constructor, this will act the same apart from when you delete the instance of the Car, the engine will remain in memory, it exists in its own right!

Which is the right choice depends on the objects and what they represent and how they are associated.

class Car:
  def __init__(self, engine):
    self.engine = engine

  def __str__(self):
    return f"A car that has: \n{self.engine}"

class Engine:
  def __init__(self, horse_power):
    self.horse_power = horse_power

  def __str__(self):
    return f"Engine with {self.horse_power} bhp"


if __name__ == "__main__":
  engine_one = Engine(200)
  car_one = Car(engine_one)
  print(car_one.engine) # prints Engine with 200 bhp
  print(car_one) 
  # prints
  # A car that has: 
  # Engine with 200 bhp
  del car_one

Before deletion of the Car instance car_one.

Before deletion of car_one

After deletion of the Car instance car_one.

After deletion of car_one

=== TASK ===

Student Class

Create a Student class that has a first_name, surname, age, email and id.

These can all be public attributes.

Override the __str__() method so that it prints out the as follows.

student_one = Student("Joe", "Bloggs", 25, "j.bloggs@derby.ac.uk", 12345678)
print(student_one)
Name: Joe Bloggs
Age: 25
Email: j.bloggs@derby.ac.uk
ID: 12345678

Records Class

Create a Records class that manages a non-public list of _students (i.e. a list that will contain instances of Student).

Records should have two methods:

  1. add_student() that adds an instance of Student to _students.
records = Records()
student_one = Student("Joe", "Bloggs", 25, "j.bloggs@derby.ac.uk", 12345678)
records.add_student(student_one)
  1. add_students_from_list() that takes a list as a parameter and adds this list to the internal list of students _students.
records = Records()
student_one = Student("Joe", "Bloggs", 25, "j.bloggs@derby.ac.uk", 12345678)
student_two = Student("Ada", "Lovelace", 36, "a.lovelace@derby.ac.uk", 87654321)
records.add_student_from_list([student_one, student_two])

You should also override the Record __str__() method so that it prints out each of the students in the student list _students by using the Student __str__() method.

Instead of accessing the Student __str__() method directly, you should use the str() method which will get the value returned from __str__().

e.g.

student_one = Student("Joe", "Bloggs", 25, "j.bloggs@derby.ac.uk", 12345678)
student_one_str = str(student_one) 
# student_one_str should contain - "Name: Joe Bloggs\nAge: 25\nEmail: j.bloggs@derby.ac.uk\nID: 12345678\n"
print(student_one)
Name: Joe Bloggs
Age: 25
Email: j.bloggs@derby.ac.uk
ID: 12345678

Some Examples

The below code snippets provide examples of how the classes should operate.

Adding a Single Student

records = Records()
student_one = Student("Joe", "Bloggs", 25, "j.bloggs@derby.ac.uk", 12345678)
records.add_student(student_one)
print(records)
Name: Joe Bloggs
Age: 25
Email: j.bloggs@derby.ac.uk
ID: 12345678

Adding a List of Students

records = Records()
student_one = Student("Joe", "Bloggs", 25, "j.bloggs@derby.ac.uk", 12345678)
student_two = Student("Ada", "Lovelace", 36, "a.lovelace@derby.ac.uk", 87654321)
records.add_student_from_list([student_one, student_two])
print(records)
Name: Joe Bloggs
Age: 25
Email: j.bloggs@derby.ac.uk
ID: 12345678
Name: Ada Lovelace
Age: 36
Email: a.lovelace@derby.ac.uk
ID: 87654321

Getting Started

You can get started by copying the following into main.py.

class Student:
  pass


class Records:
  pass


if __name__ == "__main__":
  records = Records()
  student_one = Student("Joe", "Bloggs", 25, "j.bloggs@derby.ac.uk", 12345678)
  student_two = Student("Ada", "Lovelace", 36, "a.lovelace@derby.ac.uk",
                        87654321)
  records.add_student_from_list([student_one, student_two])
  print(records)

References

Composition and Aggregation in Java

The Playing Cards Program 1.0

This is an example from:

Chapter 9: Python Programming for the Absolute Beginner, Third Edition - Michael Dawson.

It is a simple program that demonstrates combining objects.

You will find two simple classes Card and Hand.

The Card class represents a playing card.

The Hand class represents a player's hand of cards.

The key point of this program is that the Hand class internally manages a list of cards that are in the player's hand.

Copy the following into main.py.

# Playing Cards 1.0
# Demonstrates combining objects
class Card(object):
  """ A playing card. """
  RANKS = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"]
  SUITS = ["c", "d", "h", "s"]

  def __init__(self, rank, suit):
    self.rank = rank
    self.suit = suit

  def __str__(self):
    rep = self.rank + self.suit
    return rep


class Hand(object):
  """ A hand of playing cards. """

  def __init__(self):
    self.cards = []

  def __str__(self):
    if self.cards:
      rep = ""
      for card in self.cards:
        rep += str(card) + "\t"
    else:
      rep = "<empty>"

    return rep

  def clear(self):
    self.cards = []

  def add(self, card):
    self.cards.append(card)

  def give(self, card, other_hand):
    self.cards.remove(card)
    other_hand.add(card)


def main():
  card1 = Card(rank = "A", suit = "c")
  print("Printing a Card object:")
  print(card1)
  card2 = Card(rank = "2", suit = "c")
  card3 = Card(rank = "3", suit = "c")
  card4 = Card(rank = "4", suit = "c")
  card5 = Card(rank = "5", suit = "c")
  print("\nPrinting the rest of the objects individually:")
  print(card2)
  print(card3)
  print(card4)
  print(card5)

  my_hand = Hand()
  print("\nPrinting my hand before I add any cards:")
  print(my_hand)
  my_hand.add(card1)
  my_hand.add(card2)
  my_hand.add(card3)
  my_hand.add(card4)
  my_hand.add(card5)
  print("\nPrinting my hand after adding 5 cards:")
  print(my_hand)

  your_hand = Hand()
  my_hand.give(card1, your_hand)
  my_hand.give(card2, your_hand)
  print("\nGave the first two cards from my hand to your hand.")
  print("Your hand:")
  print(your_hand)
  print("My hand:")
  print(my_hand)

  my_hand.clear()
  print("\nMy hand after clearing it:")
  print(my_hand)
  input("\n\nPress the enter key to exit.")


if __name__ == "__main__":
  main()

Inheritance

Let's imagine we were creating a simple university record system that keeps track of both students and staff.

Consider the following class that represents a student.

1. Student and Staff (No Inheritance)

class Student:
  """ Represents a student """

  def __init__(self, first_name, surname, age, student_id, email):
    self.first_name = first_name
    self.surname = surname
    self.age = age
    self.student_id = student_id
    self.email = email

  def __str__(self):
    return_string = ""
    return_string += "Instance of Student\n\n"
    return_string += f"Name: {self.first_name} {self.surname}\n"
    return_string += f"Age: {self.age}\n"
    return_string += f"ID: {self.student_id}\n"
    return return_string

  def send_email(self):
    print(f"Email sent to {self.email}")

  def is_minor(self):
    return self.age < 18

def main():
    student_one = Student("Bradley", "Davis", 25, 87654321, "b.davis@derby.ac.uk")
    student_two = Student("Joe", "Bloggs", 17, 12345678, "j.bloggs@derby.ac.uk")

    print(student_one)
    print(student_two)
    
    student_one.send_email()
    student_two.send_email()

    print(student_one.is_minor())
    print(student_two.is_minor())

if __name__ == "__main__":
    main()

We could add another class that represents a staff member. Add this class to main.py and update the main() method.

class Staff:
  """ Represents a staff member """

  def __init__(self, first_name, surname, age, staff_id, email, NI_no):
    self.first_name = first_name
    self.surname = surname
    self.age = age
    self.staff_id = staff_id
    self.email = email
    self.NI_no = NI_no

  def __str__(self):
    return_string = ""
    return_string += "Instance of Staff\n\n"
    return_string += f"Name: {self.first_name} {self.surname}\n"
    return_string += f"Age: {self.age}\n"
    return_string += f"ID: {self.staff_id}\n"
    return_string += f"NI_no: {self.NI_no}\n"
    return return_string

  def send_email(self):
    print(f"Email sent to {self.email}")

  def is_pensionable(self):
    return self.age >= 65
def main():
    student_one = Student("Bradley", "Davis", 25, 87654321, "b.davis@derby.ac.uk")
    student_two = Student("Joe", "Bloggs", 17, 12345678, "j.bloggs@derby.ac.uk")
    staff_one = Staff("Sam", "O'Neill", 38, 12345678, "s.oneill@derby.ac.uk", "ABCDEF123")

    print(student_one)
    print(student_two)
    print(staff_one)
    
    student_one.send_email()
    student_two.send_email()
    staff_one.send_email()

    print(student_one.is_minor())
    print(student_two.is_minor())
    print(staff_one.is_pensionable())

if __name__ == "__main__":
    main()

If we look at the Student and Staff classes, we will see that they share a lot of characteristics and behaviour. They share a lot of code!

Object orientation tries to deal with this type of code duplication using inheritance. Inheritance is the idea that some classes might have things in common with a base (parent) class.

Sometimes the derived classes are called child classes. Much like you and I inherit genetics from our parents, you can think of derived (child) classes inheriting from their base (parent) class.

Some simple examples are:

Base (child) classDerived (child) class
ShapeCircle, Square, Triangle
AnimalDog, Cat, Cow
PersonStudent, Staff

The last example is exactly what is happening with the above. Both students and staff are people and they all have a name and age. In this case, they also share an email, and university ID and they have an identical method called send_email(). What they don't share is the national insurance number NI_no and the methods is_minor() and is_pensionable().

2. Person Class

We will create what is called a base (parent) class that will have all the shared characteristics (attributes) and behaviour (methods). We can then use this to define derived (child) classes that inherit all of these.

class Person:
  """ Represents a person """

  def __init__(self, first_name, surname, age, id, email):
    self.first_name = first_name
    self.surname = surname
    self.age = age
    self.id = id
    self.email = email

  def __str__(self):
    return_string = ""
    return_string += f"Instance of {self.__class__}\n\n"
    return_string += f"Name: {self.first_name} {self.surname}\n"
    return_string += f"Age: {self.age}\n"
    return_string += f"ID: {self.id}\n"
    return return_string

  def send_email(self):
    print(f"Email sent to {self.email}")

Now we have a person class, the only difference is that it does not have either of the methods is_minor() or is_pensionable() and does not have NI_no.

person_one = Person("Bradley", "Davis", 25, 87654321)

# person_one.is_minor() # This will result in an error
# person_one.is_pensionable() # This will result in an error

3. Updating Student and Staff (with Inheritance)

Now that we have our Parent class, we can now use this to further define newly derived (child) classes.

Here are the updated Student and Staff classes that inherit from Person.

class Student(Person):
  """ Represents a student """

  def __init__(self, first_name, surname, age, student_id, email):
    # this calls the base (parent) constructor
    super().__init__(first_name, surname, age, student_id, email)

  def is_minor(self):
    return self.age < 18


class Staff(Person):
  """ Represents a staff member """

  def __init__(self, first_name, surname, age, staff_id, email, NI_no):
    # this calls the base (parent) constructor
    super().__init__(first_name, surname, age, staff_id, email)
    self.NI_no = NI_no

  def is_pensionable(self):
    return self.age >= 65

As you can see, our Student and Staff classes are now much more simple, they inherit from Person (notice Person is now in paratheses () e.g. class Student(Person)) and they then define their attributes and methods on top of that.

Also, notice the use of super(). This is a special method that gives the derived (child) class access to the base (parent) class methods. Here we call the base (parent) constructor __int__() from within the derived (child) classes constructor __init__().

Here is the whole code.

class Person:
  """ Represents a person """

  def __init__(self, first_name, surname, age, id, email):
    print("Person constructor called.")
    self.first_name = first_name
    self.surname = surname
    self.age = age
    self.id = id
    self.email = email

  def __str__(self):
    return_string = ""
    return_string += f"Instance of {self.__class__}\n\n"
    return_string += f"Name: {self.first_name} {self.surname}\n"
    return_string += f"Age: {self.age}\n"
    return_string += f"ID: {self.id}\n"
    return return_string

  def send_email(self):
    print(f"Email sent to {self.email}")

class Student(Person):
  """ Represents a student """

  def __init__(self, first_name, surname, age, student_id, email):
    print("Creating a staff member")
    super().__init__(first_name, surname, age, student_id, email)

  def is_minor(self):
    return self.age < 18


class Staff(Person):
  """ Represents a staff member """

  def __init__(self, first_name, surname, age, staff_id, email, NI_no):
    print("Creating a staff member")
    super().__init__(first_name, surname, age, staff_id, email)
    
    self.NI_no = NI_no

  def is_pensionable(self):
    return self.age >= 65

def main():
    student_one = Student("Bradley", "Davis", 25, 87654321, "b.davis@derby.ac.uk")
    student_two = Student("Joe", "Bloggs", 17, 12345678, "j.bloggs@derby.ac.uk")
    staff_one = Staff("Sam", "O'Neill", 38, 12345678, "s.oneill@derby.ac.uk", "ABCDEF123")

    print(student_one)
    print(student_two)
    print(staff_one)

    student_one.send_email()
    student_two.send_email()
    staff_one.send_email()

    print(student_one.is_minor())
    print(student_two.is_minor())
    print(staff_one.is_pensionable())

if __name__ == "__main__":
    main()

The Student and Staff classes are now much more concise and only contain the things that are particular to each.

3.1 Class Hierarchy

ClassInherits From
Personobject Python's base class
StudentPerson
StaffPerson

4. object Class

It also turns out that all classes inherit from Python's base class object. To see this we can write this explicitly. Instead of class Person: we can write class Person(object). object has a bunch of things that all classes inherit and can be overridden like __init__().

class Person(object):
  """ Represents a person """

  def __init__(self, first_name, surname, age, id, email):
    print("Person constructor called.")
    self.first_name = first_name
    self.surname = surname
    self.age = age
    self.id = id
    self.email = email

  def __str__(self):
    return_string = ""
    return_string += f"Instance of {self.__class__}\n\n"
    return_string += f"Name: {self.first_name} {self.surname}\n"
    return_string += f"Age: {self.age}\n"
    return_string += f"ID: {self.id}\n"
    return return_string

  def send_email(self):
    print(f"Email sent to {self.email}")

You don't need to write this explicitly, but it is worth knowing.

5. Multiple Levels of Inheritance

It is possible to keep inheriting from classes.

Here we create a slightly different version of Person but we have a new class UniversityMember which inherits from Person and then Student and Staff will inherit from UniversityMember.

5.1 Class Hierarchy

The following table now summarises the classes, which class they inherit from and their instance attributes and methods.

classInherits FromInstance AttributesInstance Methods
Personobject (Python's base class)first_name, surname, age, home_address
UniversityMemberPersonid, emailsend_email()
StudentUniversityMemberuni_addressis_minor()
StaffUniversityMemberNI_nois_pensionable()

5.2 Implementaion

Here is our new code that implements this class hierarchy. Copy and paste this into main.py to see it working.

class Person:
  """ Represents a person """

  def __init__(self, first_name, surname, age, home_address):
    self.first_name = first_name
    self.surname = surname
    self.age = age
    self.home_address = home_address
  def __str__(self):
    return_string = ""
    return_string += f"Instance of {self.__class__}\n\n"
    return_string += f"Name: {self.first_name} {self.surname}\n"
    return_string += f"Age: {self.age}\n"
    return_string += f"Address: {self.home_address}\n"
    return return_string

  def send_email(self):
    print(f"Email sent to {self.email}")

class UniversityMember(Person):
  """ Represents a University Member"""
  def __init__(self, first_name, surname, age, home_address, student_id, email):
    super().__init__(first_name, surname, age, home_address)
    self.id = id
    self.email = email

class Student(UniversityMember):
  """ Represents a student """

  def __init__(self, first_name, surname, age, home_address, uni_address, student_id, email):
    super().__init__(first_name, surname, age, home_address, student_id, email)
    self.uni_address = uni_address

  def is_minor(self):
    return self.age < 18


class Staff(UniversityMember):
  """ Represents a staff member """

  def __init__(self, first_name, surname, age, home_address, staff_id, email, NI_no):
    super().__init__(first_name, surname, age, home_address, staff_id, email)
    
    self.NI_no = NI_no

  def is_pensionable(self):
    return self.age >= 65

def main():
    student_one = Student("Bradley", "Davis", 25, "Somewhere he calls home", "XR lab, he sleeps here", 87654321, "b.davis@derby.ac.uk",)
    student_two = Student("Joe", "Bloggs", 17, "Home sweet home", "Uni digs", 12345678, "j.bloggs@derby.ac.uk")
    staff_one = Staff("Sam", "O'Neill", 38, "His house", 12345678, "s.oneill@derby.ac.uk", "ABCDEF123")

    print(student_one)
    print(student_two)
    print(staff_one)

    student_one.send_email()
    student_two.send_email()
    staff_one.send_email()

    print(student_one.is_minor())
    print(student_two.is_minor())
    print(staff_one.is_pensionable())

if __name__ == "__main__":
    main()

6. A Simple Animal Example

The following example creates an Animal class with instance attributes name, age, no_legs and an instance method talk().

We then inherit from Animal to create a Dog and a Duck class.

Notice that when we call super().__init__() in the Dog and Duck constructor (__init__()) we hardcode the no_legs to 4 and 2, respectively.

Why bother asking the person creating the instance of Dog or Duck the number of legs when you know the answer?

Here is the code.

class Animal:
  """ class representing an animal"""
  def __init__(self, name, age, no_legs):
    self.name = name
    self.age = age
    self.no_legs = no_legs

  def talk(self):
    print(f"Hi I am {self.name}, I am {self.age} years old and I have {self.no_legs} legs")

class Dog(Animal):
  """ class representing a dog"""
  def __init__(self, name, age):
    # here we hardcode the no of legs to be 4
    super().__init__(name, age, 4)

  def bark(self):
    print("Woof")

class Duck(Animal):
  """ class representing a duck"""
  def __init__(self, name, age):
    # here we hardcode the no of legs to be 4
    super().__init__(name, age, 2)

  def quack(self):
    print("Quack")

if __name__ == "__main__":
  animal_list = []
  dog_one = Dog("Rex", 11)
  duck_one = Duck("Daffy", 84)
  animal_list.append(dog_one)
  animal_list.append(duck_one)
  # because they both inherit from the Animal class we know they have a talk() method
  for animal in animal_list:
    animal.talk()

  # however they have their own instance methods bark() and ``quack``

  dog_one.bark()
  duck_one.quack()

This will print out:

Hi I am Rex, I am 11 years old and I have 4 legs
Hi I am Daffy, I am 84 years old and I have 2 legs
Woof
Quack

We will see a better way to do this using polymorphism in a later lesson.

=== TASK ===

Please note that we could probably do this TASK differently and there are arguments for that, however, it is a simple exercise to help test inheritance.

Create a RegularPolygon class that has an attributes no_sides, side_length and instance methods area() and perimeter().

You can compute the area and perimeter of a regular polygon as follows.

You should compute the area and perimeter to 2 decimal places.

Area of A Regular Polygon

AREA = (1/4)*no_sides * (side_length**2 / math.tan(math.pi/no_sides))

Note that you will need the math module to use pi and tan.

Perimeter of A Regular Polygon

PERIMETER = no_sides * side_length

Maths is Fun - Regular Polygons

Extending RegularPolygon

Extend the RegularPolygon class to create EquilateralTriangle, Square and Pentagon. Each class should correctly set no_sides to 3, 4 and 5, respectively.

Implement the __str__() method so that it returns the following string.

class__str()__ returns
EquilateralTriangle"EquilateralTriangle\n\nArea: {AREA}\n\nPerimeter: {PERIMETER}\n"
Square"Square\n\nArea: {AREA}\n\nPerimeter: {PERIMETER}\n"
Pentagon"Pentagon\n\nArea: {AREA}\n\nPerimeter: {PERIMETER}\n"

Equilateral Triangle Example

tri = EquilateralTriangle(1)
print(tri)

Prints out:

EquilateralTriangle

Area: 0.43

Perimeter: 3

Square Example

square = Square(1)
print(square)

Prints out:

Square

Area: 1.0

Perimeter: 4

Pentagon Example

pent = Pentagon(1)
print(pent)

Prints out:

Pentagon

Area: 1.72

Perimeter: 5

You can generate more examples using Calculator Soup - Regular Polygon Calculator.

Getting Started

You can get started by copying and pasting the following into main.py.

import math

class RegularPolygon:
  pass

class EquilateralTriangle(RegularPolygon):
  pass
  
class Square(RegularPolygon):
  pass

class Pentagon(RegularPolygon):
  pass

if __name__ == "__main__":
  tri = EquilateralTriangle(1)
  square = Square(1)
  pent = Pentagon(1)

  print(tri)
  print(square)
  print(pent)

References

Maths is Fun - Regular Polygons

Calculator Soup - Regular Polygon Calculator

The Playing Cards Program 2.0

This is an example from:

Chapter 9: Python Programming for the Absolute Beginner, Third Edition - Michael Dawson.

It is a simple program that extends the previous playing cards program using inheritance.

Along with the two simple classes Card and Hand you will find a new class called Deck.

Deck represents a card deck. Interestingly if you think of a deck, it is quite like a hand, it is just a special hand that has all 52 different cards in to start with.

Hence our deck just extends (inherits) from Hand.

It then has three additional methods to the standard Hand class - populate() (populate the deck with all 52 cards), shuffle() (shuffle the card order in the list) and finally deal() which given a list of hands will deal out a number of cards to each by calling its give() method which gives a card from the deck to the other hand.

Copy the following into main.py.

import random

# Playing Cards 2.0
# Demonstrates inheritance - class extension
class Card(object):
  """ A playing card. """
  RANKS = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"]
  SUITS = ["c", "d", "h", "s"]

  def __init__(self, rank, suit):
    self.rank = rank
    self.suit = suit

  def __str__(self):
    rep = self.rank + self.suit
    return rep


class Hand(object):
  """ A hand of playing cards. """

  def __init__(self):
    self.cards = []

  def __str__(self):
    if self.cards:
      rep = ""
      for card in self.cards:
        rep += str(card) + "\t"
    else:
      rep = "<empty>"

    return rep

  def clear(self):
    self.cards = []

  def add(self, card):
    self.cards.append(card)

  def give(self, card, other_hand):
    self.cards.remove(card)
    other_hand.add(card)


class Deck(Hand):
  """ A deck of playing cards. """

  def populate(self):
    for suit in Card.SUITS:
      for rank in Card.RANKS:
        self.add(Card(rank, suit))

  def shuffle(self):
    random.shuffle(self.cards)

  def deal(self, hand_list, per_hand=1):
    for rounds in range(per_hand):
      for hand in hand_list:
        if self.cards:
          top_card = self.cards[0]
          self.give(top_card, hand)
        else:
          print("Can't continue deal. Out of cards!")

def main():
    # create a deck
  deck_one = Deck()
  print("Created a new deck.")
  print("Deck:")
  print(deck_one)

  # populate the deck
  deck_one.populate()
  print("\nPopulated the deck.")
  print("Deck:")
  print(deck_one)

  # shuffle the deck
  deck_one.shuffle()
  print("\nShuffled the deck.")
  print("Deck:")
  print(deck_one)

  # create my hand
  my_hand = Hand()

  # create your hand
  your_hand = Hand()

  # create a list of hands
  hand_list = [my_hand, your_hand]

  # deal 5 cards to each of the hands in the hand_list
  deck_one.deal(hand_list, per_hand = 5)
  print("\nDealt 5 cards to my hand and your hand.")

  # print out the hands
  print("My hand:")
  print(my_hand)
  print("Your hand:")
  print(your_hand)
  print("Deck:")
  print(deck_one)

if __name__ == "__main__":
  main()

Method Overriding and Polymorphism

Method overriding allows a derived (child) class to override a method defined in a base (parent) class.

Polymorphism means "many forms". In computer science, it is often used to mean reusing the same interface (function) for many meanings. In particular, we will be discussing subtyping polymorphism.

1. A Simple Example

Let's revisit our animal example from the lesson on inheritance.

This example creates an Animal class with instance attributes name, age, no_legs and an instance method talk().

We then inherit from Animal to create a Dog and a Duck class.

Notice that when we call super().__init__() in the Dog and Duck constructor (__init__()) we hardcode the no_legs to 4 and 2, respectively.

Why bother asking the person creating the instance of Dog or Duck the number of legs when you know the answer?

Here is the code.

class Animal:
  """ class representing an animal"""
  def __init__(self, name, age, no_legs):
    self.name = name
    self.age = age
    self.no_legs = no_legs

  def talk(self):
    print(f"Hi I am {self.name}, I am {self.age} years old and I have {self.no_legs} legs")

class Dog(Animal):
  """ class representing a dog"""
  def __init__(self, name, age):
    # here we hardcode the no of legs to be 4
    super().__init__(name, age, 4)

  def bark(self):
    print("Woof")

class Duck(Animal):
  """ class representing a duck"""
  def __init__(self, name, age):
    # here we hardcode the no of legs to be 4
    super().__init__(name, age, 2)

  def quack(self):
    print("Quack")

if __name__ == "__main__":
  animal_list = []
  dog_one = Dog("Rex", 11)
  duck_one = Duck("Daffy", 84)
  animal_list.append(dog_one)
  animal_list.append(duck_one)
  # because they both inherit from the Animal class we know they have a talk() method
  for animal in animal_list:
    animal.talk()

  # however they have their own instance methods bark() and ``quack``

  dog_one.bark()
  duck_one.quack()

This will print out:

Hi I am Rex, I am 11 years old and I have 4 legs
Hi I am Daffy, I am 84 years old and I have 2 legs
Woof
Quack

1.1 Using Polymorphism

Currently, this does not override the talk() method in either Duck or Dog, let's rewrite this so the talk() method now prints out "Woof" and "Quack", respectively. We will remove the bark() and quack() methods.

We will also create a list of animals that contain both Dog instances and Duck instances, iterate over this list and call the talk() method. This is polymorphism!

Why? It is because we know that Dog and Duck inherit from Animal, so we know they have a talk() method. However, we have overridden the method in both derived (child) classes which gives us different behaviour using the same method name, i.e. talk().

class Animal:
  """ class representing an animal"""
  def __init__(self, name, age, no_legs):
    self.name = name
    self.age = age
    self.no_legs = no_legs

  # override the base talk() method
  def talk(self):
    print(f"Hi I am {self.name}, I am {self.age} years old and I have {self.no_legs} legs")

class Dog(Animal):
  """ class representing a dog"""
  def __init__(self, name, age):
    # here we hardcode the no of legs to be 4
    super().__init__(name, age, 4)

  def talk(self):
    print("Woof")

class Duck(Animal):
  """ class representing a duck"""
  def __init__(self, name, age):
    # here we hardcode the no of legs to be 4
    super().__init__(name, age, 2)

  # override the base talk() method
  def talk(self):
    print("Quack")

if __name__ == "__main__":
  animal_list = []
  dog_one = Dog("Rex", 11)
  duck_one = Duck("Daffy", 84)
  animal_list.append(dog_one)
  animal_list.append(duck_one)
  # because they both inherit from the Animal class we know they have a talk() method
  # this is polymorphism
  for animal in animal_list:
    animal.talk()

The last two lines,

  for animal in animal_list:
    animal.talk()

show polymorphism in action as we ask two diffent types, Dog and Duck to invoke its talk() method.

The above example now prints out:

Woof
Quack

2. Student and Staff Example

Based on our previous lesson we had the following code to represent a Staff and a Student class which inherit from a Person class.

class Person:
  """ Represents a person """

  def __init__(self, first_name, surname, age, id, email):
    print("Person constructor called.")
    self.first_name = first_name
    self.surname = surname
    self.age = age
    self.id = id
    self.email = email

  def __str__(self):
    return_string = ""
    return_string += f"Instance of {self.__class__}\n\n"
    return_string += f"Name: {self.first_name} {self.surname}\n"
    return_string += f"Age: {self.age}\n"
    return_string += f"ID: {self.id}\n"
    return return_string

  def send_email(self):
    print(f"Email sent to {self.email}")

class Student(Person):
  """ Represents a student """

  def __init__(self, first_name, surname, age, student_id, email):
    print("Creating a staff member")
    super().__init__(first_name, surname, age, student_id, email)

  def is_minor(self):
    return self.age < 18


class Staff(Person):
  """ Represents a staff member """

  def __init__(self, first_name, surname, age, staff_id, email, NI_no):
    print("Creating a staff member")
    super().__init__(first_name, surname, age, staff_id, email)
    
    self.NI_no = NI_no

  def is_pensionable(self):
    return self.age >= 65

def main():
    student_one = Student("Bradley", "Davis", 25, 87654321, "b.davis@derby.ac.uk")
    student_two = Student("Joe", "Bloggs", 17, 12345678, "j.bloggs@derby.ac.uk")
    staff_one = Staff("Sam", "O'Neill", 38, 12345678, "s.oneill@derby.ac.uk", "ABCDEF123")

    print(student_one)
    print(student_two)
    print(staff_one)

    student_one.send_email()
    student_two.send_email()
    staff_one.send_email()

    print(student_one.is_minor())
    print(student_two.is_minor())
    print(staff_one.is_pensionable())

if __name__ == "__main__":
    main()

One thing you will notice if you run this code is that both Student and Staff instances can invoke (call) the send_email() method because it is inherited from Person. They can also invoke (call) the methods they define is_minor() and is_pensionable().

Also when we print the instances, e.g. print(student_one) and print(staff_one), this is invoking the __str__() method defined in Person.

2.1 Using Polymorphism

However, because __str__() is defined in Person, when invoked for Staff, it won't print out the national insurance number. We can choose to override this method.

class Staff(Person):
  """ Represents a staff member """

  def __init__(self, first_name, surname, age, staff_id, email, NI_no):
    print("Creating a staff member")
    super().__init__(first_name, surname, age, staff_id, email)
    
    self.NI_no = NI_no

  # override the base __str__ method
  def __str__(self):
    return_string = ""
    return_string += f"Instance of {self.__class__}\n\n"
    return_string += f"Name: {self.first_name} {self.surname}\n"
    return_string += f"Age: {self.age}\n"
    return_string += f"ID: {self.id}\n"
    # also adds NI_no
    return_string += f"National Insurance No.: {self.NI_no}\n"
    return return_string
  
  def is_pensionable(self):
    return self.age >= 65

Now when we do print(staff_one) it will include the national insurance number. Nice.

Except, we are repeating the code. We can again use super() to simplify this. This time super() will be used to call the instance method __str__().

class Staff(Person):
  """ Represents a staff member """

  def __init__(self, first_name, surname, age, staff_id, email, NI_no):
    print("Creating a staff member")
    super().__init__(first_name, surname, age, staff_id, email)
    
    self.NI_no = NI_no

  # override the base __str__ method but use it's functionality via super()
  def __str__(self):
    # call the __str__() method of Person (base) and assign to return_string
    return_string = super().__str__()
    # also adds NI_no
    return_string += f"National Insurance No.: {self.NI_no}\n"
    return return_string
  
  def is_pensionable(self):
    return self.age >= 65

We can override any method defined in the base (parent) class. Let's also override the send_email() method for the Staff class.

class Staff(Person):
  """ Represents a staff member """

  def __init__(self, first_name, surname, age, staff_id, email, NI_no):
    print("Creating a staff member")
    super().__init__(first_name, surname, age, staff_id, email)
    
    self.NI_no = NI_no

  # override the base __str__ method but use it's functionality via super()
  def __str__(self):
    # call the __str__() method of Person (base) and assign to return_string
    return_string = super().__str__()
    # also adds NI_no
    return_string += f"National Insurance No.: {self.NI_no}\n"
    return return_string
  
  def is_pensionable(self):
    return self.age >= 65

  # override the base send_email method but use it's functionality 
  def send_email(self):
    super().send_email()
    print(f"Staff ID: {self.id}")

Our final code looks like this.

class Person:
  """ Represents a person """

  def __init__(self, first_name, surname, age, id, email):
    print("Person constructor called.")
    self.first_name = first_name
    self.surname = surname
    self.age = age
    self.id = id
    self.email = email

  def __str__(self):
    return_string = ""
    return_string += f"Instance of {self.__class__}\n\n"
    return_string += f"Name: {self.first_name} {self.surname}\n"
    return_string += f"Age: {self.age}\n"
    return_string += f"ID: {self.id}\n"
    return return_string

  def send_email(self):
    print(f"Email sent to {self.email}")

class Student(Person):
  """ Represents a student """

  def __init__(self, first_name, surname, age, student_id, email):
    print("Creating a staff member")
    super().__init__(first_name, surname, age, student_id, email)

  def is_minor(self):
    return self.age < 18


class Staff(Person):
  """ Represents a staff member """

  def __init__(self, first_name, surname, age, staff_id, email, NI_no):
    print("Creating a staff member")
    super().__init__(first_name, surname, age, staff_id, email)
    
    self.NI_no = NI_no

  def __str__(self):
    # call the __str__() method of Person (base) and assign to return_string
    return_string = super().__str__()
    # also adds NI_no
    return_string += f"National Insurance No.: {self.NI_no}\n"
    return return_string
  
  def is_pensionable(self):
    return self.age >= 65

  def send_email(self):
    super().send_email()
    print(f"Staff ID: {self.id}")

def main():
    student_one = Student("Bradley", "Davis", 25, 87654321, "b.davis@derby.ac.uk")
    student_two = Student("Joe", "Bloggs", 17, 12345678, "j.bloggs@derby.ac.uk")
    staff_one = Staff("Sam", "O'Neill", 38, 12345678, "s.oneill@derby.ac.uk", "ABCDEF123")

    person_list = []
    person_list.append(student_one, student_two, staff_one)

    # polymorphism, we do not know where the __str__() method is implemented, we just know it is.
    for person in person_list:
      print(person)

    # polymorphism, we do not know where the send_email() method is implemented, we just know it is.
    for person in person_list:
      person.send_email()
      
    print(student_one.is_minor())
    print(student_two.is_minor())
    print(staff_one.is_pensionable())

if __name__ == "__main__":
    main()

The following lines in the above code demonstrate polymorphism.

    # polymorphism, we do not know where the __str__() method is implemented, we just know it is.
    for person in person_list:
      print(person)

    # polymorphism, we do not know where the send_email() method is implemented, we just know it is.
    for person in person_list:
      person.send_email()

That is we know that instances of Staff and Student inherit from Person and therefore have the methods __str__() and send_email(). However, we have overridden them with gives us different behaviour using the same method name. In fact, we didn't override send_email() in Student, so it uses the one defined in Person.

3. Abstract Classes

An abstract class allows us to template methods that derived classes must implement.

We can demonstrate this with our earlier animal example.

Here we make Animal an abstract class by inheriting the ABC class (Abstract Base Class).

We can then mark methods as abstract using the decorator @abstractmethod, this forces any derived class such as Dog to override this method.

from abc import ABC, abstractmethod

class Animal(ABC):
  """ class representing an animal"""
  def __init__(self, name, age, no_legs):
    self.name = name
    self.age = age
    self.no_legs = no_legs

  # abstract method, derived classes must implement!
  @abstractmethod
  def talk(self):
    return 1

class Dog(Animal):
  """ class representing a dog"""
  def __init__(self, name, age):
    # here we hardcode the no of legs to be 4
    super().__init__(name, age, 4)

  def talk(self):
    print("Woof")

class Duck(Animal):
  """ class representing a duck"""
  def __init__(self, name, age):
    # here we hardcode the no of legs to be 4
    super().__init__(name, age, 2)

  # override the base talk() method
  def talk(self):
    print("Quack")

if __name__ == "__main__":
  animal_list = []
  dog_one = Dog("Rex", 11)
  duck_one = Duck("Daffy", 84)
  animal_list.append(dog_one)
  animal_list.append(duck_one)
  # because they both inherit from the Animal class we know they have a talk() method
  # this is polymorphism
  for animal in animal_list:
    animal.talk()

The above example will again print out:

Woof
Quack

Try removing the talk() method from Duck and you will get the following error.

Traceback (most recent call last):
  File "main.py", line 34, in <module>
    duck_one = Duck("Daffy", 84)
TypeError: Can't instantiate abstract class Duck with abstract method talk

=== TASK ===

Create an abstract class called Shape, it should have two abstract methods; area() and perimeter() and one attribute colour.

You should create classes for the following shapes given in the table below. You should inherit from Shape and override the area() and perimeter() methods. Round both area and circumference to 2 decimal places.

ShapeClass NameAttributesArea FormulaPerimeter Formula
Right TriangleRightTrianglebase, height(1/2) * base * heightbase + height + math.sqrt(base**2 + height**2)
SquareSquaresideside**24 * side
RectangleRectanglebase, heightbase * height2 * (base + height)

HINT: Remember you will need to use self to access the instance attributes.

If done correctly you should be able to run this code.

right_tri = RightTriangle(1,1, "Red")
square = Square(1, "Blue")
rectangle = Rectangle(1,2, "Green")

shape_list = []
shape_list.append(right_tri)
shape_list.append(square)
shape_list.append(rectangle)

for shape in shape_list:
  print(shape.area())

print()

for shape in shape_list:
  print(shape.perimeter())

print()

for shape in shape_list:
  print(shape.colour)

Which will print out the following.

0.5
1
2

3.41
4
6

Red
Blue
Green

Extra Thought

What could Square inherit from other than Shape and how would this work?

The Playing Cards Program 3.0

This is an example from:

Chapter 9: Python Programming for the Absolute Beginner, Third Edition - Michael Dawson.

It is a simple program that extends the previous playing cards program using inheritance and overriding methods.

We now introduce two extra card classes that extend from Card - UnprintableCard, PositionableCard

Both of these override the base (Card) class __str__() method.

PositionableCard also manages an attribute is_face_up that keeps track of which way up a card is, face up or face down. It also has a method called flip(), that allows us to change is_face_up to True or False.

Copy the following into main.py.

import random


# Playing Cards 3.0
# Demonstrates inheritance - overriding methods
class Card(object):
    """ A playing card. """
    RANKS = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"]
    SUITS = ["c", "d", "h", "s"]

    def __init__(self, rank, suit):
        self.rank = rank
        self.suit = suit

    def __str__(self):
        rep = self.rank + self.suit
        return rep


class UnprintableCard(Card):
    """ A Card that won't reveal its rank or suit when printed. """

    def __str__(self):
        return "<unprintable>"


class PositionableCard(Card):
    """ A Card that can be face up or face down. """

    def __init__(self, rank, suit, face_up=True):
        super().__init__(rank, suit)
        self.is_face_up = face_up

    def __str__(self):
        if self.is_face_up:
            rep = super().__str__()
        else:
            rep = "XX"
        return rep

    def flip(self):
        self.is_face_up = not self.is_face_up


class Hand(object):
    """ A hand of playing cards. """

    def __init__(self):
        self.cards = []

    def __str__(self):
        if self.cards:
            rep = ""
            for card in self.cards:
                rep += str(card) + "\t"
        else:
            rep = "<empty>"

        return rep

    def clear(self):
        self.cards = []

    def add(self, card):
        self.cards.append(card)

    def give(self, card, other_hand):
        self.cards.remove(card)
        other_hand.add(card)


class Deck(Hand):
    """ A deck of playing cards. """

    def populate(self):
        for suit in Card.SUITS:
            for rank in Card.RANKS:
                self.add(Card(rank, suit))

    def shuffle(self):
        random.shuffle(self.cards)

    def deal(self, hand_list, per_hand=1):
        for rounds in range(per_hand):
            for hand in hand_list:
                if self.cards:
                    top_card = self.cards[0]
                    self.give(top_card, hand)
                else:
                    print("Can't continue deal. Out of cards!")


def main():
    card_one = Card("A", "c")
    card_two = UnprintableCard("A", "d")
    card_three = PositionableCard("A", "h")
    print("Printing a Card object:")
    print(card_one)
    print("\nPrinting an UnprintableCard object:")
    print(card_two)
    print("\nPrinting a PositionableCard object:")
    print(card_three)
    print("Flipping the PositionableCard object.")
    card_three.flip()
    print("Printing the PositionableCard object:")
    print(card_three)
    input("\n\nPress the enter key to exit.")


if __name__ == "__main__":
    main()

Modules and Classes

This is a fairly straightforward lesson.

Basically, you can create modules which contain both functions and classes.

A module should contain related code, whether that be in the form of functions or classes.

For example, we could create a Critter class and also create a function called print_critter_list().

Here are the contents of critter.py.

# critter.py
# demonstrates having a class and a function in the same module

class Critter:
  def __init__(self, name):
    self.name = name

  def __str__(self):
    return f"Hi I am {self.name}"

# simple function that prints out a list of critters
# note this is not in the class Critter
def print_critter_list(critter_list):
  for critter in critter_list:
    print(critter)

Here are the contents of main.py.

# main.py
from critter import Critter, print_critter_list

# create a list of critters using the imported Critter class
critter_list =[Critter("Bob"), Critter("Sue"), Critter("Ava"), Critter("Alan")]

# print the list of critters using the imported print_critter_list function
print_critter_list(critter_list)

Running main.py will print out the following:

Hi I am Bob
Hi I am Sue
Hi I am Ava
Hi I am Alan

If you are interested in how to structure larger projects you can search on the internet and you will find lots of best practices and tutorials.

However, it is out of the scope of this course.

=== TASK ===

Create a module called person.py. It should contain a single class Person and a single function say_hello().

The class Person should have attributes name and age and you should override the __str__() method so that it returns Hi I am NAME and I am AGE years old.

The function say_hello() should take in an instance of Person and print out the instance of Person.

You can copy the following into main.py to get started, but you will need to have the Person class and say_hello function set up in hello.py.

# main.py
from person import Person, say_hello
terry = Person("Terry", 45)
say_hello(terry)

Which will print out:

Hi I am Terry and I am 45 years old

The Blackjack Game

This is an example from:

Chapter 9: Python Programming for the Absolute Beginner, Third Edition - Michael Dawson.

This is the full Blackjack program, it uses the classes we have built up so far, but also adds a number of other classes. It also groups functionality into three modules blackjack, cards and games.

cards.py

# Cards Module
# Basic classes for a game with playing cards

import random

class Card(object):
  """ A playing card. """
  RANKS = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"]
  SUITS = ["c", "d", "h", "s"]

  def __init__(self, rank, suit, face_up=True):
    self.rank = rank
    self.suit = suit
    self.is_face_up = face_up

  def __str__(self):
    if self.is_face_up:
      rep = self.rank + self.suit
    else:
      rep = "XX"
    return rep

  def flip(self):
    self.is_face_up = not self.is_face_up


class Hand(object):
  """ A hand of playing cards. """

  def __init__(self):
    self.cards = []

  def __str__(self):
    if self.cards:
      rep = ""
      for card in self.cards:
        rep += str(card) + "\t"
    else:
      rep = "<empty>"
    return rep

  def clear(self):
    self.cards = []

  def add(self, card):
    self.cards.append(card)

  def give(self, card, other_hand):
    self.cards.remove(card)
    other_hand.add(card)


class Deck(Hand):
  """ A deck of playing cards. """

  def populate(self):
    for suit in Card.SUITS:
      for rank in Card.RANKS:
        self.add(Card(rank, suit))

  def shuffle(self):
    random.shuffle(self.cards)

  def deal(self, hands, per_hand=1):
    for rounds in range(per_hand):
      for hand in hands:
        if self.cards:
          top_card = self.cards[0]
          self.give(top_card, hand)
        else:
          print("Can't continue deal. Out of cards!")


if __name__ == "__main__":
  print("This is a module with classes for playing cards.")
  input("\n\nPress the enter key to exit.")

games.py

# Games
# Demonstrates module creation
class Player(object):
  """ A player for a game. """

  def __init__(self, name, score=0):
    self.name = name
    self.score = score

  def __str__(self):
    rep = self.name + ":\t" + str(self.score)
    return rep


def ask_yes_no(question):
  """Ask a yes or no question."""
  response = None
  while response not in ("y", "n"):
    response = input(question).lower()
  return response


def ask_number(question, low, high):
  """Ask for a number within a range."""
  response = None
  while response not in range(low, high):
    response = int(input(question))
  return response


if __name__ == "__main__":
  print("You ran this module directly (and did not 'import' it).")
  input("\n\nPress the enter key to exit.")

blackjack.py

import cards, games

class BJ_Card(cards.Card):
  """ A Blackjack Card. """
  ACE_VALUE = 1

  @property
  def value(self):
    if self.is_face_up:
      v = BJ_Card.RANKS.index(self.rank) + 1
      if v > 10:
        v = 10
    else:
      v = None
    return v


class BJ_Deck(cards.Deck):
  """ A Blackjack Deck. """

  def populate(self):
    for suit in BJ_Card.SUITS:
      for rank in BJ_Card.RANKS:
        self.cards.append(BJ_Card(rank, suit))


class BJ_Hand(cards.Hand):
  """ A Blackjack Hand. """

  def __init__(self, name):
    super().__init__()
    self.name = name

  def __str__(self):
    rep = self.name + ":\t" + super().__str__()
    if self.total:
      rep += "(" + str(self.total) + ")"
      print("here")
    return rep

  @property
  def total(self):
    # if a card in the hand has value of None, then total is None
    for card in self.cards:
      if not card.value:
        return None

    # add up card values, treat each Ace as 1
    t = 0
    for card in self.cards:
      t += card.value

    # determine if hand contains an Ace
    contains_ace = False
    for card in self.cards:
      if card.value == BJ_Card.ACE_VALUE:
        contains_ace = True

    # if hand contains Ace and total is low enough, treat Ace as 11
    if contains_ace and t <= 11:
      # add only 10 since we've already added 1 for the Ace
      t += 10
    return t

  def is_busted(self):
    return self.total > 21


class BJ_Player(BJ_Hand):
  """ A Blackjack Player. """

  def is_hitting(self):
    response = games.ask_yes_no("\n" + self.name +
                                ", do you want a hit? (Y/N): ")
    return response == "y"

  def bust(self):
    print(self.name, "busts.")
    self.lose()

  def lose(self):
    print(self.name, "loses.")

  def win(self):
    print(self.name, "wins.")

  def push(self):
    print(self.name, "pushes.")


class BJ_Dealer(BJ_Hand):
  """ A Blackjack Dealer. """

  def is_hitting(self):
    return self.total < 17

  def bust(self):
    print(self.name, "busts.")

  def flip_first_card(self):
    first_card = self.cards[0]
    first_card.flip()


class BJ_Game(object):
  """ A Blackjack Game. """

  def __init__(self, names):
    self.players = []
    for name in names:
      player = BJ_Player(name)
      self.players.append(player)
    self.dealer = BJ_Dealer("Dealer")
    self.deck = BJ_Deck()
    self.deck.populate()
    self.deck.shuffle()

  @property
  def still_playing(self):
    sp = []
    for player in self.players:
      if not player.is_busted():
        sp.append(player)
    return sp

  def __additional_cards(self, player):
    while not player.is_busted() and player.is_hitting():
      self.deck.deal([player])
      print(player)
      if player.is_busted():
        player.bust()

  def play(self):
    # deal initial 2 cards to everyone
    self.deck.deal(self.players + [self.dealer], per_hand=2)
    self.dealer.flip_first_card()  # hide dealer's first card

    for player in self.players:
      print(player)
    print(self.dealer)

    # deal additional cards to players
    for player in self.players:
      self.__additional_cards(player)

    self.dealer.flip_first_card()  # reveal dealer's first

    if not self.still_playing:
      # since all players have busted, just show the dealer's hand
      print(self.dealer)
    else:
      # deal additional cards to dealer
      print(self.dealer)
      self.__additional_cards(self.dealer)
      if self.dealer.is_busted():
        # everyone still playing wins
        for player in self.still_playing:
          player.win()
      else:
        # compare each player still playing to dealer
        for player in self.still_playing:
          if player.total > self.dealer.total:
            player.win()
          elif player.total < self.dealer.total:
            player.lose()
          else:
            player.push()
    # remove everyone's cards
    for player in self.players:
      player.clear()
    self.dealer.clear()

main.py

# Blackjack
# From 1 to 7 players compete against a dealer
import games, blackjack


def main():
  print("\t\tWelcome to Blackjack!\n")
  names = []
  number = games.ask_number("How many players? (1 - 7): ", low=1, high=8)
  for i in range(number):
    name = input("Enter player name: ")
    names.append(name)

  print()
  game = blackjack.BJ_Game(names)
  again = None

  while again != "n":
    game.play()
    again = games.ask_yes_no("\nDo you want to play again?: ")


if __name__ == "__main__":
  main()
  input("\n\nPress the enter key to exit.")