Pythonprogrammingquickguide

Download as pdf or txt
Download as pdf or txt
You are on page 1of 145

Python Programming

Fatos Morina
© 2022 Fatos Morina
Contents

Introduction to Python . . . . . . . . . . . . . . . . . . . . . 1

The Basics . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2
Comments . . . . . . . . . . . . . . . . . . . . . . . . . . . 2
print() method . . . . . . . . . . . . . . . . . . . . . . . . 3

Operators . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4
Arithmetic operators . . . . . . . . . . . . . . . . . . . . . 4
Assignment operators . . . . . . . . . . . . . . . . . . . . 5
Comparison operators . . . . . . . . . . . . . . . . . . . . 9

Data types . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 13
Variables . . . . . . . . . . . . . . . . . . . . . . . . . . . . 13
Numbers . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15
Strings . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 17
Lists . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 30
Tuples . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 42
Dictionaries: Key-Value Data Structures . . . . . . . . . . 45
Sets . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 52

Type Conversions . . . . . . . . . . . . . . . . . . . . . . . . 57
Conversions between primitive types . . . . . . . . . . . 57
Other Conversions . . . . . . . . . . . . . . . . . . . . . . 58
Wrap Up . . . . . . . . . . . . . . . . . . . . . . . . . . . . 63

Control Flow . . . . . . . . . . . . . . . . . . . . . . . . . . . 64
CONTENTS

Wrap Up . . . . . . . . . . . . . . . . . . . . . . . . . . . . 71

Functions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 72
Default arguments . . . . . . . . . . . . . . . . . . . . . . 74
Keyword argument list . . . . . . . . . . . . . . . . . . . 75
Data lifecycle . . . . . . . . . . . . . . . . . . . . . . . . . 76
Changing data inside functions . . . . . . . . . . . . . . . 77
Lambda functions . . . . . . . . . . . . . . . . . . . . . . 79
Decorators . . . . . . . . . . . . . . . . . . . . . . . . . . . 84
Wrap up . . . . . . . . . . . . . . . . . . . . . . . . . . . . 91

Object Oriented Programming . . . . . . . . . . . . . . . . 93


Methods . . . . . . . . . . . . . . . . . . . . . . . . . . . . 99
Hiding information . . . . . . . . . . . . . . . . . . . . . . 107
Inheritance . . . . . . . . . . . . . . . . . . . . . . . . . . 114
Polymorphism . . . . . . . . . . . . . . . . . . . . . . . . 126

Importing . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 131
Limiting parts that we want to import . . . . . . . . . . . 132
Importing everything . . . . . . . . . . . . . . . . . . . . 133

Exceptions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 134
Common Exceptions . . . . . . . . . . . . . . . . . . . . . 134
Handling exceptions . . . . . . . . . . . . . . . . . . . . . 135

Afterword . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 141
Introduction to Python
Python is one of the most popular programming languages that was
created in 1991 by Guido van Rossum.
According to Guido van Rossum, Python is a:

“high-level programming language, and its core design


philosophy is all about code readability and a syntax
which allows programmers to express concepts in a few
lines of code.”

It represents one of the easiest languages that you can learn and also
work with. It can be used for software development, server side of
web development, machine learning, mathematics, and any type of
scripting that you can think of.
The good thing about it is that it is widely used and accepted
through many companies and academic institutions, making it a
really good choice especially if you are just starting your coding
journey. Moreover, it has a large community of developers that use
it and are willing to help. This community has already published
many open source libraries that you can start using. They also
actively keep improving them.
Its syntax is quite similar to that of the English language, making
it easier for you to understand and use it quite intuitively.
Python runs on an interpreter system, meaning that you are not
expected to wait for a compiler to compile the code and then
execute it. You can instead build prototypes fast.
It works on different platforms, such as Windows, Linux, Mac,
Rapsberry Pi, etc.
The Basics
In comparison to other languages, Python places a special impor-
tance on indentations.
In other programming languages, white spaces and indentations are
only used to make the code more readable and prettier, whereas in
Python they represent a sub-block of code.
The following code is not going to work, since the second line is
not properly intended:

1 if 100 > 10:


2 print("100 is greater than 10")

For it to work properly, the indentation should look like the


following:

1 if 100 > 10:


2 print("100 is greater than 10")

This may look hard to understand, but you can have code editors
that highlight such syntax errors quite vividly. Moreover, the more
Python code you write, the easier it gets for you to consider such
indentations as second nature.

Comments
We use comments to specify parts that the program should be
simply ignored and not executed by the Python interpreter. This
means that everything written inside a comment is not taken into
The Basics 3

consideration at all and you can write in any language that you
want, including your own native language.
We can start comments with the symbol # in front of the line:

1 # This is a comment in Python

We can also include more extended comments in multiple lines


using triple quotes:

1 """
2 This is a comment in Python.
3 Here we are typing in another line and we are still insid\
4 e the same comment block.
5
6 In the previous line we have a space, because it is allow\
7 ed to have spaces inside comments.
8
9 Let us end this comment right here.
10 """

print() method
We are going to use the method print() since it helps us see results
in the console.
You do not need to know how it works behind the scenes or even
know what a method is right now. Just think of it as a way for us
to display results from our code in the console.
Operators
Arithmetic operators
Even though you may pull out your phone from your pocket and
do some calculations, you should also get used from now on to
implement some arithmetic operators inside Python.
When we want to add two numbers, we use the plus sign, just like
in Math:

1 print(50 + 4) # 54

Similarly, for subtraction, we use the minus sign:

1 print(50 - 4) # 46

For multiplication, we use the star sign:

1 print(50 * 4) # 200

When doing divisions, we use the forward slash sign:

1 print(50 / 4) # 12.5
2 print(8 / 4) # 2.0

This results in float numbers. If we want to only get the integer


number when doing divisions also known as simply doing floor
divisions, we should use the double fraction sign:
Operators 5

1 print(50 // 4) # 12
2 print(8 / 4) # 2

We can also find the remainder of a number divided by another


number using the percent sign %.

1 print(50 % 4) # 2

This operation can be helpful especially in cases when we want to


check whether a number is odd or even. A number is odd if when
divided by 2, the remainder is 1. Otherwise, it is even.
Here is an illustration:

1 print(50 % 2) # 0
2 # Since the remainder is 0, this number is even
3
4 print(51 % 2) # 1
5 #Since the remainder is 1, this number is odd

When we want to raise a number to a specific power, we should use


the double star sign:

1 print(2 ** 3) # 8
2 # This is a short way of writing 2 * 2 * 2
3
4 print(5 ** 4) # 625
5 # This is a short way of writing 5 * 5 * 5 * 5

Assignment operators
These operators are used to assign values to variables.
When we declare a variable, we use the equal sign:
Operators 6

1 name = "Fatos"
2 age = 28

We can also declare multiple variables in the same line:

1 name, age, location = "Fatos", 28, "Europe"

We can use this way to also swap values between variables, for
example, let us say that we have two variables a and b and want to
switch their values.
One logical way to do that would be to introduce a third variable
that serves as a temporary variable:

1 a, b = 1, 2
2
3 print(a) # 1
4 print(b) # 2
5
6 c = a
7 a = b
8 b = c
9
10 print(a) # 2
11 print(b) # 1

However, we can do that in a single line in the following way:


Operators 7

1 a, b = 1, 2
2
3 print(a) # 1
4 print(b) # 2
5
6 b, a = a, b
7
8 print(a) # 2
9 print(b) # 1

We can also have a merge of the assignment operator with arith-


metic operators.
Let us see how we can do that for addition first.
Let us assume that we have the following variables:

1 total_sum = 20
2
3 current_sum = 10

Now we want to add the value of the current_sum to total_sum. To


do that, we should write the following:

1 total_sum = total_sum + current_sum


2
3 print(total_sum) # 30

This may look as not accurate, since the right hand side is not equal
to the left hand side. However, in this case we are simply doing an
assignment and not a comparison of both sides of the equation.
To do this quickly, we can use the following form:
Operators 8

1 total_sum += current_sum
2
3 print(total_sum) # 30

This is equivalent to the previous statement.


Similarly, we can do the same with other arithmetic operators:

• Subtraction:

1 result = 3
2 number = 4
3
4 result -= number # This is equal to result = result - nu\
5 mber
6
7 print(result) # -1

• Multiplication:

1 product = 3
2 number = 4
3
4 product *= number # This is equal to product = product *\
5 number
6
7 print(product) # 12

• Division:
Operators 9

1 result = 8
2 number = 4
3
4 result *= number # This is equal to result = result / nu\
5 mber
6
7 print(result) # 2.0

• Modulo operator:

1 result = 8
2 number = 4
3
4 result %= number # This is equal to result = result % nu\
5 mber
6
7 print(result) # 0

• Power operator:

1 result = 2
2 number = 4
3
4 result **= number # This is equal to result = result ** \
5 number
6
7 print(result) # 16

Comparison operators
We have learned in elementary school to do comparisons of num-
bers, such as checking whether a specific number is larger than
another number, or whether they are equal.
We can use almost the same operators in Python to do such
comparisons.
Operators 10

Let us see them in action.

Equalities

Checking whether two numbers are equal can be done using the
==:

1 print(2 == 3) # False

The last expression evaluates to False since 2 is not equal to 3.


There is another operator that can be used to check whether 2
numbers are not equal. This is an operator that you may have not
had the chance to see in your math classes written exactly in this
way. This is the operator !=.
Let us do a comparison of whether 2 is not equal to 3:

1 print(2 != 3) # True

This expression evaluates to True since 2 is indeed not equal to 3.

Inequalities

Now here we are going to see how to check whether a number is


larger than another number:

1 print(2 > 3) # False

This is something that you should already know from your math
classes.
When trying to check whether a number is greater than or equal to
another number, we need to use this operator >=:
Operators 11

1 print(2 >= 3) # False

Similarly, for the other side, we have:

1 print(2 < 3) # True


2 print(2 <= 3) # True

Logical operators

In high school math, you have probably learned about logical


operators such as and and or.
Briefly speaking, for an expression to evaluate to True when using
and, both statements should be true. In Python, we implement it
using and:

1 print(5 > 0 and 3 < 5) # True

This example is going to evaluate to True since 5 is greater than 0,


which evaluates to True, and 3 is less than 5, which also evaluates to
True, from which we get, True and True, which evaluates to True.
Let us take an example when an and expression is going to evaluate
to False:

1 print(2 > 5 and 0 > -1) # False

2 is not greater than 5, so the statement in the left hand side is


going to evaluate to False. No matter what’s on the right hand, we
are going to get the whole expression equal to False, since False
and whatever value else is going to result to False.
When we want to have statements that at least one of them should
evaluate to True, then we should use or:
Operators 12

1 print(2 > 5 or 0 > -1) # True

This is going to evaluate to True since the statement in the right


hand side evaluates to True.
If both statements are False, then or results with False in the end:

1 print(2 < 0 or 0 > 1) # False

This is False since 0 is not greater than 2 and also 0 is not greater
than 1. Hence, the whole expression is False.
Data types
Variables
Variables can be considered as the building blocks of any computer
program that you can think of.
They can be used to store values and then be reused as many times
as you want. The moment you want to change their value, you can
just change it in one place and that new value that you just changed
is going to get reflected everywhere else where this variable is used.
Every variable in Python is an object.
A variable is created in the moment it is initialized with a value.
Here are the general rules for Python variables:

• A variable name must start with a letter or the underscore


character. It cannot start with a number.
• A variable name can only contain alpha-numeric characters
and underscores (A-z, 0-9, and _ ).
• Variable names are case-sensitive, meaning that height,
Height, HEIGHT are all different variables.

Let us define our first variable:

1 age = 28

In this example, we are initializing a variable with the name age


and assigning the value 28 to it.
We can define other variables along with it, like:
Data types 14

1 age = 28
2 salary = 10000

We can use pretty much any name that we want, but it is a better
practice to use names that can be well understood both by you and
other work colleagues who work with you.
We have other variable types in Python, such as float numbers,
strings, and boolean values. We can even create our own custom
types.
Let us see an example of a variable that holds a float number:

1 height = 3.5

As you can see, this initialization is quite similar to the ones when
we had integer numbers. Here we are only changing the value on
the right and Python interpreter is smart enough to know that we
are dealing with another type of variable, namely a float type of
variable.
Let us see an example of a string:

1 reader = "Fatos"

We put string values either in quotes or in apostrophes to specify


the value that we want to store in string variables.
When we want to store boolean values, we need to use reserved
words, namely, True and False.

1 text_visibile = False

We can also have a boolean value stored when we have expressions


that result with a boolean value, for example, when we compare a
number with another number, such as:
Data types 15

1 is_greater = 5 > 6

This variable is going to get initialized with the value False since 5
is lower than 6.

Numbers
We have three numeric types in Python: integers, floats and com-
plex numbers.

Integers
Integers represent whole numbers that can be both positive and
negative and do not contain any decimal part.
Here are a few examples of integers: 1, 3000, -31234, etc.
When adding, subtracting, or multiplying two integers, we get an
integer as a result in the end.

1 print(3 + 5) # 8
2 print(3 - 5) # -2
3 print(3 * 5) # 15

These are all integers.


This also includes cases when we raise a number to a power:

1 result = 3 ** 4 # This is similar to multiplying 3 * 3 *\


2 3 * 3 in which case, we are multiplying integers togethe\
3 r
4 print(result) # 81

When we want to divide two integers, we are going to get a float


number.
Data types 16

Boolean

Boolean type represents truth values True and False. We include the
explanation of this type inside this Numbers section, since booleans
are indeed subtypes of the integer type.
More specifically, almost always a False value can be considered as
a 0, whereas a True value can be considered as a 1.
As such, we can also do arithmetic operations with them:

1 print(True * 5) # 5
2 print(False * 500) # 0, since False is equal to 0

The exception for such integer representations of Boolean values is


when we these values are strings such as “False” and “True”.

Floats

Float numbers are numbers that contain the decimal part, such as
-3.14, 12.031, 9,3124, etc.

We can also convert float numbers using the float() function:

1 ten = float(10)
2
3 print(ten) # 10.0

When adding, subtracting, or dividing two float numbers, or a float


number and an integer, we get a float number as a result in the end:
Data types 17

1 print(3.4 * 2) # 6.8
2 print(3.4 + 2) # 5.4
3 print(3.4 - 2) # 1.4
4 print(2.1 * 3.4) # 7.14

Complex numbers

Complex numbers have both the real and the imaginary part that
we write in the following way:

1 complex_number = 1 + 5j
2
3 print(complex_number) # (1+5j)

Strings
Strings represent characters that are enclosed in either single quotes
or double quotes and both of them are treated the same:

1 name = "Fatos" # Double quotes


2
3 name = 'Fatos' # Single quotes

If we want to include a quote inside a string, we need to let Python


know that it should not close the string but simply escape that
quote:
Data types 18

1 greeting = 'Hello. I\'m fine.' # We escaped the apostrop\


2 he
3
4 double_quote_greeting = "Hello. I'm fine." # When using \
5 double quotes, we do not need to escape the apostrophe

When we want to include a new line in a string, we can include the


special character \n:

1 my_string = "I want to continue \n in the next line"


2
3 print(my_string)
4 # I want to continue
5 # in the next line

Since strings are arrays of characters, we can index specific charac-


ters using indexes. Indexes start from 0 and go all the way until the
length of the string minus 1. We exclude the index that is equal to
the length of the string since we start indexing from 0 and not from
1.
Here is an example:

1 string = "Word"

In this example, if we were to select individual characters of the


string, they would unfold as follows:
Data types 19

1 string = "Word"
2
3 print(string[0]) # W
4 print(string[1]) # o
5 print(string[2]) # r
6 print(string[3]) # d

We can use negative indexes as well, which means that we start


from the end of the string with -1. We cannot use 0 to index from
the end of a string, since -0 = 0:

1 print(string[-1]) # d
2 print(string[-2]) # r
3 print(string[-3]) # o
4 print(string[-4]) # W

We can also do slicing and include only a portion of the string and
not the entire string, for example, if we want to get characters that
start from a specific index until a specific index, we should write it
in the following way: string[start_index:end_index], excluding
the character at index end_index:

1 string = "Word"
2
3 print(string[0:3]) # Wor

If we want to start from a specific index and continue getting all


the remaining characters of the string until the end, we can omit
specifying the end index, as follows:

1 string = "Word"
2
3 print(string[2:]) # rd

If we want to start from 0 and go until a specific index, we can


simply specify the ending index:
Data types 20

1 string = "Word"
2
3 print(string[:2]) # Wo

This means that the value of string is equal to string[:2] (exclud-


ing the character at position 2) + string[2:].
Note that strings in Python are immutable, meaning that once a
string object has been created, it cannot be modified, such as trying
to change a character in a string.
As an illustration, let us say that we want to change the first
character in the string, namely switching W with A in the following
way:

1 string = "Word"
2 string[0] = "A"

Now, if we try to print string, we would get an error like the


following:

1 # TypeError: 'str' object does not support item assignment

If we need a new string, we should simply create a new one.

String operators

We can concatenate strings using the + operator:


Data types 21

1 first = "First"
2 second = "Second"
3
4 concantenated_version = first + " " + second
5
6 print(concatenated_version) # First Second

We can use the multiplication operator * with strings and numbers.


This can be used to repeat the string that many times. For example,
if we want to repeat a string 5 times, but we do not want to write it
manually five times, we can simply multiply it with the number 5:

1 string = "Abc"
2
3 repeated_version = string * 5
4
5 print(repeated_version) # AbcAbcAbcAbcAbc

String built-in methods


There are a few built-in methods of strings that we can use that can
make it easier for us to manipulate with them.

len()

len() is a method that we can use to get the length of a string:

1 sentence = "I am fine."


2
3 print(len(sentence)) # 10

replace()

replace() can be used to replace a character or a substring of a


string with another character or substring:
Data types 22

1 string = "Abc"
2
3 modified_version = string.replace("A", "Z")
4
5 print(modified_version) # Zbc

strip()

strip() removes white spaces that can be in the beginning or at


the end of a string:

1 string = " Hi there "


2
3 print(string.strip()) # Hi there

split()

split() can be used to convert a string into an array of substrings


based on a specific pattern that is mentioned as the separator. For
example, let us assume that we want to save all words of a sentence
in an array of words. These words are separated by white spaces,
so we will need to do the splitting based on that:

1 sentence = "This is a sentence that is being declared her\


2 e"
3
4 print(string.split(" "))
5 # ['This', 'is', 'a', 'sentence', 'that', 'is', 'being', \
6 'declared', 'here']

join()

join() is the opposite of split(): from an array of strings, it returns


a string. The process of concatenation is supposed to happen with
a specified separator in between each element of the array that in
the end results with a single concatenated string:
Data types 23

1 words = ["cat", "dog", "rabbit"]


2
3 print(" - ".join(words)) # cat - dog - rabbit

count()

count() can be used to count the number of times a character or a


substring appears in a string:

1 string = "Hi there"


2
3 print(string.count("h")) # 1, since, it is case sensitiv\
4 e and 'h' is not equal to 'H'
5
6 print(string.count("e")) # 2
7
8 print(string.count("Hi")) # 1
9
10 print(string.count("Hi there")) # 1

find()

find() can be used to find a character or a substring in a string


and returns the index of it. In case it does not find it, it will simply
return -1:

1 string = "Hi there"


2
3 print(string.find("3")) # -1
4
5 print(string.find("e")) # 5
6
7 print(string.find("Hi")) # 0
8
9 print(string.find("Hi there")) # 0
Data types 24

lower()

lower() can be used to convert all characters of a string into lower


case:

1 string = "Hi there"


2
3 print(string.lower()) # hi there

upper()

upper() can be used to convert all characters of a string into lower


case:

1 string = "Hi there"


2
3 print(string.upper()) # HI THERE

capitalize()

capitalize() can be used to covert the first character of a string


into uppercase:

1 string = "hi there"


2
3 print(string.capitalize()) # Hi there

title()

title() can be used to convert starting characters of each word


(sequences that are separated by white spaces) of a string into
uppercase:
Data types 25

1 string = "hi there"


2
3 print(string.title()) # Hi There

isupper()

isupper() is a method that can be used to check whether all


characters in a string are upper case:

1 string = "are you HERE"


2 another_string = "YES"
3
4 print(string.isupper()) # False
5 print(another_string.isupper()) # True

islower()

islower() similarly can be used to check whether all characters are


lower case:

1 string = "are you HERE"


2 another_string = "no"
3
4 print(string.islower()) # False
5 print(another_string.islower()) # True

isalpha()

isalpha() returns True if all characters in a string are letters of the


alphabet:
Data types 26

1 string = "A1"
2 another_string = "aA"
3
4 print(string.isalpha()) # False, since it contains 1
5 print(another_string.isalpha()) # True since both `a` an\
6 d `A` are letters of the alphabet

isdecimal()

isdecimal() returns True if all characters in a string are numbers:

1 string = "A1"
2 another_string = "3.31"
3 yet_another_string = "3431"
4
5 print(string.isdecimal()) # False, since it contains 'A'
6 print(another_string.isdecimal()) # False, since it cont\
7 ains '.'
8 print(yet_another_string.isdecimal()) # True, since it c\
9 ontains only numbers

Formatting

Formatting a string can be quite useful since you may be using it


quite frequently no matter the type of project or script that you are
working on.
Let us first illustrate why do we need formatting and include string
interpolation.
Imagine that I want to develop a software that greets people the
moment they come in, such as:

1 greeting = "Good morning Fatos."


Data types 27

This now looks great, but I am not the only person who uses it,
right?
I am just one of the people who gets to use it.
Now if someone comes and signs in, I will have to use their own
names, such as:

1 greeting = "Good morning Besart."

This is just my first real user who is registering. I am not counting


myself.
Now let us think that I get lucky and the second user also pops in
on a Friday morning and our application should display:

1 greeting = "Good morning Betim."

As you can see, we are getting somewhere from a business perspec-


tive since two new users just showed up, but this is not a scalable
implementation. We are writing a very static greeting expression.
You should already remember that we are about to mention some-
thing that was introduced just at the beginning.
Yes, we will need to use variables and include a variable next to the
string, as follows:

1 greeting = "Good morning " + first_name

This is much more flexible.


This is one way of doing that.
Another way that I am referring to is using a method called
format().

We can use curly braces to specify places where we want to put


dynamic values, such as the following:
Data types 28

1 greeting = "Good morning {}. Today is {}.".format("Fatos"\


2 , "Friday")

This is now going to put the first parameter of the method format()
inside the first curly braces, which in our case is Fatos. Then, in the
second occurrence of the curly braces, it is going to put the second
parameter of the method format().
If we try to print the value of the string, we should get the following:

1 print(greeting)
2 # Good morning Fatos. Today is Friday.

We can specify parameters with indexes inside curly braces like the
following that can then be used:

1 greeting = "Today is {1}. Have a nice day {0}".format("Fa\


2 tos", "Friday")
3
4 print(greeting)
5 # greeting = "Today is {1}. Have a nice day {0}.".format(\
6 "Fatos", "Friday")

We can also specify parameters inside the format() method and use
those specific words inside curly braces as a reference:

1 greeting = "Today is {day_of_the_week}. Have a nice day {\


2 first_name}.".format(first_name="Fatos",
3 \
4 day_of_the_week="Friday")
5 print(greeting) # Today is Friday. Have a nice day Fatos.

We can combine both types of arguments in a single example, such


as the following:
Data types 29

1 short_bio = 'My name is {name}. My last name is {0}. I lo\


2 ve {passion}. I like playing {1}.'.format(
3 'Morina',
4 'Basketball',
5 name='Fatos',
6 passion='Programming'
7 )
8
9 print(short_bio)
10 # My name is Fatos. My last name is Morina. I love Progra\
11 mming. I like playing Basketball.

As you can see, using named arguments as opposed to positional


ones can be less error prone, since their ordering in the format()
method does not matter.
We can also use another way of formatting strings which consists
of beginning a string with f or F before the opening quotation mark
or triple quotation mark and include names of the variables that we
want to be included in the end:

1 first_name = "Fatos"
2 day_of_the_week = "Friday"
3 continent = "Europe"
4
5 greeting = f'Good morning {first_name}. Today is {day_of_\
6 the_week}'
7
8 print(greeting) # Good morning Fatos. Today is Friday.

Here is another example where we are using a triple quotient after


the F:
Data types 30

1 continent = "Europe"
2
3 i_am_here = F'''I am in {continent}'''
4
5 print(i_am_here) # I am in Europe

Lists
If you take a look at a bookshelf, you can see that they are
stacked and put closely together. You can see that there are many
examples of collecting, and structuring elements in some way.
This is also quite important in computer programming. We cannot
just continue declaring countless variables and manage them that
easily.
Let us say that we have a class of students and want to save their
names. We can start saving their names according to the way they
are positioned in the classroom:

1 first = "Albert"
2 second = "Besart"
3 third = "Fisnik"
4 fourth = "Festim"
5 fifth = "Gazmend"

The list can keep on going and it is also quite hard for us to keep
track of all of them.
There is fortunately an easier way for us to put these in a collection
in Python called list.
Let us create a list called students and store in them all the names
declared in the previous code block:
Data types 31

1 students = ["Albert", "Besart", "Fisnik", "Festim", "Gazm\


2 end"]

This is prettier, right?


Moreover, this way, it is easier for us to manage and also manipulate
with the elements.
You may think that, “Well it was easier for me to just call first and
get the value stored in there. Now it is impossible to get a value
from this new list, called students”.
If we would not be able to read and use those elements that we just
stored in a list, that would make it less helpful.
Fortunately, lists have indexes, which start from 0, meaning that if
we want to get the first element in a list, we need to use index 0
and not index 1 as you may think.
In the example above, the list items have these corresponding
indexes:

1 students = ["Albert", "Besart", "Fisnik", "Festim", "Gazm\


2 end"]
3 # Indexes 0 1 2

Now, if we want to get the first element, we simply write:

1 students[0]

If we want to get the second element, we just write:

1 students[1]

As you can probably get the gist, we simply need to write the name
of the list and also the corresponding index of the element that we
want to get in the square brackets.
Data types 32

This list is of course not static. We can add elements to it, like when
a new student joins the class.
Let us add a new element in the list students with the value Besfort:

1 students.append("Besfort")

We can also change the value of an existing element. To do that, we


need to simply reinitialize that specific element of the list with a
new value.
For example, let us change the name of the first student:

1 students[0] = "Besim"

Lists can contain contain different types of variables, for example,


we can have a string that contains integers, float numbers, and
strings:

1 combined_list = [3.14, "An element", 1, "Another element \


2 here"]

Slicing

Similar to strings, lists can also be sliced, which as a result returns


new lists, meaning that the original lists remain unchanged.
Let us see how we can get the first three elements of a list using
slicing:

1 my_list = [1, 2, 3, 4, 5]
2
3 print(my_list[0:3]) # [1, 2, 3]
Data types 33

As you can see, we have specified 0 as the starting index and 3 as


the index where the slicing should stop, excluding the element at
the ending index.
If we want to simply start from an index and get all the remaining
elements in the list, meaning that the end_index should be the last
index, then we can omit and not have to write the last index at all:

1 my_list = [1, 2, 3, 4, 5]
2
3 print(my_list[3:]) # [4, 5]

Similarly, if we want to start from the beginning of the list and do


the slicing until a specific index, then we can omit writing the 0
index entirely, since Python is smart enough to infer that:

1 my_list = [1, 2, 3, 4, 5]
2
3 print(my_list[:3]) # [1, 2, 3]

Strings in Python are immutable, whereas lists are mutable, mean-


ing that we can modify lists’ content after we declare them.
As an illustration, let us say that we want to change the first
character in the string, namely switching S with B in the following
way:

1 string = "String"
2 string[0] = "B"

Now, if we try to print string, we would get an error like the


following:

1 # TypeError: 'str' object does not support item assignment

Now if we have a list and want to modify its first element, then we
can successfully do so:
Data types 34

1 my_list = ["a", "b", "c", "d", "e"]


2
3 my_list[0] = 50
4
5 print(my_list) # [50, 'b', 'c', 'd', 'e']

We can expand a list by concatenating it with another list using the


+operator:

1 first_list = [1, 2, 3]
2
3 second_list = [4, 5]
4
5 first_list = first_list + second_list
6
7 print(first_list) # [1, 2, 3, 4, 5]

Nesting a list inside a list


We can nest a list inside another list:

1 math_points = [30, "Math"]


2
3 physics_points = [53, "Phyiscs"]
4
5 subjects = [math_points, physics_points]
6
7 print(subjects) # [[30, 'Math'], [53, 'Phyiscs']]

These lists do not need even have to have the same length.
To access elements of a list which is inside a list we need to use
double indexes.
Let us see how we can access the element math_points inside the
subjects list. Since math_points is an element in the subjects list
positioned at index 0, we simply need to do the following:
Data types 35

1 print(subjects[0]) # [30, 'Math']

Now let us assume that we want to access Math inside subjects list.
Since Math is at index 1, we are going to need to use the following
double indexes:

1 print(subjects[0][1]) # 'Math'

List methods

len() is a method that can be used to find the length of a list:

1 my_list = ["a", "b", 1, 3]


2
3 print(len(my_list)) # 4

Adding elements

We can also expand lists by adding new elements, or we can also


delete elements.
Adding new elements at the end of a list can be done by using the
append() method:

1 my_list = ["a", "b", "c"]


2
3 my_list.append("New element")
4
5 my_list.append("Yet another new element")
6
7 print(my_list)
8 # ['a', 'b', 'c', 'New element', 'Yet another new element\
9 ']
Data types 36

If we want to add elements at specific indexes in a list, we can


use the insert() method, where we specify the index in the first
argument, and the element that we want to add in the list as a
second argument:

1 my_list = ["a", "b"]


2
3 my_list.insert(1, "z")
4
5 print(my_list) # ['a', 'z', 'b']

Deleting elements from lists

We can delete elements from lists using the pop() method, which
removes the last element in the list:

1 my_list = [1, 2, 3, 4, 5]
2
3 my_list.pop() # removes 5 from the list
4
5 print(my_list) # [1, 2, 3, 4]
6
7 my_list.pop() # removes 4 from the list
8
9 print(my_list) # [1, 2, 3]

We can also specify the index of an element in the list that indicates
which element in the list we should delete:
Data types 37

1 my_list = [1, 2, 3, 4, 5]
2
3 my_list.pop(0) # Delete the element at index 0
4
5 print(my_list) # [2, 3, 4, 5]

We can also delete elements from lists using del statement and then
specifying the value of the element that we want to delete:

1 my_list = [1, 2, 3, 4, 1]
2
3 del my_list[0] # Delete element my_list[0]
4
5 print(my_list) # [2, 3, 4, 5]

We can also delete slices of lists using del:

1 my_list = [1, 2, 3, 4, 1]
2
3 del my_list[0:3] # Delete elements: my_list[0], my_list[\
4 1], my_list[2]
5
6 print(my_list) # [4, 1]

This can also be done using remove():

1 my_list = [1, 2, 3, 4]
2
3 my_list.remove(3)
4
5 print(my_list) # [1, 2, 4]

reverse() can be used to reverse the elements in a list. This is quite


easy and straightforward:
Data types 38

1 my_list = [1, 2, 3, 4]
2
3 my_list.reverse()
4
5 print(my_list) # [4, 3, 2, 1]

Index search

Getting elements of a list using indexes is simple. Finding indexes


of elements of a list is also easy. We simply need to use the method
index() and mention the element that we want to find inside a list:

1 my_list = ["Fatos", "Morina", "Python", "Software"]


2
3 print(my_list.index("Python")) # 2

Membership
This is quite intuitive and related to real life: We get to ask ourselves
whether something is part of something or not.
- Is my phone in my pocket or bag?

• Is my coworker’s email included in the CC?


• Is my friend in this coffee shop?

In Python, if we want to check whether a value is part of something,


that we can use the operator in:

1 my_list = [1, 2, 3] # This is a list


2
3 print(1 in my_list) # True

Since 1 is included in the array [1, 2, 3], the expression evaluates


to True.
We can also use it not only with arrays of numbers, but with arrays
of characters as well:
Data types 39

1 vowels = ['a', 'i', 'o', 'u']


2 print('y' in vowels) # False

Since y is not a vowel and not included in the declared array, the
expression in the second line of the previous code snippet is going
to result to False.
Similarly, we can also check whether something is not included
using not in:

1 odd_numbers = [1, 3, 5, 7]
2 print(2 not in odd_numbers) # True

Since 2 is not included in the array, the expression is going to


evaluate to True.

Sorting
Sorting elements in a list is something that you may need to do from
time to time. sort() is a built-in method that makes it possible for
you to sort elements in a list in an ascending order alphabetically
or numerically:

1 my_list = [3, 1, 2, 4, 5, 0]
2
3 my_list.sort()
4
5 print(my_list) # [0, 1, 2, 3, 4, 5]
6
7 alphabetical_list = ['a', 'c', 'b', 'z', 'e', 'd']
8
9 alphabetical_list.sort()
10
11 print(alphabetical_list) # ['a', 'b', 'c', 'd', 'e', 'z']

There are other methods of lists that we have not included in here.
Data types 40

List comprehension

List comprehension represents a concise way in which we use a for


loop to create a new list from an existing one. The result is always
a new list in the end.
Let us start with an example where we want to multiply each
number of a list with 10 and save that result in a new list. First,
let us do this without using list comprehensions:

1 numbers = [2, 4, 6, 8] # Complete list


2
3 numbers_tenfold = [] # Empty list
4
5 for number in numbers:
6 number = number * 10 # Multiply each number with 10
7 numbers_tenfold.append(number) # Add that new number\
8 in the new list
9
10 print(numbers_tenfold) # [20, 40, 60, 80]

We can implement that using list comprehensions in the following


way:

1 numbers = [2, 4, 6, 8] # Complete list


2
3 numbers_tenfold = [number * 10 for number in numbers] # \
4 List comprehension
5
6 print(numbers_tenfold) # [20, 40, 60, 80]

We can also include conditions when doing these list comprehen-


sions.
Let us assume that we want to save a list of positive numbers.
Data types 41

Before we write the way we would implement this using list


comprehensions, let us write a way in which we would create a list
of only numbers that are greater than 0 in another list and increase
those positive numbers by 100:

1 positive_numbers = [] # Empty list


2
3 numbers = [-1, 0, 1, -2, -3, -4, 3, 2] # Complete list
4
5 for number in numbers:
6 if number > 0: # If the current number is greater th\
7 an 0
8 positive_numbers.append(number + 100) # add that\
9 number inside the list positive_numbers
10
11
12 print(positive_numbers) # [101, 103, 102]

We can do the same using list comprehension:

1 numbers = [-1, 0, 1, -2, -3, -4, 3, 2] # Compelete list


2
3 positive_numbers = [number + 100 for number in numbers if\
4 number > 0] # List comprehension
5
6 print(positive_numbers) # [101, 103, 102]

As you can see, this is much shorter and should take less time to
write.
We can also use list comprehensions with multiple lists as well.
Let us take an example where we want to add each element of a list
with each element in another list:
Data types 42

1 first_list = [1, 2, 3]
2 second_list = [50]
3
4 double_lists = [first_element +
5 second_element for first_element in first\
6 _list for second_element in second_list]
7
8 print(double_lists) # [51, 52, 53]

In the end, we are going to get a resulting list that has the same
number of elements as the list with the longest length.

Tuples
Tuples are collections that are ordered and immutable, meaning
that their content cannot be changed. They are ordered and their
elements can be accessed using indexes.
Let us start with our first tuple:

1 vehicles = ("Computer", "Smartphone", "Smart watch", "Tab\


2 let")
3
4 print(vehicles)
5
6 # ('Computer', 'Smartphone', 'Smart watch', 'Tablet')

All the indexing and slicing operations that we have seen in the
chapter of lists apply for tuples as well:
Data types 43

1 print(len(vehicles)) # 4
2
3 print(vehicles[3]) # Tablet
4
5 print(vehicles[:3]) # ('Computer', 'Smartphone', 'Smart \
6 watch')

Finding the index of an element inside a tuple can be done using


the index() method:

1 print(vehicles.index('tablet')) # 3

We can also concatenate or merge two tuples using the + operator:

1 natural_sciences = ('Chemistry', 'Astronomy',


2 'Earth science', 'Physics', 'Biology')
3
4 social_sciences = ('Anthropology', 'Archaeology', 'Econom\
5 ics', 'Geography',
6 'History', 'Law', 'Linguistics', 'Poli\
7 tics', 'Psychology', 'Sociology')
8
9 sciences = natural_sciences + social_sciences
10
11 print(sciences)
12 # ('Chemistry', 'Astronomy', 'Earth science', 'Physics', \
13 'Biology', 'Anthropology', 'Archaeology', 'Economics', 'G\
14 eography', 'History', 'Law', 'Linguistics', 'Politics', '\
15 Psychology', 'Sociology')

Membership check
We can check whether an element is part of a tuple using operators
in and not in just like with lists:
Data types 44

1 vehicles = ('Car', 'Bike', 'Airplane')


2
3 print('Motorcycle' in vehicles) # False, since Motorcycl\
4 e is not included in vehicles
5
6 print('Train' not in vehicles) # True, since Train is no\
7 t included in vehicles

Nesting 2 tuples

Instead of merging, we can also nest tuples into a single tuple by


using tuples that we want to nest inside the parenthesis:

1 natural_sciences = ('Chemistry', 'Astronomy',


2 'Earth science', 'Physics', 'Biology')
3
4 social_sciences = ('Anthropology', 'Archaeology', 'Econom\
5 ics', 'Geography',
6 'History', 'Law', 'Linguistics', 'Poli\
7 tics', 'Psychology', 'Sociology')
8
9 sciences = (natural_sciences, social_sciences)
10
11 print(sciences)
12 # (('Chemistry', 'Astronomy', 'Earth science', 'Physics',\
13 'Biology'), ('Anthropology', 'Archaeology', 'Economics',\
14 'Geography', 'History', 'Law', 'Linguistics', 'Politics'\
15 , 'Psychology', 'Sociology'))

Immutability

Since tuples are immutable, they cannot be changed after they are
created. This means that we cannot add, or delete elements in them,
or append a tuple to another tuple.
Data types 45

We cannot even modify existing elements in it. If we try to modify


an element in a tuple, we are going to face a problem like the
following:

1 vehicles = ('Car', 'Bike', 'Airplane')


2
3 vehicles[0] = 'Truck'
4
5 print(vehicles)
6 # TypeError: 'tuple' object does not support item assignm\
7 ent

Dictionaries: Key-Value Data


Structures
As we saw previously, elements in lists are associated with indexes
that can be used to reference those elements. There is another
data structure in Python that allows us to specify our own custom
indexes and not just numbers. These are called dictionaries and they
are similar to dictionaries that we use to find the meaning of foreign
words we do not understand.
Let us assume that you are trying to learn German and there is a
new word that you have not had the chance to learn it before that
you just saw at a market: Wasser.
Now, you can pick up your phone and check its corresponding
meaning in English using Google Translate or any other application
of your choice, but if you were to use a physical dictionary, you
would need to find this word by going to that specific page and
check its meaning sitting right beside there. The reference or the
key for the meaning of this word would be the term Wasser.
Now, if we want to implement this in Python, we should not use
lists that have indexes only as numbers. We should use dictionaries
Data types 46

instead.
For dictionaries, we use curly braces and have each element that
has two parts: the key and the value. In our previous example, the
key was the German word, whereas the value was its translation in
English, as can be seen in the following example:

1 german_to_english_dictionary = {
2 "Wasser": "Water",
3 "Brot": "Bread",
4 "Milch": "Milk"
5 }

Now, when we want to access specific elements in the dictionary,


we simply use keys, for example, let us assume that we want to get
the meaning of the word Brot in English. To do that, we can simply
reference that element using that key:

1 brot_translation = german_to_english_dictionary["Brot"]
2 print(translation) # Bread

When we print the value that we get, we are going to get the
translation in English.
Similarly, we can get the English translation of the word Milch by
getting the value of the element that has Milch as the key:

1 milch_translation = german_to_english_dictionary["Milch"]
2 print(milch_translation) # Milk

We can also get the value of an element in a dictionary using get()


and specifying the key of the item that we want to get:

1 german_to_english_dictionary.get("Wasser")

We can create dictionaries using dict():


Data types 47

1 words = dict([
2 ('abandon', 'to give up to someone or something on th\
3 e ground'),
4 ('abase', 'to lower in rank, office, or esteem'),
5 ('abash', 'to destroy the self-possession or self-con\
6 fidence of')
7 ])
8
9 print(words)
10 # {'abandon': 'to give up to someone or something on the \
11 ground', 'abase': 'to lower in rank, office, or esteem', \
12 'abash': 'to destroy the self-possession or self-confiden\
13 ce of'}

Both keys and values can be of any data type.


We can have more than one key with the same value, but keys
should be unique.

Adding new values

We can add new values inside dictionaries by specifying a new key


and a corresponding value and then Python is going to create a new
element inside that dictionary:

1 words = {
2 'a': 'alfa',
3 'b': 'beta',
4 'd': 'delta',
5 }
6
7 words['g'] = 'gama'
8
9 print(words)
10 # {'a': 'alfa', 'b': 'beta', 'd': 'delta', 'g': 'gama'}
Data types 48

If we specify the key of an element that is already part of the


dictionary, that element is going to be modified:

1 words = {
2 'a': 'alfa',
3 'b': 'beta',
4 'd': 'delta',
5 }
6
7 words['b'] = 'bravo'
8
9
10 print(words)
11 # {'a': 'alfa', 'b': 'bravo', 'd': 'delta'}

Removing elements

If we want to remove elements from a dictionary, we can use the


method pop() and also specify the key of the element that we want
to delete:

1 words = {
2 'a': 'alfa',
3 'b': 'beta',
4 'd': 'delta',
5 }
6
7 words.pop('a')
8
9 print(words) # {'b': 'beta', 'd': 'delta'}

We can also delete values using popitem() which removes the last
inserted key-value pair starting from Python 3.7. In earlier versions,
it deletes a random pair:
Data types 49

1 words = {
2 'a': 'alfa',
3 'b': 'beta',
4 'd': 'delta',
5 }
6
7 words['g'] = 'gamma'
8
9 words.popitem()
10
11 print(words)
12 # {'a': 'alfa', 'b': 'beta', 'd': 'delta'}

There is another way that we can delete elements, namely by using


del statement:

1 words = {
2 'a': 'alfa',
3 'b': 'beta',
4 'd': 'delta',
5 }
6
7 del words['b']
8
9 print(words) # {'a': 'alfa', 'd': 'delta'}

Length

We can get the length of a dictionary using len() just like with lists
and tuples:
Data types 50

1 words = {
2 'a': 'alfa',
3 'b': 'beta',
4 'd': 'delta',
5 }
6
7 print(len(words)) # 3

Membership

If we want to check whether a key is already part of a dictionary


so that we avoid overriding it, we can use the operator in and not
in just like with lists and tuples:

1 words = {
2 'a': 'alfa',
3 'b': 'beta',
4 'd': 'delta',
5 }
6
7 print('a' in words) # True
8 print('z' not in words) # True

Comprehension

We can use comprehension just like with lists to create dictionaries


in a quick way.
To help us with that, we are going to need to use a method called
items() that converts a dictionary into a list of tuples where the
element in index 0 is a key, whereas in position with index 1, we
have a value.
Let us first see the method items() in action:
Data types 51

1 points = {
2 'Festim': 50,
3 'Zgjim': 89,
4 'Durim': 73
5 }
6
7 elements = points.items()
8
9 print(elements) # dict_items([('Festim', 50), ('Zgjim', 8\
10 9), ('Durim', 73)])

Now let us create a new dictionary from this existing dictionary


points using comprehension. We can assume that a professor is in
a good mood and generous enough to reward each student with a
bonus of 10 points. We want to add these new points to each student
by saving these new points in a new dictionary:

1 points = {
2 'Festim': 50,
3 'Zgjim': 89,
4 'Durim': 73
5 }
6
7 elements = points.items()
8
9 points_modified = {key: value + 10 for (key, value) in el\
10 ements}
11
12 print(points_modified) # {'Festim': 60, 'Zgjim': 99, 'Du\
13 rim': 83}
Data types 52

Sets
Sets are unordered and unindexed collections of data. Since ele-
ments in sets are not ordered, we cannot access elements using
indexes or using the method get().
We can add tuples, but we cannot add dictionaries or lists in a set.
We cannot add duplicate elements in sets. This means that when we
want to remove duplicate elements from another type of collection,
we can make use of this uniqueness in sets.
Let us start creating our first set using curly brackets as follows:

1 first_set = {1, 2, 3}

We can also create sets using the set() constructor:

1 empty_set = set() # Empty set


2
3 first_set = set((1, 2, 3)) # We are converting a tuple i\
4 nto a set

Like all data structures, we can find the length of a set using the
method len():

1 print(len(first_set)) # 3

Adding elements

Adding one element in a set can be done using the method add():
Data types 53

1 my_set = {1, 2, 3}
2
3 my_set.add(4)
4
5 print(my_set) # {1, 2, 3, 4}

If we want to add more than one element, then we need to use


method update() and as an input for this method a list, tuple, string
or another set:

1 my_set = {1, 2, 3}
2
3 my_set.update([4, 5, 6])
4
5 print(my_set) # {1, 2, 3, 4, 5, 6}
6
7 my_set.update("ABC")
8
9 print(my_set) # {1, 2, 3, 4, 5, 6, 'A', 'C', 'B'}

Deleting elements

If we want to delete elements from sets, we can use methods


discard() or remove():

1 my_set = {1, 2, 3}
2
3 my_set.remove(2)
4
5 print(my_set) # {1, 3}

If we try to delete an element that is not part of the set using


remove(), then we are going to get an error:
Data types 54

1 my_set = {1, 2, 3}
2
3 my_set.remove(4)
4
5 print(my_set) # KeyError: 4

To avoid such errors when removing elements from sets, we can


use the method discard():

1 my_set = {1, 2, 3}
2
3 my_set.discard(4)
4
5 print(my_set) # {1, 2, 3}

Set Theory Operations

If you remember high school math lessons, you should already


know union, intersection, and the difference between two sets of
elements. These operations are supported for sets in Python as well.

Union

Union represents the collection of all unique elements from both


sets. We can find the union of two sets using the pipe operator | or
the union() method:

1 first_set = {1, 2}
2 second_set = {2, 3, 4}
3
4 union_set = first_set.union(second_set)
5
6 print(union_set) # {1, 2, 3, 4}
Data types 55

Intersection

Intersection represents the collection that contains elements


that are in both sets. We can find it using operator & or the
intersection() method:

1 first_set = {1, 2}
2 second_set = {2, 3, 4}
3
4 intersection_set = first_set.intersection(second_set)
5
6 print(union_set) # {2}

Difference

The difference between two sets represents the collection that


contains only the elements that are in the first set, but not in the
second. We can find the difference between two sets using the -
operator or the method difference()

1 first_set = {1, 2}
2 second_set = {2, 3, 4}
3
4 difference_set = first_set.difference(second_set)
5
6 print(difference_set) # {1}

As you can probably remember from high school, ordering of sets


when we find the difference of two sets matters, which is not the
case with the union and intersection.
This is similar to arithmetic, where 3 - 4 is not equal to 4 - 3:
Data types 56

1 first_set = {1, 2}
2 second_set = {2, 3, 4}
3
4 first_difference_set = first_set.difference(second_set)
5
6 print(first_difference_set) # {1}
7
8 second_difference_set = second_set.difference(first_set)
9
10 print(second_difference_set) # {3, 4}
Type Conversions
Conversions between primitive
types
Python is an object oriented programming. That is why it uses
constructor functions of classes to do conversions from one type
into another.

int()

int() is a method that is used to do a conversion of an integer literal,


float literal (rounding it to its previous integer number, i.e. 3.1 to 3),
or a string literal (with the condition that the string represents an
int or float literal): :

1 three = int(3) # converting an integer literal into an i\


2 nteger
3 print(three) # 3
4
5 four = int(4.8) # converting a float number into its pre\
6 vious closest integer
7 print(four) # 4
8
9 five = int('5') # converting a string into an integer
10 print(five) # 5

float()

float() is similarly used to create float numbers from an integer,


float, or string literal (with the condition that the string represents
an int or float literal):
Type Conversions 58

1 int_literal = float(5)
2 print(int_literal) # 5.0
3
4 float_literal = float(1.618)
5 print(float_literal) # 1.618
6
7 string_int = float("40")
8 print(string_int) # 40.0
9
10 string_float = float("37.2")
11 print(string_float) # 37.2

str()

str() can be used to create strings from strings, integer literals,


float literals, and many other data types:

1 int_to_string = str(3)
2 print(int_to_string) # '3'
3
4 float_to_string = str(3.14)
5 print(float_to_string) # '3.14'
6
7 string_to_string = str('hello')
8 print(string_to_string) # 'hello'

Other Conversions
The way we can convert from one type of data structure into
another type is the following:

1 destination_type(input_type)

Let us get started with specific types, so that it becomes much


clearer.
Type Conversions 59

Conversions to lists

We can convert a set, tuple, or dictionary into a list using the list()
constructor.

1 books_tuple = ('Book 1', 'Book 2', 'Book 3')


2 tuple_to_list = list(books_tuple) # Converting tuple to \
3 list
4 print(tuple_to_list) # ['Book 1', 'Book 2', 'Book 3']
5
6
7 books_set = {'Book 1', 'Book 2', 'Book 3'}
8 set_to_list = list(books_set) # Converting set to list
9 print(set_to_list) # ['Book 1', 'Book 2', 'Book 3']

When converting a dictionary into a list, only its keys are going to
make it into a list:

1 books_dict = {'1': 'Book 1', '2': 'Book 2', '3': 'Book 3'}
2 dict_to_list = list(books_dict) # Converting dict to list
3 print(dict_to_list) # ['1', '2', '3']

If we want to keep both keys and values of a dictionary, we need to


use the method items() to first convert it into a list of tuples where
each tuple is a key and a value:

1 books_dict = {'1': 'Book 1', '2': 'Book 2', '3': 'Book 3'}
2
3 dict_to_list = list(books_dict.items()) # Converting dic\
4 t to list
5
6 print(dict_to_list)
7 # [('1', 'Book 1'), ('2', 'Book 2'), ('3', 'Book 3')]
Type Conversions 60

Conversions to tuples

All data structures can be converted to a tuple using the tuple()


constructor method, including a dictionary in which case we get a
tuple with the keys of the dictionary:

1 books_list = ['Book 1', 'Book 2', 'Book 3']


2 list_to_tuple = tuple(books_list) # Converting tuple to \
3 tuple
4 print(list_to_tuple) # ('Book 1', 'Book 2', 'Book 3')
5
6
7 books_set = {'Book 1', 'Book 2', 'Book 3'}
8 set_to_tuple = tuple(books_set) # Converting set to tuple
9 print(set_to_tuple) # ('Book 1', 'Book 2', 'Book 3')
10
11
12 books_dict = {'1': 'Book 1', '2': 'Book 2', '3': 'Book 3'}
13 dict_to_tuple = tuple(books_dict) # Converting dict to t\
14 uple
15 print(dict_to_tuple) # ('1', '2', '3')

Conversions to sets

Similarly, all data structures can be converted to a set using the


set() constructor method, including a dictionary in which case we
get a set with the keys of the dictionary:
Type Conversions 61

1 books_list = ['Book 1', 'Book 2', 'Book 3']


2 list_to_set = set(books_list) # Converting list to set
3 print(list_to_set) # {'Book 2', 'Book 3', 'Book 1'}
4
5
6 books_tuple = ('Book 1', 'Book 2', 'Book 3')
7 tuple_to_set = set(books_tuple) # Converting tuple to set
8 print(tuple_to_set) # {'Book 2', 'Book 3', 'Book 1'}
9
10
11 books_dict = {'1': 'Book 1', '2': 'Book 2', '3': 'Book 3'}
12 dict_to_set = set(books_dict) # Converting dict to set
13 print(dict_to_set) # {'1', '3', '2'}

Conversions to dictionaries

Conversions into dictionaries cannot be done with any type of sets,


lists, or tuples, since dictionaries represent data structures where
each element contains both a key and a value.
Converting a list, or a tuple into a dictionary can be done if each
element in a list is also a list with two elements, or a tuple with two
elements.

1 books_tuple_list = [(1, 'Book 1'), (2, 'Book 2'), (3, 'Bo\


2 ok 3')]
3 tuple_list_to_dictionary = dict(books_tuple_list) # Conv\
4 erting list to set
5 print(tuple_list_to_dictionary) # {1: 'Book 1', 2: 'Book\
6 2', 3: 'Book 3'}
7
8 books_list_list = [[1, 'Book 1'], [2, 'Book 2'], [3, 'Boo\
9 k 3']]
10 tuple_list_to_dictionary = dict(books_list_list) # Conve\
11 rting list to set
Type Conversions 62

12 print(tuple_list_to_dictionary) # {1: 'Book 1', 2: 'Book\


13 2', 3: 'Book 3'}
14
15
16 books_tuple_list = ([1, 'Book 1'], [2, 'Book 2'], [3, 'Bo\
17 ok 3'])
18 tuple_list_to_set = dict(books_tuple_list) # Converting \
19 tuple to set
20 print(tuple_list_to_set) # {'Book 2', 'Book 3', 'Book 1'}
21
22 books_list_list = ([1, 'Book 1'], [2, 'Book 2'], [3, 'Boo\
23 k 3'])
24 list_list_to_set = dict(books_list_list) # Converting tu\
25 ple to set
26 print(list_list_to_set) # {'Book 2', 'Book 3', 'Book 1'}

In case when we want to convert a set into a dictionary, we need


to have each element as a tuple of length 2.

1 books_tuple_set = {('1', 'Book 1'), ('2', 'Book 2'), ('3'\


2 , 'Book 3')}
3 tuple_set_to_dict = dict(books_tuple_set) # Converting d\
4 ict to set
5 print(tuple_set_to_dict) # {'1', '3', '2'}

If we try to do a conversion of a set that has each element as a list


of length 2 into a dictionary, we are going to get an error:

1 books_list_set = {['1', 'Book 1'], ['2', 'Book 2'], ['3',\


2 'Book 3']}
3 list_set_to_dict = dict(books_list_set) # Converting dic\
4 t to set
5 print(list_set_to_dict) # {'1', '3', '2'}

After we run the last code block, we are going to get an error:
Type Conversions 63

1 TypeError: unhashable type: 'list'

Wrap Up
In conclusion, Python has a variety of data types that you can use
to store data. These data types are important to know so that you
can choose the right one for your needs. Be sure to use the right
data type for the task that is in front of you to avoid errors and
optimize performance.
Control Flow
Conditional statements

When you think about ways we think and also communicate with
each other, you may get the impression that we are indeed always
using conditions.

• If it’s 8 am, I take the bus and go to work.


• If I am hungry, I eat.
• If this item is cheap, I can afford it.

This is also something that you can do in programming. We can use


conditions to control the flow of the execution.
To do that, we use the reserved term if and an expression that
evaluates to a True or False value. We can then also use an else
statement where we want the flow to continue in cases when the
if condition is not met.
To make it easier to understand, let us assume that we have an
example where we want to check whether a number is positive:

1 if number > 0:
2 print("The given number is positive")
3 else:
4 print("The given number is not positive")

If we were to have number = 2: we would enter into the if branch


and execute the command that is used to print the following text
in the console:
Control Flow 65

1 The given number is positive

If we would have another number, such as -1, we would see in the


console the following message being printed:

1 The given number is not positive

We can also add additional conditions and not just 2 like above
by using elif which is evaluated when the if expression is not
evaluated.
Let us see an example to make it easier for you to understand it:

1 if number > 0:
2 print("The given number is positive")
3 elif number == 0:
4 print("The given number is 0")
5 else:
6 print("The given number is negative")

Now if we were to have number = 0, the first condition is not


going to be met, since the value is not greater than 0. As you can
guess, since the given number is equal to 0, we are going to see the
following message being printed in the console:

1 The given number is 0

In cases when the value is negative, our program is going to pass


the first two conditions since they are not satisfied and then jump
into the else branch and print the following message in the console:

1 The given number is negative


Control Flow 66

Looping / Iterator

Looping represents the ability of the program to execute a set of


instructions over and over again until a certain condition is met.
We can do it with both while and for.
Let us first see the iteration with for.

for loop

This looping is simple and quite straightforward. All you have to do


is specify a starting state and mention the range in which it should
iterate, as can be seen in the following example:

1 for number in range(1, 7):


2 print(number)

In this example, we are iterating from 1 to 7 and printing each


number (from 1 up to 7 excluding 7) in the console.
We can change both the starting and the ending numbers in the
range as we want. This way, we can be quite flexible depending on
our specific scenarios.

while loop

Let us now describe iterations with while. This is also another way
of doing iterations that is also quite straightforward and intuitive.
Here we need to specify a starting condition before the while block
and also update the condition accordingly. The while loop needs
a “loop condition.” If it stays True, it continues iterating. In this
example, when num is 11 the loop condition equals False.
Control Flow 67

1 number = 1
2
3 while number < 7:
4 print(number)
5 number += 1 # This part is necessary for us to add s\
6 o that the iteration does not last forever

This while block is going to print the same statements as the code
we used with the for block.

Iteration: Looping Through Data Structures

Now that we have covered both iteration and lists, we can jump
into ways of iterating through lists.
We do not just store things into data structures and leave them
there for ages. We are supposed to be able to use those elements
in different scenarios.
Let us take our list of students from before:

1 students = ["Albert", "Besart", "Fisnik", "Festim", "Gazm\


2 end"]

Now, to iterate through the list, we can simply type:

1 for student in students:


2 print(student)

Yes, it’s that simple. We are iterating through each element in the
list and printing their values.
We can do this for dictionaries as well. However, since elements in
dictionaries have 2 parts (key and the value), we need to specify
both the key and the value as follows:
Control Flow 68

1 german_to_english_dictionary = {
2 "Wasser": "Water",
3 "Brot": "Bread",
4 "Milch": "Milk"
5 }
6
7 for key, value in german_to_english_dictionary:
8 print("The German word " + key + " means " + value + \
9 " in English")

We can also get only keys from the elements of the dictionary:

1 for key in german_to_english_dictionary:


2 print(key)

Note that key and value are simply variable names that we have
chosen to illustrate the iteration, but we can use any name that we
want for our variables, such as the following example:

1 for german_word, english_translation in german_to_english\


2 _dictionary:
3 print("The German word " + german_word + " means " + \
4 english_translation + " in English")

This iteration is going to print the same thing in the console as the
code block before the last one.
We can also have nested for loops, for example, let us say that we
want to iterate through a list of numbers and finding a sum of each
element with each other element of a list. We can do that using
nested for loops:
Control Flow 69

1 numbers = [1, 2, 3]
2 sum_of_numbers = [] # Empty list
3
4 for first_number in numbers:
5 for second_number in numbers: # Loop through the lis\
6 t and add the numbers
7 current_sum = first_number + second_number
8 # add current first_number from the first_list to\
9 the second_number from the second_list
10 sum_of_numbers.append(current_sum)
11
12
13 print(sum_of_numbers)
14 # [2, 3, 4, 3, 4, 5, 4, 5, 6]

Stopping a for-loop

Sometimes we may need to exit a for loop before it reaches the end.
This may be the case when a condition has been met or we have
found what we were looking for and there is no need to continue
any further.
In those situations, we can use break to stop any other iteration of
the for loop.
Let us assume that we want to check whether there is a negative
number in a list. In case we find that positive number, we stop
searching for it.
Let us implement this using break:
Control Flow 70

1 my_list = [1, 2, -3, 4, 0]


2
3 for element in my_list:
4 print("Current number: ", element)
5 if element < 0:
6 print("We just found a negative number")
7 break
8
9 # Current number: 1
10 # Current number: 2
11 # Current number: -3
12 # We just found a negative number

As we can see, the moment we reach -3, we break from the for loop
and stop.

Skipping an iteration

There can also be cases when we want to skip certain iterations


since we are not interested in them and they do not matter that
much. We can do that using continue which prevents code execu-
tion below it in that code block and moves the execution procedure
towards the next iteration:

1 my_sum = 0
2 my_list = [1, 2, -3, 4, 0]
3
4 for element in my_list:
5 if element < 0: # Do not include negative numbers in\
6 the sum
7 continue
8 my_sum += element
9
10 print(my_sum) # 7
Control Flow 71

pass is a statement that can be used to help us when we are about


to implement a method, or something but haven’t done yet and do
not want to get errors.
It helps us execute the program even if some parts of the code are
missing:

1 my_list = [1, 2, 3]
2
3 for element in my_list:
4 pass # Do nothing

Wrap Up
In conclusion, Python offers conditional statements to help you
control the flow of your program. The if statement lets you run a
block of code only if a certain condition is met. The elif statement
lets you run a block of code only if another condition is met. And
the else statement lets you run a block of code only if no other
condition is met. These statements are very useful for controlling
the flow of your program.
Functions
There can be plenty of cases when we need to use the same code
block again and again. Our first guess would be to write it as many
times as we want.
Objectively, it does work, but the truth is, this is a really bad
practice. We are doing repetitive work that can be quite boring and
also prone to many mistakes that can be overlooked.
This is the reason why we need to start using code blocks that we
can define once and use that same code of instructions anywhere
else.
Just think about this in real life: You see a YouTube video that has
been recorded and uploaded to YouTube once. It is then going to
be watched by many other people, but the video still remains the
same one that was uploaded initially.
In other words, we use methods as a representative of a set of coding
instructions that are then supposed to be called anywhere else in
the code and that we do not have to write it repeatedly. In cases
when we want to modify this method, we simply change it at the
place where it was first declared and other places where it is called
do not have to do anything.
To define a method in Python, we start by using the def keyword,
then the name of the function and then a list of arguments that we
expect to be used. After that, we need to start writing the body of
the method in a new line after an indentation.

1 def add(first_number, second_number):


2 our_sum = first_number + second_number
3 return our_sum
Functions 73

As you can see from the coloring, both defand return are keywords
in Python that you cannot use to name your variables.
Now, everywhere we want this add() to be called, we can just call
it there and not have to worry about implementing it entirely.
Since we have defined this method, we can call it in the following
way:

1 result = add(1, 5)
2
3 print(result) # 6

You can get the impression that this is such a simple method and
start asking, why are we even bothering to write a method for it?
You are right. This was a very simple method just to get you
introduced to the way we can implement functions.
Let us write a function that finds the sum of numbers that are
between two specified numbers:

1 def sum_in_range(starting_number, ending_number):


2 result = 0
3
4 while starting_number < ending_number:
5 result = result + starting_number
6 starting_number = starting_number + 1
7
8 return result

This is now a set of instructions that you can call in other places
and do not have to write all of it again.
Functions 74

1 result = sum_in_range(1, 5)
2
3 print(result) # 10

Note that functions define a scope such that variables that are
defined in there are not accessible outside. For example, we cannot
access the variable named product outside the scope of the function:

1 def multiply_in_range(starting_number, ending_number):


2 product = 1
3 while starting_number < ending_number:
4 product = product * starting_number
5 starting_number = starting_number + 1
6 return product

product is accessible only inside the body of this method.

Default arguments
When we call functions, we may make some of the arguments
optional by writing an initial value for them at the header of the
function.
Let us take an example of getting a user’s first name as a required
argument and let the second argument be an optional one.

1 def get_user(first_name, last_name=""):


2 return f"Hi {first_name} {last_name}"

Let us now call this function with both arguments:


Functions 75

1 user = get_user("Durim", "Gashi")


2
3 print(user) # Hi Durim Gashi

We can now call that same function even though the the second
argument is not specified:

1 user = get_user("Durim")
2
3 print(user) # Hi Durim

Keyword argument list


We can define arguments of functions as keywords:

1 # The first argument is required. The other two are optio\


2 nal
3 def get_user(number, first_name='', last_name=''):
4 return f"Hi {first_name} {last_name}"

Now, we can call this function by writing arguments as keywords:

1 user = get_user(1, last_name="Gashi")


2
3 print(user) # Hi Gashi

As you can see, we can omit first_name since it is not required.


We can also change the ordering of the arguments when calling the
function and it will still work the same:
Functions 76

1 user = get_user(1, last_name="Gashi", first_name='Durim')


2
3 print(user) # Hi Durim Gashi

Data lifecycle
Variables that are declared inside a function cannot be accessed
outside it. They are isolated.
Let us see an example to illustrate this:

1 def counting():
2 count = 0 # This is not accessible outside of the fu\
3 nction.
4
5
6 counting()
7
8 print(count) # This is going to throw an error when exec\
9 uting, since count is only declared inside the function a\
10 nd is not acessible outside that

Similarly, we cannot change variables inside functions that have


been declared outside functions and that are not passed as argu-
ments:
Functions 77

1 count = 3331
2
3
4 def counting():
5 count = 0 # This is a new variable
6
7
8 counting()
9
10 print(count) # 3331
11 # This is declared outside the function and has not been \
12 changed

Changing data inside functions


We can change mutable data that is passed through a function as
arguments. Mutable data represents data that can be modified even
after it has been declared, for example lists are mutable data.

1 names = ["betim", "durim", "gezim"]


2
3
4 def capitalize_names(current_list):
5
6 for i in range(len(current_list)):
7 current_list[i] = current_list[i].capitalize()
8
9 print("Inside the function:", current_list)
10
11 return current_list
12
13
14 capitalize_names(names) # Inside the function: ['Betim',\
15 'Durim', 'Gezim']
Functions 78

16
17 print("Outside the function:", names) # Outside the func\
18 tion: ['Betim', 'Durim', 'Gezim']

In case of immutable data, we can only modify the variable inside


a function, but the actual value outside that function is going to
remain unchanged. Immutable data are strings and numbers:

1 name = "Betim"
2
3
4 def say_hello(current_param):
5 current_param = current_param + " Gashi"
6 name = current_param # name is a local variable
7 print("Value inside the function:", name)
8 return current_param
9
10
11 say_hello(name) # Value inside the function: Betim Gashi
12
13 print("Value outside the function:", name) # Value outsi\
14 de the function: Betim

If we really want to update immutable variables through a function,


we can assign a returning value of a function to the immutable
variable:
Functions 79

1 name = "Betim"
2
3
4 def say_hello(current_param):
5 current_param = current_param + " Gashi"
6 name = current_param # name is a local variable
7 print("Value inside the function", name)
8 return current_param
9
10
11 # Here we are assigning the value of name to the current_\
12 param that is returned from the function
13 name = say_hello(name) # Value inside the function Betim\
14 Gashi
15
16 # Value outside the function: Betim Gashi
17 print("Value outside the function:", name)

Lambda functions
Lambda functions are anonymous functions that can be used to re-
turn an output. We can write lambda functions using the following
syntax pattern:

1 lambda parameters: expression

The expression can only be written in a single line.


Let us start illustrating these anonymous functions using a few
examples.
Let us start with a function that multiples each input with 10:
Functions 80

1 tenfold = lambda number : number * 10


2
3 print(tenfold(10)) # 100

Let us write another example in which we check whether the given


argument is positive or not:

1 is_positive = lambda a : f'{a} is positive' if a > 0 els\


2 e f'{a} is not positive'
3
4
5 print(is_positive(3)) # 3 is positive
6
7 print(is_positive(-1)) # -1 is not positive

Note that we cannot use the if clause without the else clause inside
a lambda function.
At this point, you may wonder, why do we need to use lambda
functions, since they seem to be almost the same as other functions.
We can see that illustrated in the following section.

Functions as arguments of functions

So far, we have seen ways of calling functions using numbers and


strings. We can actually call functions with any type of Python
object.
We can even provide as argument of a function an entire function,
which can provide a level of abstraction that can be quite useful.
Let us see an example where we want to do a few conversions from
one unit into another:
Functions 81

1 def convert_to_meters(feet):
2 return feet * 0.3048
3
4
5 def convert_to_feet(meters):
6 return meters / 0.3048
7
8
9 def convert_to_miles(kilometers):
10 return kilometers / 1.609344
11
12
13 def convert_to_kilometers(miles):
14 return miles * 1.609344

Now, we can make a general function and pass another function as


an argument:

1 def conversion(operation, argument):


2 return operation(argument)

We can now call conversion() like the following:

1 result = conversion(convert_to_miles, 10)


2
3 print(result) # 6.2137119223733395

As you can see, we have written convert_to_miles as a parameter


of the function conversion(). We can use other already defined
functions like that:
Functions 82

1 result = conversion(convert_to_feet, 310)


2
3 print(result) # 1017.0603674540682

We can now make use of lambdas and make this type of abstraction
much simpler.
Instead of writing all those four functions, we can simply write a
concise lambda function and use it as a parameter when calling the
conversion() function:

1 def conversion(operation, argument):


2 return operation(argument)
3
4
5 result = conversion(lambda kilometers: kilometers / 1.609\
6 344, 10)
7
8 print(result) # 6.2137119223733395

This is of course simpler.


Let us use a few other examples with built-in functions.

map()

map() is a built-in function that creates a new object by getting


results by calling a function on each element of an existing list:

1 map(function_name, my_list)

Let us see an example of writing a lambda function as the function


of a map.
Let us triple each number in a list using list comprehension:
Functions 83

1 my_list = [1, 2, 3, 4]
2
3 triple_list = [x * 3 for x in my_list]
4
5 print(triple_list) # [3, 6, 9, 12]

We can implement that using a map() function and a lambda


function:

1 my_list = [1, 2, 3, 4]
2
3 triple_list = map(lambda x: x * 3, my_list)
4
5 print(triple_list) # [3, 6, 9, 12]

This creates a new list. The old list is not changed.

filter()

This is another built-in function that can be used to filter elements


of a list that satisfy a condition.
Let us first filter out negative elements from a list using list
comprehension:

1 my_list = [3, -1, 2, 0, 14]


2
3 non_negative_list = [x for x in my_list if x >= 0]
4
5 print(non_negative_list) # [3, 2, 0, 14]

Now, let us filter elements using filter() and a lambda function.


This function returns an object which we can convert into a list
using list():
Functions 84

1 my_list = [3, -1, 2, 0, 14]


2
3 non_negative_filter_object = filter(lambda x: x >= 0, my_\
4 list)
5
6 non_negative_list = list(non_negative_filter_object)
7
8 print(non_negative_list) # [3, 2, 0, 14]

This should now give you the intuition needed on how you can call
functions with other functions as arguments and why lambdas are
useful and important.

Decorators
A decorator represents a function that accepts another function as
an argument.
We can think of it as a dynamic way of changing the way a function,
method, or class behaves without having to use subclasses.
Once a function is being passed as an argument to a decorator, it
will be modified and then returned as a new function.
Let us start with a basic function that we want to decorate:

1 def reverse_list(input_list):
2 return input_list[::-1]

In this example, we are simply returning a reversed list.


We can also write a function that accepts another function as an
argument:
Functions 85

1 def reverse_list(input_list):
2 return input_list[::-1]
3
4
5 def reverse_input_list(another_function, input_list):
6 # we are delegating the execution to another_function\
7 ()
8 return another_function(input_list)
9
10
11 result = reverse_input_list(reverse_list, [1, 2, 3])
12
13 print(result) # [3, 2, 1]

We can also nest a function into another function:

1 def reverse_input_list(input_list):
2 # reverse_list() is now a local function that is not \
3 accessible from the outside
4 def reverse_list(another_list):
5 return another_list[::-1]
6
7 result = reverse_list(input_list)
8 return result # Return the result of th\
9 e local function
10
11
12 result = reverse_input_list([1, 2, 3])
13 print(result) # [3, 2, 1]

In this example, reverse_list() now is a local function and cannot


be called outside the scope of the reverse_input_list() function.
Now we can write our first decorator:
Functions 86

1 def reverse_list_decorator(input_function):
2 def function_wrapper():
3 returned_result = input_function()
4 reversed_list = returned_result[::-1]
5 return reversed_list
6
7 return function_wrapper

reverse_list_decorator() is a decorator function that takes as


input another function. To call it, we need to write another function:

1 # Function that we want to decorate


2 def get_list():
3 return [1, 2, 3, 4, 5]

Now we can call the decorator with our new function as an


argument:

1 decorator = reverse_list_decorator(get_list) # This retu\


2 rns a reference to the function
3
4 result_from_decorator = decorator() # Here we call the a\
5 ctual function using parenthesis
6
7 # We can now print the result in the console
8 print(result_from_decorator) # [5, 4, 3, 2, 1]

Here is the complete example:


Functions 87

1 def reverse_list_decorator(input_function):
2 def function_wrapper():
3 returned_result = input_function()
4 reversed_list = returned_result[::-1]
5 return reversed_list
6
7 return function_wrapper
8
9 # Function that we want to decorate
10 def get_list():
11 return [1, 2, 3, 4, 5]
12
13
14 # This returns a reference to the function
15 decorator = reverse_list_decorator(get_list)
16
17 # Here we call the actual function using the parenthesis
18 result_from_decorator = decorator()
19
20 # We can now print the result in the console
21 print(result_from_decorator) # [5, 4, 3, 2, 1]

We can also call a decorator using annotations. To do that, we use


the @ sign before the name of the decorator that we want to call and
put it right above the name of the function:

1 # Function that we want to decorate


2 @reverse_list_decorator # The annotation of the decorato\
3 r function
4 def get_list():
5 return [1, 2, 3, 4, 5]

Now, we can simply call the functionget_list() and the decorator


is going to be applied in it:
Functions 88

1 result_from_decorator = get_list()
2
3 print(result_from_decorator) # [5, 4, 3, 2, 1]

Stacking decorators

We can also use more than one decorator for a single function.
Their order of execution starts from top to bottom, meaning that
the decorator that has been defined first is applied first, then the
second one, and so on.
Let us do a simple experiment and apply the same decorator that
we defined in the previous section twice.
Let us first understand what does that mean.
So we first call the decorator to reverse a list:
[1, 2, 3, 4, 5] to [5, 4, 3, 2, 1]
Then we apply it again, but now with the returned result from the
previous calling of the decorator:
[5, 4, 3, 2, 1] ⇒ [1, 2, 3, 4, 5]

In other words, reversing a list and then reversing that reversed list
again is going to return the original ordering of the list.
Let us see this with decorators:

1 @reverse_list_decorator
2 @reverse_list_decorator
3 def get_list():
4 return [1, 2, 3, 4, 5]
5
6
7 result = get_list()
8
9 print(result) # [1, 2, 3, 4, 5]
Functions 89

Let us explain this with another example.


Let us implement another decorator that only returns numbers that
are larger than 1. We then want to reverse that return list with our
existing decorator.

1 def positive_numbers_decorator(input_list):
2 def function_wrapper():
3 # Get only numbers larger than 0
4 numbers = [number for number in input_list() if n\
5 umber > 0]
6 return numbers
7
8 return function_wrapper

Now we can call this decorator and the other decorator that we
have implemented:

1 @positive_numbers_decorator
2 @reverse_list_decorator
3 def get_list():
4 return [1, -2, 3, -4, 5, -6, 7, -8, 9]
5
6
7 result = get_list()
8 print(result) # [9, 7, 5, 3, 1]

Here is the complete example:


Functions 90

1 def reverse_list_decorator(input_function):
2 def function_wrapper():
3 returned_result = input_function()
4 reversed_list = returned_result[::-1] # Reverse \
5 the list
6 return reversed_list
7
8 return function_wrapper
9
10
11 # First decoorator
12 def positive_numbers_decorator(input_list):
13 def function_wrapper():
14 # Get only numbers larger than 0
15 numbers = [number for number in input_list() if n\
16 umber > 0]
17 return numbers
18
19 return function_wrapper
20
21 # Function that we want to decorate
22
23
24 @positive_numbers_decorator
25 @reverse_list_decorator
26 def get_list():
27 return [1, -2, 3, -4, 5, -6, 7, -8, 9]
28
29
30 result = get_list()
31 print(result) # [9, 7, 5, 3, 1]

Passing arguments in decorator functions


We can also pass arguments to decorator functions:
Functions 91

1 def add_numbers_decorator(input_function):
2 def function_wrapper(a, b):
3 result = 'The sum of {} and {} is {}'.format(
4 a, b, input_function(a, b)) # calling the in\
5 put function with arguments
6 return result
7 return function_wrapper
8
9
10 @add_numbers_decorator
11 def add_numbers(a, b):
12 return a + b
13
14
15 print(add_numbers(1, 2)) # The sum of 1 and 2 is 3

Built-in decorators

Python comes with multiple built-in decorators, such as


@classmethod, @staticmethod, @property, etc. These are covered in
the next chapter.

Wrap up
In conclusion, Python is an excellent language for writing functions
because they are easy to write.
Lambda functions are a great way to make small, concise functions
in Python. They’re perfect for when you don’t need a full-blown
function, or when you just want to test out a snippet of code.
Python decorators are a great way to improve code readability
and maintainability. They allow you to modularize your code and
make it more organized. Additionally, they can be used to perform
Functions 92

various tasks such as logging, exception handling, and testing. So


if you’re looking for a way to clean up your Python code, consider
using decorators.
Object Oriented
Programming
If you go to buy a cookie at a local store, you are going to get a piece
of the cookie that has been produced in many other copies.
There has been a cookie cutter at a factory that has been used to
produce a large number of cookies that are then distributed all
throughout different stores where it is then ready to be served to
the end customers.
We can think of that cookie cutter as a blueprint that has been
designed once and is used afterwards. This blueprint is also used
in computer programming.
A blueprint that is used to create countless other copies is called a
class. We can think of a class like a class called Cookie, Factory,
Building, Book, Pencil, etc. We can use the class of Pencil as a
blueprint to create as many instances as we want of it that we call
objects.
In other words, blueprints are classes that are used as cookie cutters,
whereas the cookies that are served at different stores are objects.
Object Oriented Programming represents a way of organizing a
program in classes and objects. Classes are used to create objects.
Objects interact with each other.
We do not use the exact same blueprint for every object that is out
there. There is a blueprint for producing books, another one for
producing pencils, and so on. We need to categorize them based on
attributes and their functionalities.
An object that is created from the Pencil class can have a color type,
a manufacturer, a specific thickness, etc. These are the attributes.
Object Oriented Programming 94

A pencil object can also write which represents its functionality, or


its method.
We use classes and objects in different programming languages,
including Python.
Let us see how does a very basic Bicycle class look like in Python:

1 class Bicycle:
2 pass

We have used the keyword class to indicate that we are about to


start writing a class and then we type the name of the class.
We have added the part pass because we want to let Python
interpreter to not yell at us by throwing errors for not continuing
to write the remaining part of the code that belongs to this class.
Now, if we want to create new objects from this class Bicycle, we
can simply write the name of the object (which can be any variable
name that you want) and initiailize it with the constructor method
Bicycle() that is used to create new objects:

1 favorite_bike = Bicycle()

In this case, favorite_bike is an object that is created from the class


Bicycle. It gets all the functionalities and attributes of the class
Bicycle.
We can enrich our Bicycle class and include additional attributes so
that we can have custom bikes, tailored to our needs.
To do that, we can define a constructor method called init as
follows:
Object Oriented Programming 95

1 class Bicycle:
2 def __init__(self, manufacturer, color, is_mountain_b\
3 ike):
4 self.manufacturer = manufacturer
5 self.color = color
6 self.is_mountain_bike = is_mountain_bike

Note the usage of underscores before and after the name init of
the method. They represent indicators to the Python interpreter to
treat that method as a special method.
This is a method that does not return anything. It is a good practice
to define it as the first method of the class, so that other developers
can also see it being at a specific line.
Now, if we want to create new objects using this blueprint of
bicycles, we can simply write:

1 bike = Bicycle("Connondale", "grey", True)

We have provided our custom parameters for this bike and are
passing them to the constructor method so that we get a new bike
with those specific attributes in return. As you can probably tell,
we are creating a grey, mountain bike of the brand Connondale.
We can also create objects from classes by using optional arguments
as follows:

1 class Bicycle:
2 # All the following attributes are optional
3 def __init__(self, manufacturer=None, color='grey', i\
4 s_mountain_bike=False):
5 self.manufacturer = manufacturer
6 self.color = color
7 self.is_mountain_bike = is_mountain_bike
Object Oriented Programming 96

Now we have just created this object with these attributes, which
are not currently accessible outside the scope of the class. This
means that we have created this new object from the Bicycle class,
but its corresponding attributes are not accessible. To access them,
we can implement methods that help us access them.
To do that, we are going to define getters and setters, which
represent methods that are used to get and set values of attributes
of objects. We are going to use an annotation called @property to
hep us with that.
Let’s see it with code:

1 class Bicycle:
2 def __init__(self, manufacturer, color, is_mountain_b\
3 ike):
4 self._manufacturer = manufacturer
5 self._color = color
6 self._is_mountain_bike = is_mountain_bike
7
8 @property
9 def manufacturer(self):
10 return self._manufacturer
11
12 @manufacturer.setter
13 def manufacturer(self, manufacturer):
14 self._manufacturer = manufacturer
15
16
17 bike = Bicycle("Connondale", "Grey", True)
18
19 print(bike.manufacturer) # Connondale

We can write getters and setters for all the attributes of the class:
Object Oriented Programming 97

1 class Bicycle:
2 def __init__(self, manufacturer, color, is_mountain_b\
3 ike):
4 self._manufacturer = manufacturer
5 self._color = color
6 self._is_mountain_bike = is_mountain_bike
7
8 @property
9 def manufacturer(self):
10 return self._manufacturer
11
12 @manufacturer.setter
13 def manufacturer(self, manufacturer):
14 self._manufacturer = manufacturer
15
16 @property
17 def color(self):
18 return self._color
19
20 @color.setter
21 def color(self, color):
22 self._color = color
23
24 @property
25 def is_mountain_bike(self):
26 return self._is_mountain_bike
27
28 @is_mountain_bike.setter
29 def is_mountain_bike(self, is_mountain_bike):
30 self.is_mountain_bike = is_mountain_bike
31
32 bike = Bicycle("Connondale", "Grey", True)

Now that we have defined them, we can call these getter methods
as attributes:
Object Oriented Programming 98

1 print(bike.manufacturer) # Connondale
2 print(bike.color) # Grey
3 print(bike.is_mountain_bike) # True

We can also modify the value that we initially used for any attribute
by simply typing the name of object and the attribute that we want
to change the content:

1 bike.is_mountain_bike = False
2 bike.color = "Blue"
3 bike.manufacturer = "Trek"

Our classes can also have other methods as well and not just getters
and setters.
Let us define a method inside the class Bicycle that we can then call
from any object that we have created from that class:

1 class Bicycle:
2 def __init__(self, manufacturer, color, is_mountain_b\
3 ike):
4 self._manufacturer = manufacturer
5 self._color = color
6 self._is_mountain_bike = is_mountain_bike
7
8 def get_description(self):
9 desc = "This is a " + self._color + " bike of the\
10 brand " + self._manufacturer
11 return desc

We have created a very simple method in which we are preparing


a string as a result from the attributes of the object that we are
creating. We can then call this method like any other method.
Let us see this in action:
Object Oriented Programming 99

1 bike = Bicycle("Connondale", "Grey", True)


2
3 print(bike.get_description()) # This is a Grey bike of t\
4 he brand Connondale

Methods
Methods are similar to functions that we have already covered
before.
In a nutshell, we group a few statements in a code block called
method where we perform some operations that we expect to be
done more than once and do not want to write them again and
again. In the end, we may not return any result at all.
There are three types of methods in Python:

• instance methods
• class methods
• static methods

Let us briefly talk about the overall structure of methods and then
dive a little more into details for each method type.

Parameters

Parameters of a method make it possible for us to pass on dynamic


values that can then be taken into consideration when executing
the statements that are inside the method.
The return statement represents the statement that is going to be
the last one to be executed in that method since it is an indicator
for the Python interpreter to stop the execution of any other line
and return a value.
Object Oriented Programming 100

self argument

The first argument of a method in Python is self which is also one


of the differences between a method and a function. It represents a
reference to the object to which it belongs to. If it is not specified
as the first argument of the method when being declared, the first
argument is then treated as a reference to the object.
We only write it when we declare the method, but we do not need to
include it when we invoke that particular method using an object as
a caller. It is not required to be named as self, but it is a convention
that is widely practiced from developers writing Python code all
around the world.
Let us define an instance method inside the class Bicycle that we
can then call from any object that we have created from that class:

1 class Bicycle:
2 def __init__(self, manufacturer, color, is_mountain_b\
3 ike):
4 self._manufacturer = manufacturer
5 self._color = color
6 self._is_mountain_bike = is_mountain_bike
7
8 def get_description(self):
9 desc = "This is a " + self._color + " bike of the\
10 brand " + self._manufacturer
11 return desc

We have created a very simple method in which we are preparing


a string as a result from the attributes of the object that we are
creating. We can then call this method like any other method:
Object Oriented Programming 101

1 bike = Bicycle("Connondale", "Grey", True)


2
3 print(bike.get_description()) # This is a Grey bike of t\
4 he brand Connondale
5 # We are not passing any argument when calling the method\
6 get_description() since we do not need to include self a\
7 t all

Class methods
We have covered instance methods so far that are methods that we
can call with objects.
Class methods are methods that can be called using class names
and can be accessed without needing to create any new object at
all.
Since it is a specific type of method, we need to tell Python
interpreter that it is actually different. We do that by making a
change in the syntax.
We use the annotation @classmethod above a class method and cls
similar to the usage of self for instance methods. cls is just a
conventional way of referring to the class that is calling the method,
and not that you have to use this name.
Let us declare our first class method:

1 class Article:
2 blog = 'https://www.python.org/'
3
4 # the init method is called when an instance of the c\
5 lass is created
6 def __init__(self, title, content):
7 self.title = title
8 self.content = content
9
Object Oriented Programming 102

10 @classmethod
11 def get_blog(cls):
12 return cls.blog

Now let us call this class method that we have just declared:

1 print(Article.get_blog()) # https://www.python.org/

Note that we did not have to write any argument when calling get_-
blog() method. On the other hand, when we declare methods and
instance methods, we should always include at least one argument.

Static methods

These are methods that do not have direct relations to class vari-
ables or instance variables. They can be seen as utility function
that are supposed to help us do something with arguments that are
passed when calling them.
We can call them by using both the class name and an object that is
created by that class where this method is declared. This means that
they do not need to have their first argument related to the object or
class calling them which was the case with using parameters self
for instance methods and cls for class methods.
There is no limit to the number of arguments that we can use to
call them.
To create it, we need to use the @staticmethod annotation.
Let us create a static method:
Object Oriented Programming 103

1 class Article:
2 blog = 'https://www.python.org/'
3
4 # the init method is called when an instance of the c\
5 lass is created
6 def __init__(self, title, content):
7 self.title = title
8 self.content = content
9
10 @classmethod
11 def get_blog(cls):
12 return cls.blog
13
14 @staticmethod
15 def print_creation_date(date):
16 print(f'The blog was created on {date}')
17
18
19 article = Article('First Article', 'This is the first art\
20 icle')
21
22 # Calling the static method using the object
23 article.print_creation_date('2022-07-18') # The blog was\
24 created on 2022-07-18
25
26 # Calling the static method using the class name
27 Article.print_creation_date('2022-07-21') # The blog was\
28 created on 2022-07-21

Static methods cannot modify class or instance attributes. They are


meant to be like utility functions.
If we try to change a class, we are going to get errors:
Object Oriented Programming 104

1 class Article:
2 blog = 'https://www.python.org/'
3
4 # the init method is called when an instance of the c\
5 lass is created
6 def __init__(self, title, content):
7 self.title = title
8 self.content = content
9
10 @classmethod
11 def get_blog(cls):
12 return cls.blog
13
14 @staticmethod
15 def set_title(self, date):
16 self.title = 'A random title'

If we try to call this static method now, we are going to get an error:

1 # Calling the static method using the class name


2 Article.set_title('2022-07-21')

1 TypeError: set_title() missing 1 required positional argu\


2 ment: 'date'

This is because static methods do not have any reference to self as


they are not directly related to objects or classes and so they cannot
modify attributes.

Access modifier

When creating classes, we can restrict access to certain attributes


and methods so that they are not accessible that easily.
Object Oriented Programming 105

We have publicand private access modifiers.


Let us see both of them.

Public attributes

Public attributes are the ones that are accessible from both inside
and outside the class.
By default, all attributes and methods are public in Python. If we
want them to be private, we need to specify that.
Let us see an example of public attributes:

1 class Bicycle:
2 def __init__(self, manufacturer, color, is_mountain_b\
3 ike):
4 self.manufacturer = manufacturer
5 self.color = color
6 self.is_mountain_bike = is_mountain_bike
7
8 def get_manufacturer(self):
9 return self.manufacturer

In the previous code block, both color and get_manufacturer()


are accessible outside the class since they are public and can be
accessed both inside and outside the class:

1 bike = Bicycle("Connondale", "Grey", True)


2
3 print(bike.color) # Grey
4 print(bike.get_manufacturer()) # Connondale

Private attributes

Private attributes can be accessed directly only from inside the


class.
Object Oriented Programming 106

We can make properties attributes by using the double underscore,


as can be seen in the following example:

1 class Bicycle:
2 def __init__(self, manufacturer, color, is_mountain_b\
3 ike, old):
4 self.manufacturer = manufacturer
5 self.color = color
6 self.is_mountain_bike = is_mountain_bike
7 self.__old = old # This is a private property

Now if we try to access __old, we are going to get an error:

1 bike = Bicycle("Connondale", "Grey", True, False)


2
3 print(bike.__old) # AttributeError: 'Bicycle' object has\
4 no attribute '__old'

Let us now see an example where we are declaring private methods


using the double underscore in front of the name of the method that
we want to make as private:

1 class Bicycle:
2 def __init__(self, manufacturer, color, is_mountain_b\
3 ike, old):
4 self.manufacturer = manufacturer
5 self.color = color
6 self.is_mountain_bike = is_mountain_bike
7 self.__old = old # This is a private property
8
9 def __get_old(self): # This is a private method
10 return self.__old

Now, if we want to call this private method from outside the class,
an error is going to be thrown:
Object Oriented Programming 107

1 bike = Bicycle("Connondale", "Grey", True, False)


2
3 print(bike.__get_old()) # AttributeError: 'Bicycle' obje\
4 ct has no attribute '__get_old'

It is not a common practice to have private variables in Python.


However, developers may see it necessary to restrict access so that
specific variables are not carelessly accessed and modified.

Hiding information
When you go out there and use a coffee machine, it is not expected
from you to know all the engineering details that are behind that
machine. This is also with your car. When you sit in your driving
seat, you do not analyze and understand about all the details of
every part of the car. You have some basic idea about them, but
other than that, you are driving more freely.
This can be thought as a restriction of the access from people
outside, so that they do not have to worry about exact details that
are going on inside.
We can do that in Python as well.
We have seen so far the foundational blocks of object oriented
programming, such as classes and objects.
Classes are blueprints that are used to create instances called
objects. We can use objects of different classes to interact with each
other and build a robust program.
When we work on our own programs, we may need to not let
everyone know about all the details that our classes have. We can
hence limit access to them, so that certain attributes are less likely
to be accessed unintentionally and be modified wrongfully.
To help us with that, we hide parts of a class and simply provide an
interface that has less details about the inner workings of our class.
Object Oriented Programming 108

We can hide data in two ways:

1. Encapsulation
2. Abstraction

Let us begin with Encapsulation.

Encapsulation

Encapsulation is not something special and unique just for Python.


Other programming languages use it as well.
In a nutshell, we can define it as binding data and methods in a
class. This class is then used to create objects.
We encapsulate classes by using private access modifiers that can
then restrict direct access to such attributes. This can restrict the
control.
We are then supposed to write public methods that can provide
access to the outside world.
These methods are called getters and setters.
A getter method is a method that is used to get the value of an
attribute.
A setter is a method that is used to set the value of an attribute.
Let us define first define a getter and a setter method that can be
used to get values:
Object Oriented Programming 109

1 class Smartphone:
2 def __init__(self, type=None): # defining initialize\
3 r for case of no argument
4 self.__type = type # setting the type here in th\
5 e beginning when the object is created
6
7 def set_type(self, value):
8 self.__type = value
9
10 def get_type(self):
11 return (self.__type)

Now, let us use this class to set the type and also get the type:

1 smartphone = Smartphone('iPhone') # we are setting the t\


2 ype using the constructor method
3
4 # getting the value of the type
5 print(smartphone.get_type()) # iPhone
6
7 # Changing the value of the type
8 smartphone.set_type('Samsung')
9
10 # getting the new value of the type
11 print(smartphone.get_type()) # Samsung

What we have done so far is that we have set and also read the value
of a private attribute of an object created from the Smartphone class.
We can also define getters and setters, using the @property
annotation to hep us with that.
Let’s see it with code:
Object Oriented Programming 110

1 class Bicycle:
2 def __init__(self, manufacturer, color):
3 self._manufacturer = manufacturer
4 self._color = color
5
6 @property
7 def manufacturer(self):
8 return self._manufacturer
9
10 @manufacturer.setter
11 def manufacturer(self, manufacturer):
12 self._manufacturer = manufacturer
13
14 @property
15 def color(self):
16 return self._color
17
18 @color.setter
19 def color(self, color):
20 self._color = color
21
22
23 bike = Bicycle("Connondale", "Grey")

Now that we have defined them, we can call these getter methods
as attributes:

1 print(bike.manufacturer) # Connondale
2 print(bike.color) # Grey

We can also modify the value that we initially used for any attribute
by simply typing the name of the object and the attribute that we
want to modify:
Object Oriented Programming 111

1 bike.is_mountain_bike = False
2 bike.color = "Blue"

Our classes can also have other methods as well, and not just getters
and setters.
Let us define a method inside the class Bicycle that we can then call
from any object that we have created from that class:

1 class Bicycle:
2 def __init__(self, manufacturer, color, is_mountain_b\
3 ike):
4 self._manufacturer = manufacturer
5 self._color = color
6 self._is_mountain_bike = is_mountain_bike
7
8 def get_description(self):
9 desc = "This is a " + self._color + " bike of the\
10 brand " + self._manufacturer
11 return desc

We have created a very simple method in which we are preparing


a string as a result from the attributes of the object that we are
creating. We can then call this method like any other method.
Let us see this in action:

1 bike = Bicycle("Connondale", "Grey", True)


2
3 print(bike.get_description()) # This is a Grey bike of t\
4 he brand Connondale

But why do we need encapsulation?

This looks quite promising and fancy, but you may not get it quite
yet. You may get the impression that you need additional reasons
on why you need this type of hiding.
Object Oriented Programming 112

To drive this home, let us take another class, where we have a


private attribute called salary. Let us say that we don’t care about
encapsulation and we are only trying to build a class fast and use
it in our project for our accountant client.
Let us say that we have the following class:

1 class Employee:
2 def __init__(self, name=None, email=None, salary=None\
3 ):
4 self.name = name
5 self.email = email
6 self.salary = salary

Now, let us create a new employee object and initialize its attributes
accordingly:

1 # We are creating an object


2 betim = Employee('Betim', '[email protected]', 5000)
3
4 print(betim.salary) # 5000

Since salary is not being protected in any way, we can set a new
salary for this new object without any problem:

1 betim.salary = 25000
2
3 print(betim.salary) # 25000

As we can see, this person got five times the salary of what he was
getting previously without going through any type of evaluation
or interviewing at all. In fact, it happened in a matter of seconds.
That’s probably going to hit the budget of the company heavily.
We obviously do not want to do that. We want to restrict access to
the salary attribute so that it is not called from other places. We can
do that by using the double underscore before the attribute name
as can be shown below:
Object Oriented Programming 113

1 class Employee:
2 def __init__(self, name=None, email=None, salary=None\
3 ):
4 self.__name = name
5 self.__email = email
6 self.__salary = salary

Let us create a new object:

1 # We are creating an object


2 betim = Employee('Betim', '[email protected]', 1000)

Now, if we try to access its attributes, we cannot do so, since they


are private attributes:

1 print(betim.salary) # 1000

Trying to access any of the attributes is going to be followed with


an error:

1 AttributeError: 'Employee' object has no attribute 'salar\


2 y'

We can simply implement a method that returns the attributes but


we are not providing any way for someone to increase their salary
sneakily:
Object Oriented Programming 114

1 class Employee:
2 def __init__(self, name=None, email=None, salary=None\
3 ):
4 self.__name = name
5 self.__email = email
6 self.__salary = salary
7
8 def get_info(self):
9 return self.__name, self.__email, self.__salary

Now, we can access the information of objects created by this class:

1 # We are creating an object


2 betim = Employee('Betim', '[email protected]', '5000')
3
4 print(betim.get_info()) # ('Betim', '[email protected]',\
5 '5000')

In summary, encapsulation helps us protect properties of objects


and provides access them in a controlled fashion.

Inheritance
In real life, we can share many characteristics with other human
beings.
We all need to eat food, drink water, work, sleep, move, and
so on. These and many other behaviors and characteristics are
shared among billions of people all around the world. They are not
something unique that only our generation has. These traits have
been for many generations that we have not had the chance to even
see at all.
This is also something that is going to last for future generations to
come.
Object Oriented Programming 115

We can also have certain shared characteristics between objects


and classes that we implement ourselves in computer programming
using inheritance. This includes both attributes and methods.
Let us imagine that we have a class called Book. It should contain
a title, an author, a number of pages, a category, an ISBN, etc. We
are going to keep our class simple and use only two attributes:

1 class Book:
2 def __init__(self, title, author):
3 self.title = title
4 self.author = author
5
6 def get_short_book_paragraph(self):
7 short_paragraph = "This is a short paragraph of t\
8 he book."
9 return short_paragraph

Now, we can create an object from this class and access it:

1 first_book = Book("Atomic Habits", "James Clear")


2
3 print(first_book.title) # Atomic Habits
4 print(first_book.author) # James Clear
5 print(first_book.get_short_book_paragraph()) # This is a\
6 short paragraph of the book.

Let us now create a subclass of the class Book that inherits attributes
and methods from the class Book, but also has another additional
method called get_book_description():
Object Oriented Programming 116

1 class Book:
2 def __init__(self, title, author):
3 self.title = title
4 self.author = author
5
6 def get_short_book_paragraph(self):
7 short_paragraph = "This is a short paragraph of t\
8 he book."
9 return short_paragraph
10
11
12 class BookDetails(Book):
13 def __init__(self, title, author):
14 Book.__init__(self, title, author)
15 # Here we are call the constructor of the parent \
16 class Book
17
18 def get_book_details(self):
19 description = "Title: " + self.title + ". "
20 description += "Author: " + self.author
21 return description

Note the syntax in which we tell Python that BookDetails is a


subclass of the class Book:

1 class BookDetails(Book):

If we try to access this new method from objects of the class Book,
we are going to get an error:
Object Oriented Programming 117

1 first_book = Book("Atomic Habits", "James Clear")


2
3 print(first_book.get_book_details())
4 # AttributeError: 'Book' object has no attribute 'get_boo\
5 k_details'

This happens because this method get_book_details() can be


accessed only from objects of BookDetails:

1 first_book_details = BookDetails("Atomic Habits", "James \


2 Clear")
3
4 print(first_book_details.get_book_details())
5 # Title: Atomic Habits. Author: James Clear

We can however access any method that is defined in the parent


class, which in our case is the Book class:

1 first_book_details = BookDetails("Atomic Habits", "James \


2 Clear")
3
4 print(first_book_details.get_short_book_paragraph())
5 # This is a short paragraph of the book.

In the previous classes, Book is considered as a parent class or as a


superclass, whereas BookDetails is considered as a child class, or a
subclass.

super() function

There is a special function called super() that can be used from


a child class to refer to its parent class without writing the exact
name of the parent class.
Object Oriented Programming 118

We use it with initializers, or when calling properties or methods


of parent classes.
Let us see all three of them illustrated with examples.

Using super() with initializers

We can use super() inside the constructor method of the subclass


and even call the constructor of the super class:

1 class Animal():
2 def __init__(self, name, age):
3 self.name = name
4 self.age = age
5
6
7 class Cat(Animal):
8 def __init__(self, name, age):
9 super().__init__(name, age) # calling the parent\
10 class constructor
11 self.health = 100 # initializing a new attribute\
12 that is not in the parent class

We can also replace super() with the name of the parent class,
which is going to work in the same way again:

1 class Animal():
2 def __init__(self, name, age):
3 self.name = name
4 self.age = age
5
6
7 class Cat(Animal):
8 def __init__(self, name, age):
9 Animal.__init__(name, age) # calling the parent \
10 class constructor
Object Oriented Programming 119

11 self.health = 100 # initializing a new attribute\


12 that is not in the parent class

Even changing the order of the lines inside the child’s constructor
will not cause any error at all.

Using super() with class properties of the parent


class

We can use super() to access class properties of the parent class,


which can be useful especially when both the parent and the child
class use the same name for an attribute.
To see that in action, let us assume that we have a class attribute
called call name which is present both in the parent and the child
class. We want to access this variable from both the parent class
and the child class.
To do that, we simply need to write super() and then the name of
the variable:

1 class Producer: # parent class


2 name = 'Samsung'
3
4
5 class Seller(Producer): # child class
6 name = 'Amazon'
7
8 def get_product_details(self):
9 # Calling the variable from the parent class
10 print("Producer:", super().name)
11
12 # Calling the variable from the child class
13 print("Seller:", self.name)

Now, if we call method get_product_details(), we are going to get


the following printed in the console:
Object Oriented Programming 120

1 seller = Seller()
2
3 seller.get_product_details()
4
5 # Producer: Samsung
6 # Seller: Amazon

Using super() with methods of the parent class

We can similarly call methods in the parent class using super().

1 class Producer: # parent class


2 name = 'Samsung'
3
4 def get_details(self):
5 return f'Producer name: {self.name}'
6
7
8 class Seller(Producer): # child class
9 name = 'Amazon'
10
11 def get_details(self):
12 # Calling the method from the parent class
13 print(super().get_details())
14
15 # Calling the variable from the child class
16 print(f'Seller name: {self.name}')
17
18
19 seller = Seller()
20 seller.get_details()
21
22 # Producer name: Amazon
23 # Seller name: Amazon

This is all you need to know about super().


Object Oriented Programming 121

Types of inheritance

We can have different types of inheritance based on the relationship


of parent classes and child classes:

1. Single
2. Multi-level
3. Hierarchical
4. Multiple
5. Hybrid

1. Single inheritance

We can have a class that inherits only from another class:

1 class Animal:
2 def __init__(self):
3 self.health = 100
4
5 def get_health(self):
6 return self.health
7
8
9 class Cat(Animal):
10 def __init__(self, name):
11 super().__init__()
12 self.health = 150
13 self.name = name
14
15 def move(self):
16 print("Cat is moving")
17
18 cat = Cat("Cat")
19
20 # Calling the method from the parent class
Object Oriented Programming 122

21 print(cat.get_health()) # 150
22
23 # Calling the method from the child class
24 cat.move() # Cat is moving

2. Multi-level inheritance

This is another type of inheritance where a class inherits from


another class which inherits from another class: Class A inherits
from Class B which inherits from Class C.
Let us implement this in Python:

1 class Creature:
2 def __init__(self, alive):
3 self.alive = alive
4
5 def is_it_alive(self):
6 return self.alive
7
8
9 class Animal(Creature):
10 def __init__(self):
11 super().__init__(True)
12 self.health = 100
13
14 def get_health(self):
15 return self.health
16
17
18 class Cat(Animal):
19 def __init__(self, name):
20 super().__init__()
21 self.name = name
22
23 def move(self):
Object Oriented Programming 123

24 print("Cat is moving")
25
26
27 cat = Cat("Cat")
28
29 # Calling the method from the parent of the parent class
30 print(cat.is_it_alive())
31
32 # Calling the method from the parent class
33 print(cat.get_health()) # 150
34
35 # Calling the method from the child class
36 cat.move() # Cat is moving

3. Hierarchical inheritance

When we derive multiple child classes from the same parent class,
then we have hierarchical inheritance. These child classes inherit
from the parent class:

1 class Location:
2 def __init__(self, x, y):
3 self.x = x
4 self.y = y
5
6 def get_location(self):
7 return self.x, self.y
8
9
10 class Continent(Location):
11 pass
12
13
14 class Country(Location):
15 pass
16
Object Oriented Programming 124

17
18 continent = Continent(0, 0)
19 print(continent.get_location()) # (0, 0)
20
21 country = Country(10, 30)
22 print(country.get_location()) # (10, 30)

4. Multiple inheritance

We can have another type of inheritance, namely multiple inher-


itance which can help us inherit from more than one class at the
same time.
Let us assume that we have a class called Date and another one
called Time.
We can then implement another class then inherits from both
classes:

1 class Date:
2 date = '2022-07-23' # Hardcoded date
3
4 def get_date(self):
5 return self.date
6
7
8 class Time:
9 time = '20:20:20' # Hardcoded time
10
11 def get_time(self):
12 return self.time
13
14
15 class DateTime(Date, Time): # Inheriting from both
16 def get_date_time(self):
17 return self.get_date() + ' ' + self.get_time() #\
18 getting methods from its parent classes
Object Oriented Programming 125

19
20
21 date_time = DateTime()
22 print(date_time.get_date_time()) # 2022-07-23 20:20:20

5. Hybrid inheritance

Hybrid inheritance is a combination of multiple and multi-level


inheritance:

1 class Vehicle:
2 def print_vehicle(self):
3 print('Vehicle')
4
5
6 class Car(Vehicle):
7 def print_car(self):
8 print('Car')
9
10
11 class Ferrari(Car):
12 def print_ferrari(self):
13 print('Ferrari')
14
15
16 class Driver(Ferrari, Car):
17 def print_driver(self):
18 print('Driver')

Now, if we create an object from the class Driver, we can call all
methods in all classes:
Object Oriented Programming 126

1 driver = Driver()
2
3 # Calling all methods from the subclass
4 driver.print_vehicle() # Vehicle
5 driver.print_car() # Car
6 driver.print_ferrari() # Ferrari
7 driver.print_driver() # Driver

Polymorphism
This is another important concept from Object Oriented Program-
ming that refers to the possibility of an object behaving like
different forms and calling different behaviors.
An example of a built-in function that uses polymorphism is the
method len() which can be used for both strings and lists:

1 print(len('Python')) # 6
2
3 print(len([2, 3, -43])) # 3

We can take another example with a class called House. We can


have different subclasses that inherit methods and attributes
from that superclass, namely classes such as Condo, Apartment,
SingleFamilyHouse, MultiFamilyHouse, etc.

Let us assume that we want to implement a method in the House


class that is supposed to get the area.
Each type of living residence has a different size, so each one of the
subclasses should have different implementations.
Now we can define methods into subclasses such as:

• getAreaOfCondo()
• getAreaOfApartment()
Object Oriented Programming 127

• getAreaOfSingleFamilyHouse()
• getAreaOfMultiFamilyHouse()

This would force us to remember the names of each subclass, which


can be tedious and also prone to errors when we call them.
However, there is a simpler method that we can use that comes
from polymorphism.
We can have polymorphism using both methods and inheritance.
Let us first see how we can implement polymorphism using meth-
ods.

Polymorphism using methods


Let us say that we have two classes, namely Condo and Apartment.
Both of them have the method get_area() that returns a value.
Each of them is going to have a custom implementation.
Now the method that is going to be called depends on the class type
of the object:

1 class Condo:
2 def __init__(self, area):
3 self.area = area
4
5 def get_area(self):
6 return self.area
7
8
9 class Apartment:
10 def __init__(self, area):
11 self.area = area
12
13 def get_area(self):
14 return self.area
Object Oriented Programming 128

Let us create two objects from these classes:

1 condo = Condo(100)
2
3 apartment = Apartment(200)

Now, we can put both of them in a list and call the same method
for both objects:

1 places_to_live = [condo, apartment]


2
3 for place in places_to_live:
4 print(place.get_area()) # same method for both objec\
5 ts

After we execute that, we are going to see the following in the


console:

1 # 100
2 # 200

This is the polymorphism with methods.

Polymorphism with inheritance

We can not just call a method from a superclass. We can also use
the same name but have a different implementation for it for each
subclass.
Let us first define a superclass:
Object Oriented Programming 129

1 class House:
2 def __init__(self, area):
3 self.area = area
4
5 def get_price(self):
6 pass

Let us then implement subclasses Condo and Apartment of the


superclass House:

1 class House:
2 def __init__(self, area):
3 self.area = area
4
5 def get_price(self):
6 pass
7
8
9 class Condo(House):
10 def __init__(self, area):
11 self.area = area
12
13 def get_price(self):
14 return self.area * 100
15
16
17 class Apartment(House):
18 def __init__(self, area):
19 self.area = area
20
21 def get_price(self):
22 return self.area * 300

As we can see, both subclasses have the method get_price() but


different implementation.
Object Oriented Programming 130

We can now create new objects from subclasses and call this
method which is going to polymorph based on the object that calls
it:

1 condo = Condo(100)
2
3 apartment = Apartment(200)
4
5 places_to_live = [condo, apartment]
6
7 for place in places_to_live:
8 print(place.get_price())

After we execute that, we are going to see the following in the


console:

1 # 10000
2 # 60000

This is another example of polymorphism where we have specific


implementation of a method that has the same name.
Importing
One of the main benefits of using a popular language such as
Python is its large number of libraries that you can use and benefit
from.
Many developers around the world are generous with their time
and knowledge and publish a lot of really useful libraries that can
save us plenty of time both in our professional work, but also on
our side projects that we may do for fun.
Here are some of the modules with very useful methods that you
can immediately start using in your projects:

• time: Time access and conversions


• csv: CSV File Reading and Writing
• math: Math functions
• email: Create, send, and process email
• urllib: Work with URLs

To import one or more modules, we only need to write import and


then the name of the modules that we want to import.
Let us import our first module:

1 import os

Now, let us import multiple modules at once:

1 import os, numbers, math

Once we have imported a module, we can start using methods that


are inside it.
Importing 132

1 import math
2
3 print(math.sqrt(81)) # 9.0

We can also use new names for our imported modules by specifying
an alias for them as alias where alias is any variable name that
you want:

1 import math as math_module_that_i_just_imported


2
3 result = math_module_that_i_just_imported.sqrt(4)
4
5 print(result) # 2.0

Limiting parts that we want to


import
There are times when we do not want to import a whole package
with all its methods, since we want to avoid the overriding of
methods or variables that are in the module with the ones that we
want to implement ourselves.
We specify parts that we want to import by using the following
form:

1 from module import function

Let us take an example of importing only the square root function


from math module:
Importing 133

1 from math import sqrt


2
3 print(sqrt(100)) # 10.0

Importing everything
We can also import everything from a module, which can turn out
to be a problem. Let us illustrate this with an example.
Let us assume that we want to import everything that is included
in the math module. We can do that by using the asterisk

1 from math import * # The asterisk is an indicator to inc\


2 lude everything when importing

Now, let us assume that we want to declare a variable called sqrt:

1 sqrt = 25

When we try to call the function sqrt() from the math module, we
are going to get an error, since the interpreter is going to call the
latest sqrt variable that we have just declared in the previous code
block:

1 print(sqrt(100))

1 TypeError: 'float' object is not callable


Exceptions
When we are implementing Python scripts or doing any type of
implementation, we are going to get many errors that are thrown
even when the syntax is correct.
These types of errors that happen during execution are called
exceptions.
We indeed do not have to surrender and not do anything regarding
them. We can write handlers that are there to do something so that
the execution of the program does not stop.

Common Exceptions
Here are some of the most common exceptions that happen in
Python with definitions taken from the Python documentation¹:

• Exception (This is a class that is as a superclass of most other


exception types that happen)
• NameError - Raised when a local or global name is not found.
• AttributeError - Raised when an attribute reference or as-
signment fails.
• SyntaxError - Raised when the parser encounters a syntax
error.
• TypeError - Raised when an operation or function is applied
to an object of inappropriate type. The associated value is a
string giving details about the type mismatch.
• ZeroDivisionError - Raised when the second argument of a
division or modulo operation is zero.
¹https://docs.python.org/3/library/exceptions.html
Exceptions 135

• IOError - Raised when an I/O operation (such as a print


statement, the built-in open() function or a method of a file
object) fails for an I/O-related reason, e.g., “file not found” or
“disk full”.
• ImportError - Raised when an import statement fails to find
the module definition or when a from … import fails to find
a name that is to be imported.
• IndexError - Raised when a sequence subscript is out of
range.
• KeyError - Raised when a mapping (dictionary) key is not
found in the set of existing keys.
• ValueError - Raised when a built-in operation or function
receives an argument that has the right type but an inap-
propriate value, and the situation is not described by a more
precise exception such as IndexError.

There are many other error types, but you may not need to see
about them now. It is also very unlikely that you are going to see
all types of errors all the time.
You can see more types of exception in the Python documentation².

Handling exceptions
Let us start with a very simple example and write a program that
throws an error on purpose so that we can then fix it.
We are going to do a division by zero, which is something that you
have probably seen at school:

1 print(5 / 0)

If we try to execute that, we are going to be greeted with the


following error in the console:
²https://docs.python.org/3/library/exceptions.html
Exceptions 136

1 ZeroDivisionError: division by zero

If we were to have such occurrences inside a Python program of


any kind, we should catch and wrap this error inside a try/except
block.
We need to write inside the try block the part of the code that we
expect is going to throw errors. We then catch those types of errors
inside the except block by also specifying the type of error that we
except to happen.
Let us see the first example.
Let us see how we can deal with that error so that we also get
informed that such error happened:

1 try:
2 5 / 0
3 except ZeroDivisionError:
4 print('You cannot divide by 0 mate!')

As you can see, we are printing a message in the console once we


have reached the part where a division by 0 is happening.
We can also omit the part ZeroDivisionError completely:

1 try:
2 5 / 0
3 except:
4 print('You cannot divide by 0 mate!')

However, this is not recommended, since we are catching all types


of errors in a single except block and we are not sure what type of
errors are being caught, which can be quite useful for us.
Let us continue with another type of error.
Let us try to use a variable that is not defined at all:
Exceptions 137

1 name = 'User'
2
3 try:
4 person = name + surname # surname is not declared
5 except NameError:
6 print('A variable is not defined')

In the previous example, we have used variable surname before


declaring it, therefore a NameErroris going to be thrown.
Let us continue with another type of example that can be common.
When we use lists, it can be a common mistake to use an index that
is out of range, meaning that the used index is larger or smaller
than the range of indexes of the elements in that list.
Let us illustrate this with an example, where an IndexError is going
to be thrown:

1 my_list = [1, 2, 3, 4]
2
3 try:
4 print(my_list[5])
5 # This list only has 4 elements, so its indexes range\
6 from 0 to 3
7 except IndexError:
8 print('You have used an index that is out of range')

We can also use a single try block with multiple except errors:
Exceptions 138

1 my_list = [1, 2, 3, 4]
2
3 try:
4 print(my_list[5])
5 # This list only has 4 elements, so its indexes range\
6 from 0 to 3
7 except NameError:
8 print('You have used an invalid value')
9 except ZeroDivisionError:
10 print('You cannot divide by zero')
11 except IndexError:
12 print('You have used an index that is out of range')

In the previous example, we try to initially catch whether there is


any variable that is used but not declared. If this error happens, then
this except block is going to be taking over the execution flow. This
execution flow is going to stop there.
Then, we try to check whether we are dividing by zero. If this error
is thrown, then this except block is going to take over the execution
and everything that is inside it is going to be executed. Similarly, we
continue with the rest of the errors declared.
We can also put more than one error inside parenthesis to catch
multiple exceptions, but this is not going to be helpful for us, since
we do not know what specific error has been thrown. In other
words, the following method does work, but it is not recommended:
Exceptions 139

1 my_list = [1, 2, 3, 4]
2
3 try:
4 print(my_list[5])
5 # This list only has 4 elements, so its indexes range\
6 from 0 to 3
7 except (NameError, ZeroDivisionError, IndexError):
8 print('A NameError, ZeroDivisionError, or IndexError \
9 occurred')

finally

After the try and except are passed, there is another block that we
can declare and execute. This block starts with the finally keyword
and it is executed no matter whether we have an error is being
thrown or not:

1 my_list = ['a', 'b']


2
3 try:
4 print(my_list[0])
5 except IndexError:
6 print('An IndexError occurred')
7 finally:
8 print('The program is ending. This is going to be exe\
9 cuted.')

If we execute the previous block of code, we are going to see the


following in the console:

1 a
2 The program is ending. This is going to be executed.

We usually write code that we want to be as a cleanup inside the


finally block. This includes things like closing a file, or stopping a
connection with a database, exiting the program entirely, etc.
Exceptions 140

try, else, except

We can write statements inside try and except, but we can also use
an else block where we can write code that we want to be executed
if there are no errors being thrown:

1 my_list = ['a', 'b']


2
3 try:
4 print(my_list[0])
5 except IndexError:
6 print('An IndexError occurred')
7 else:
8 print('No error occurred. Congratulations!')

If we execute the code above, we are going to get the following


printed in the console:

1 a
2 No error occurred. Congratulations!

Wrap up

This should be sufficient for you to understand exceptions and


ways that you can use to handle them so that there is no sudden
interruptions that cause your program to fail unexpectedly.
Afterword
This book represents my attempt to make it quick and easy for you
to learn the essentials of Python. There are many other things that
Python includes and that are not covered in this book, but we will
leave it here.
I hope this serves as a useful reference for you.
Now that you have had the chance to learn how to write Python,
go out there and make a positive impact with your lines of code.

You might also like