Example Python Unit

[UNIT LONG NAME]

This Python tutorial will cover the basics of

Types in computing

In computing, we deal with values, different numbers, text, images (which are numbers), boolean (logical). These values need to be represented in the memory of a computer, that is a number of binary switches (“0’s and 1’s”).

Integers

A simple example is an integer number that can be expressed in the base2 (in the example, using 4 “bits”): 0001 is 1 in decimal, 0101 is (from left to right \(0\cdot2^3 + 1\cdot2^2 + 0\cdot2^1 + 1 \cdot 2^0(=1) = 5\). Using enough bits, we can represent any integer number (and use, for example, the leftmost bit as a flag to indicate that a minus is in front of it in order to produce a negative number). Technically, the types encountered are usually called int32, int64 (being the most common ones, but others exist too), where the trailing number indicates the number of bits used. In Python, we do not need to take care of it and integers are “precise enough”. A huge advantage of using Python!

Comparing integer numbers is always correct, because each integer has one, and exactly one representation.

Strings (aka text)

Having defined integers, it’s easy to define text: each letter corresponds to an integer number (hidden to us users). All that is needed is something that marks a stream of bites as “text” and then evaluates that a certain number corresponds to a letter.

Floating point numbers

This is where things get a bit tricky: while there is exactly one integer between the integers 4 and 6, for example, there are infinitly many floating point numbers between, say 3.1 and 3.2 and the number of digits can easily be large. This leads to a practical problem: we cannot accurately represent floating point numbers! 3.1 maybe is not equal to 3.1!

Floating point numbers consist of two parts: an “integer-like” part, that is, a numeric value. And another part that defines the exponent of a base that the numerical value is multiplied by.

Imagine that every float is like an integer, “shifted” to the left or right.

The types are usually called float32 or float64 (and more). Usually, use float64 if ever needed for any scientific computing (machine learning being a notable exception).

A number like 10/3 needs infinitely many digits to represent it exactly, but due to the limited precision that can be stored, it will be truncated in the computer. Therefore \((10 / 3) \cdot 3\) equals \(10\), but a computer will return a number like 9.99999999 (with many, typically 8 or 16 digits for a float 32 or float64 respectively), but limited.

(Python sometimes tries to hide this fact with some numbers, but nothing to rely on)

Another consequence is limited precision: adding a very small number to a very large one (i.e. if the small one is smaller than the precision that the large one is stored with, the small one will be “ignored”, as it first has to be converted to the same representation as the large one.

Basic types and operations

Python has several basic types - numerical (float, int, complex) - string - bool

There are several operations defined on them, as we have already seen in examples.

a = 1  # creates an integer

b = 3.4  # float

# several ways for strings
c = "hello"
d = 'world'
cd = "welcome to this 'world' here"  # we can now use '' inside (or vice versa)
e = """hello world"""  # which we can also wrap
e2 = """hello
world
come here!"""

g = True

print(a, b, c, d, cd, e)
print(e2)
print(g)
1 3.4 hello world welcome to this 'world' here hello world
hello
world
come here!
True
  type(a) 
int

With type(...), we can determine the type of an object.

strong typing

Python is strongly typed. This means that the type of the variable matters and some interactions between certain types are not directly possible.

a = 1
b = 2
a + b
3

These are two integers. We are not surprised that this works. What about the following?

mix_str_int = a + "foo"
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[5], line 1
----> 1 mix_str_int = a + "foo"

TypeError: unsupported operand type(s) for +: 'int' and 'str'

Maybe the following works?

mix_str_int2 = a + "5"
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[6], line 1
----> 1 mix_str_int2 = a + "5"

TypeError: unsupported operand type(s) for +: 'int' and 'str'

Python is strict on the types, but we can sometimes convert from one type to another, explicitly:

a + int("5")
6

…which works because int("5") -> 5.

There are though some implicit conversions in Python, let’s look at the following:

f = 1.2
print(type(f))
<class 'float'>
int_plus_float = a + f
print(type(int_plus_float))
<class 'float'>

This is one of the few examples, where Python automatically converts the integer type to a float. The above addition actually reads as

int_plus_float = float(a) + f

Similar with booleans as they are in principle 1 (True) and 0 (False)

True + 5
6

For readability, it is usually better to write an explicit conversion.

Container types

Python has several container types as also found in other languages. The most important ones are: - list (~array in other languages) - dict (~hash table in other languages)

They can contain other objects which can then be assigned and accessed via the [] operator (we will have a closer look at operators later on)

A list stores elements by indices, which are integers, while a dict stores elements by a key, which can be “any basic type” (to be precise: by their “hash”, it can be any immutable type).

# creating a list
list1 = [1, 2, 3]
print(list1)
[1, 2, 3]

We can access these element by indices, starting from 0

list1[0]
1

We can also assign a value to an place in the list

list1[1] = 42
print(list1)
[1, 42, 3]

and it can be extended with elements

list1.append(-5)
print(list1)
[1, 42, 3, -5]

Choosing a value that is not contained in the list raises an error. It is verbose, read and understand it.

Being able to understand and interpret errors correctly is a key to becoming better in coding.

list1[14]
---------------------------------------------------------------------------
IndexError                                Traceback (most recent call last)
Cell In[16], line 1
----> 1 list1[14]

IndexError: list index out of range

We can play a similar game with dicts

person = {'name': "Rafael Silva Coutinho", 'age': 37, 5: True, 11: "hi"}  # we can use strings but also other elements
print(person)
{'name': 'Rafael Silva Coutinho', 'age': 37, 5: True, 11: 'hi'}
print(person['name'])
print(person[5])
print(person[11])
Rafael Silva Coutinho
True
hi

We can also assign a new value to a key.

person['age'] = '42.00001'
print(person)
{'name': 'Rafael Silva Coutinho', 'age': '42.00001', 5: True, 11: 'hi'}

… or even extend it by assigning to a key that did not yet exists in the dict

person['alias'] = "rsilvaco"
print(person)
{'name': 'Rafael Silva Coutinho', 'age': '42.00001', 5: True, 11: 'hi', 'alias': 'rsilvaco'}

As we see this works. Notice, that the dict has changed, same as the list before.

Again, selecting a key that is not contained in the dict raises an error.

person['nationality']
---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)
Cell In[21], line 1
----> 1 person['nationality']

KeyError: 'nationality'

As any object in Python, there are many useful methods on list and dict that help you accomplish things. For example, what if we want to retrieve a value from a dict only if the key is there and otherwise return a default value? We can use get:

hair_color = person.get('hair_color', 'unknown color')  # the second argument gets returned if key is not in dict
print(hair_color)
unknown color

Mutability

Python has a fundamental distinction between mutable and immutable types.

Mutable means, an object can be changed Immutable means, an object can not be changed

As an example, 5 can not change; in general the basic types we looked at cannot change. We can change the value that is assigned to a variable, but the object 5 remains the same. The list and dicts we have seen above on the other hand are mutable, they have changed over the course of execution.

Every mutable object has an immutable counterpart (but not vice-versa): - list -> tuple - dict -> frozendict - set -> frozenset - etc.

# creating a tuple
tuple1 = (1, 3, 5)
# or from a list
tuple_from_list = tuple(list1)
list2 = [4, 5]
tuple2 = (3, 4)
list3 = list(tuple2)
print(list2)
print(tuple2)
print(list3)
[4, 5]
(3, 4)
[3, 4]

While we can access the elements as we can for a list, we can neither assign nor append (or in generate mutate the object:

print(tuple1[1])  # access works!
3
tuple1[0] = 5
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[27], line 1
----> 1 tuple1[0] = 5

TypeError: 'tuple' object does not support item assignment

We will soon see the effects and needs for this…

Exercise

Create a list with 3 elements. Then create a tuple with 5 elements, one of them being the list. Change an element in the list. Did it change in the tuple? Do you understand this?

my_list = [1, 2, 3]
my_tuple = (4, 5, my_list, 7, 8)
my_list[0] = 8   
print(my_tuple)
(4, 5, [8, 2, 3], 7, 8)

Dynamic typing

Python is dynamically typed. This means that a variable, which once was an int, such as a, can be assigned a value of another type (this maybe sounds trivial, but this is not possible to do in many other languages).

a = 1
print(a)
1
a = "one"
print(a)
one
a = list1
print(a)
[1, 42, 3, -5]

… and so on

Assignement and variables

We’ve seen a few things up to now but have not really looked at the assignement and variables itself. Understanding Pythons variable is crucial to understand e.g. the following:

a = 5
b = a
print(a, b) 
5 5
a = 3
print(a, b)
3 5

So far so good, no surprise here.

list1 = [1, 3]
list2 = list1
print(list1, list2)
[1, 3] [1, 3]
list2[0] = 99
print(list1, list2)
[99, 3] [99, 3]

…but that was probably unexpected! Let’s have a look at Pythons variable assignement.

Python variable assignement

Assigning something to a variable in Python makes a name point to an actual object, so the name is only a reference. For example creating the variable a and assigning it the object 5 looks like this: assignements1

a = 3
list_a = [1, 2]
print(a)
print(list_a)
3
[1, 2]

reference2
b = a  # this assigns the reference of a to b
list_b = list_a
print(a, b)
print(list_a, list_b)
3 3
[1, 2] [1, 2]

Both objects, b and list_b point now to the same objects in memory as a and list_a respectively. Re-assigning a variable let’s it point to a different object reference3

a = 'spam'
list_a = [1, 5, 2, 'world', 1]
print(a, b)
print(list_a, list_b)
spam 3
[1, 5, 2, 'world', 1] [1, 2]

Let’s make them point to the same object again:

b = a
list_b = list_a 
print(a, b)
print(list_a, list_b)
spam spam
[1, 5, 2, 'world', 1] [1, 5, 2, 'world', 1]
list_a[1] = 'hello'
print(list_a, list_b)
[1, 'hello', 2, 'world', 1] [1, 'hello', 2, 'world', 1]

Now we understand what happend: the object that both variables are pointing to simply changed. This is impossible with immutable objects (such as 3), since they are immutable.

Mutable objects usually offer the ability to create a copy.

list_c = list_a.copy()  # now there are two identical lists in the memory
list_a[2] = 'my'
print(list_a)
print(list_b)
print(list_c)
[1, 'hello', 'my', 'world', 1]
[1, 'hello', 'my', 'world', 1]
[1, 'hello', 2, 'world', 1]

list_a and list_b, pointing to the same object that was mutated, have changed, while list_c, pointing to a different object, remained the same.

Let’s have a look at two operators: the “trivial” == and the is: we know == pretty well, it tells whether the left and the right side are the same. More specific, it tells whether both sides have/represent the same value, not whether they are in fact the same object! The operator is tells us, whether two objects are the same object (compare our assignement model above!).

print(list_a == list_c)  # not the same
print(list_a == list_b)  # the same
False
True
list_c[2] = 'my'  # make it the same as the other lists
print(list_a == list_c)
True

But, as we learned before, they are not the same objects!

print(list_a is list_c)  # nope!
print(list_a is list_b)  # yes!
False
True

Usually, we are interested to compare the values, using == (notable exception: checking, if a value is None means to check using the identity equality is.

Exercise 2: Create a list a = [1, 2, 3] and create a new variable b and assign it to a. Compare a and b using == and is. Print the results. Modify a by appending 4 to it and print b. Do you understand why you have this solution?

# Step 1: Create a list
a = [1, 2, 3]

# Step 2: Assign a to b
b = a

# Step 3: Compare a and b using == and is
print("a == b:", a == b)  # Expected output: True
print("a is b:", a is b)  # Expected output: True

# Step 4: Modify a by appending 4
a.append(4)

# Step 5: Print b
print("b:", b)  # Expected output: [1, 2, 3, 4]
a == b: True
a is b: True
b: [1, 2, 3, 4]

Explanation:

a == b and a is b are both True because b is assigned directly from a, meaning they refer to the same list object. After appending 4 to a, b also changes because both a and b point to the same list in memory.