Module 2, Practical 1

The practicals of the second teaching module are a refinement of those prepared by Massimiliano Luca and Erik Dassi. Many thanks for their help.

Object Oriented Programming

As seen in the lecture, Python is a multi-paradigm language and it supports in fact the imperative/procedural paradigm (programs are sequences of statements that change the state of the system), the functional paradigm (programs are seen as mathematical functions, e.g. list comprehensions), some libraries are declarative (they define the logic without specifying the control-flow e.g. Matplotlib) but is also Object Oriented. In fact everything in Python is an object. Moreover, as we will see, new data-types can be defined in Python.

In Object Oriented Programming (OOP) objects are data structures that contain data, which is attributes and functions to work with them. In OOP, programs are made by a set of objects that interact with each other.

OOP allows to create a distinction (abstraction) between the way objects are implemented and how objects are used (i.e. what we can do with them).

Classes, Methods and Objects

The three key players of OOP are: classes, objects and methods.

Classes (types) are an abstraction that captures:

  1. the internal data representation (i.e. data attributes that are called fields)

  2. the interface to interact with the class (i.e. functions that can be used to manipulate the the methods).

Objects are instances of classes. Classes define the structure and are used to create objects. Objects are a concrete realization of the class, a real instance based on the footprint of the class. Programs are interactions among different objects.

Methods are functions that can be applied to manipulate objects.

Attributes and methods within an instantiated object can be accessed by using the . (dot) operator.

Self

Within a class method, we can refer to that very same instance of the object being created by using a special argument that is called self. self is always the first argument of each method.

Important note: All data types seen so far are in fact classes and every time that we used a data type (e.g. defining a list, a string etc.) we were in fact instantiating an object of that type (class).

Definition of a class

The syntax to define a class is the following:

class class_name:
        #the initializer method
        def __init__(self, val1,...,valn):
            self.att1 = val1
            ...
            self.attn = valn

        #definition of a method returning something
        def method1(self, par1,...,parn):
            ...
            return value

        #definition of a method returning None
        def method2(self, par1,...,parn):
            ...

In this case we defined a class class_name that has att1,..., attn (attributes) fields and two methods method1 with parameters par1,...,parn returning a value value and a method method2 with parameters par1,...,parn that does not return anything.

The values of the fields are initialized when the object is instantiated at the beginning by calling the __init__ method, which does not return anything. Note also the use of self in the initializer, which is used to specify that each field of this instance has to be given the corresponding value and must always be the first argument of the initializer.

The object is instantiated with:

my_class = class_name(p1,...,pn)

which attributes the values p1,...,pn to the fields field1,...,fieldn.

Example: Let’s define a simple class rectangle with two fields (length and width) and two methods (perimeter and area).

[ ]:
class class_name:
        #the initilizer method
        def __init__(self, val1,...,valn):
            self.att1 = val1
            ...
            self.attn = valn

        #definition of a method returning something
        def method1(self, par1,...,parn):
            ...
            return value

        #definition of a method returning None
        def method2(self, par1,...,parn):
            ...


my_class = class_name(p1,...,pn)
[1]:
import math

class Rectangle:
    def __init__(self, l,w):
        self.length = l
        self.width = w

    def perimeter(self):
        return 2*(self.length + self.width)

    def area(self):
        return self.length * self.width

    def diagonal(self):
        return math.sqrt(self.length**2 + self.width**2)

R = Rectangle(5,10)
print(type(R))
R1 = Rectangle(5,10)
print(type(R1))
print("R == R1? {} id R:{} id R1:{}".format(R == R1,
                                            id(R),
                                            id(R1)))
p = R.perimeter()
a = R.area()
d = R.diagonal()
print("\nR:\nLength: {} Width: {}\nPerimeter: {}\nArea:{}".format(R.length,
                                                                  R.width,
                                                                  p,
                                                                  a))
print("R's diagonal: {:.2f}".format(d))
R2 = Rectangle(72,13)
p = R2.perimeter()
a = R2.area()
d = R2.diagonal()
print("\nR2:\nLength: {} Width: {}\nPerimeter: {}\nArea:{}".format(R2.length,
                                                                   R2.width,
                                                                   p,
                                                                   a))
print("R's diagonal: {:.2f}".format(d))
<class '__main__.Rectangle'>
<class '__main__.Rectangle'>
R == R1? False id R:140461848354768 id R1:140461848353232

R:
Length: 5 Width: 10
Perimeter: 30
Area:50
R's diagonal: 11.18

R2:
Length: 72 Width: 13
Perimeter: 170
Area:936
R's diagonal: 73.16

Note that the type of the two objects R and R2 are of type Rectangle and that they have have different identifiers. Instantiating objects automatically calls the initializer methods (__init__) passing the correct parameters to it. The dot . operator is used to access methods of the objects. Through the dot operator we can also access the fields of an object, even though this is normally not the best practice and implementing methods to get and set the values of fields are recommended.

The life-cycle of classes and objects in a program:

The usual life-cycle of classes and objects is the following:

  1. Classes are defined with the specification of class attributes (fields) and methods;

  2. Objects are instantiated based on the definition of the corresponding classes;

  3. Objects interact with each other to implement the logic of the program and modify their state;

  4. Objects are destroyed (explicitly with del) or implicitly when there are no more references to them.

Encapsulation

When defining classes, it is possible to hide some of the details that must be kept private to the object itself and not accessed directly. This can be done by setting methods and attributes (fields) as private to the object (i.e. accessible only internally to the object itself).

Private attributes and methods can be defined using the __ notation (i.e. the name of the attribute or method is preceded by two underscores __).

Example Let’s see what happens to the rectangle class with encapsulation.

[2]:
import math

class Rectangle:
    def __init__(self, l,w):
        self.__length = l
        self.__width = w

    def perimeter(self):
        return 2*(self.__length + self.__width)

    def area(self):
        return self.__length * self.__width

    def diagonal(self):
        return math.sqrt(self.__length**2 + self.__width**2)

R = Rectangle(10,6)
p = R.perimeter()
a = R.area()
d = R.diagonal()
#we might be tempted to access the encapsulated values:
print("\nR:\nLength: {} Width: {}\nPerimeter: {}\nArea:{}".format(R.__length,
                                                                  R.__width,
                                                                  p,
                                                                  a))
#The following is going to fail alike.
#print("\nR:\nLength: {} Width: {}\nPerimeter: {}\nArea:{}".format(R.length,
#                                                                  R.width,
#                                                                  p,
#                                                                  a))
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
/tmp/ipykernel_59974/2984036234.py in <module>
     20 d = R.diagonal()
     21 #we might be tempted to access the encapsulated values:
---> 22 print("\nR:\nLength: {} Width: {}\nPerimeter: {}\nArea:{}".format(R.__length,
     23                                                                   R.__width,
     24                                                                   p,

AttributeError: 'Rectangle' object has no attribute '__length'

Since the length and width attributes are private to the class, it is not possible to get access from the outside. The code above will fail with the object has no attribute error message

To work around this, we can define a specific interface to access the values (these are normally called getter methods as they get and return the value).

Example Let’s see what happens to the rectangle class with encapsulation and getter methods.

[3]:
import math

class Rectangle:
    def __init__(self, l,w):
        self.__length = l
        self.__width = w

    def getLength(self):
        return self.__length

    def getWidth(self):
        return self.__width

    def perimeter(self):
        return 2*(self.__length + self.__width)

    def area(self):
        return self.__length * self.__width

    def diagonal(self):
        return math.sqrt(self.__length**2 + self.__width**2)

R = Rectangle(10,6)
p = R.perimeter()
a = R.area()
d = R.diagonal()
print("\nR:\nLength: {} Width: {}\nPerimeter: {}\nArea:{}".format(R.getLength(),
                                                                  R.getWidth(),
                                                                  p,
                                                                  a))

R:
Length: 10 Width: 6
Perimeter: 32
Area:60

Setter methods can be used to change the values of attributes after initialization.

Example: Let’s define a Person class with the following attributes: name, surname, telephone number and address. All attributes are private. The address and phone numbers might change, so we need a method to change them.

[4]:
class Person:
    def __init__(self, name, surname, birthdate):
        self.__n = name
        self.__s = surname
        self.__dob = birthdate
        self.__a = "unknown"
        self.__t = "unknown"

    def setAddress(self, address):
        self.__a = address

    def setTelephone(self, telephone):
        self.__t = telephone

    def getName(self):
        return self.__n

    def getSurname(self):
        return self.__s

    def getDoB(self):
        return self.__dob

    def getAddress(self):
        return self.__a

    def getTel(self):
        return self.__t

Joe = Person("Joe", "Page", "20/5/1980")
Joe.setAddress("Somerset Rd.,Los Angeles, CA 90016")
print("{} {}\nDate of Birth: {}\nPhone: {}\nAddress: {}".format(Joe.getName(),
                                               Joe.getSurname(),
                                               Joe.getDoB(),
                                               Joe.getTel(),
                                               Joe.getAddress()
                                              ))
#Joe moves to Trento
Joe.setAddress("via Sommarive, Povo, Trento")
print("\nNew address: {}".format(Joe.getAddress()))
Joe Page
Date of Birth: 20/5/1980
Phone: unknown
Address: Somerset Rd.,Los Angeles, CA 90016

New address: via Sommarive, Povo, Trento

The setAddress method is a setter method that is used to change the value of the attribute __a.

Special methods

As seen in the lecture, it is possible to redefine some operators by redefining the corresponding special methods through a process called overriding.

These are useful to define things like the sum of two objects (__add__), the equality (__eq__), which one is the smallest (__lt__ that is less than) or the way the object should be translated into a string (__str__), for example for printing purposes.

More information on these special methods can be found here.

Example A decimal number \(T\) can be expressed in base \(X\) (for simplicity we will consider \(X \in [1,9]\)) as: \(T = aX^{N} + bX^{N-1}+...+ (n+1) X^{0}\). Such a number can be represented as two elements: \((X, (a,b,...,n+1))\) (the base and the tuple of all the values). Let’s define a class MyNumber that can represent numbers in any base (from 1 to 9). The class has two attributes, the base (which is a number representing the base) and the values a tuple of numbers. Let’s redefine some operators (i.e. add, lt, str) and implement some methods to covert these numbers into decimals.

[ ]:
class MyNumber:

    def __init__(self, base, values):
        self.base = base
        warn = False
        for v in values:
            if(v >= base):
                print("Error.Values must be lower than base")
                print("Can't create n. with base {} and values {}".format(base,values))
                warn = True
        if(not warn):
            self.values = values
        else:
            self.values = None

    def toDecimal(self):
        res = 0
        L = len(self.values)
        for i in range(L):
                res += self.values[i] * self.base**(L-1 - i)
        return res


    def __str__(self):
        return "Base: {}\nValues:{}".format(self.base, self.values)

    def __add__(self, other):
        return self.toDecimal() + other.toDecimal()

    def __lt__(self, other):
        return self.toDecimal() < other.toDecimal()

    def toDecimalString(self):
        L = len(self.values)
        res = str(self.values[0]) + "*" +str(self.base ** (L-1))
        for i in range(1,L):
            res += " + " + str(self.values[i]) + "*" + str(self.base**(L-1 - i))
        return res

mn = MyNumber(10,(1,2,3))
print(mn)
print("{} = {}".format(mn.toDecimal(), mn.toDecimalString()))
mn2 = MyNumber(4, (1,2,3))
print("\n{}".format(mn2))
print("{} = {}".format(mn2.toDecimal(), mn2.toDecimalString()))
mn3 = mn + mn2
print("\nmn+mn2:{}".format(mn3))
print("\n")
mn4 = MyNumber(3,(7,1,1))
print("\n")
print("{} < {}? {}".format(mn.toDecimal(),mn2.toDecimal(),mn < mn2))
print("{} == {}? {}".format(mn.toDecimal(),mn2.toDecimal(),mn == mn2))
print("{} > {}? {}".format(mn.toDecimal(),mn2.toDecimal(),mn > mn2))

Inheritance and overriding

One object can inherit the attributes and methods from another object. This establishes a “Is-a” relationship between the two objects. The first object is called subclass of the original class. A subclass inherits all the methods and attributes of the superclass, but it can also redefine some methods through a process called overriding.

The syntax to define a subclass is the following:

class MySuperClass:
    ...
    def myMethod(self,...):
        ...

class MySubClass(MySuperClass):
    ...
        def myMethod(self,...):
        ...

basically, we just specify the superclass after the name of the subclass we are defining.

Consider the following example:

[ ]:
class Person:

    def __init__(self, name, surname, age):
        self.name = name
        self.surname = surname
        self.age = age

    def getInfo(self):
        return "{} {} is aged {}".format(self.name,
                                        self.surname,
                                        self.age)

class Dad(Person):

        children = []

        def addChild(self,child):
            self.children.append(child)

        def getChildren(self):
            return self.children

        def getInfo(self):
            personalInfo = "{} {} is aged {}".format(self.name,
                                        self.surname,
                                        self.age)
            childrInfo = ""
            for son in self.getChildren():
                childrInfo += " - {}'s child is {} {}".format(
                            self.name, son.name, son.surname) +"\n"

            return personalInfo + "\n" + childrInfo

jade = Person("Jade", "Smith",5)
print(jade.getInfo())
john = Person("John", "Smith",4)
tim = Person("Tim", "Smith",1)
dan = Dad("Dan", "Smith", 45)
dan.addChild(jade)
dan.addChild(john)
dan.addChild(tim)
print(dan.getInfo())

Note that the object Dad (subclass) is-a Person (superclass) but has a further attribute that is the list of children, each of which are of type Person. The getInfo method of the subclass Dad overrides the corresponding method of the superclass Person and prints some information on the children.

[ ]:
class Person:

    def __init__(self, name, surname, age):
        self.name = name
        self.surname = surname
        self.age = age

    def __str__(self):
        return "{} {} is aged {}".format(self.name,
                                        self.surname,
                                        self.age)

class Dad(Person):

        children = []

        def addChild(self,child):
            self.children.append(child)

        def setChildren(self, children):
            self.children = children

        def getChildren(self):
            return self.children

        def __str__(self):
            personalInfo = "{} {} is aged {}".format(self.name,
                                        self.surname,
                                        self.age)
            childrInfo = ""
            for son in self.getChildren():
                childrInfo += " - {}'s child is {} {}".format(
                            self.name, son.name, son.surname) +"\n"

            return personalInfo + "\n" + childrInfo

jade = Person("Jade", "Smith",5)
#print(jade.getInfo())
john = Person("John", "Smith",4)
tim = Person("Tim", "Smith",1)
dan = Dad("Dan", "Smith", 45)
dan.setChildren([jade,john])
#dan.addChild(john)
dan.addChild(tim)

print(jade)
print(dan)

Exercise

Implement a superclass Polygon with two attributes:

  • shape

  • sides’ length.

Add a method to return the perimeter and a method to return the number of edges of the polygon. Implement a __str__ method to print an object of the class with its shape. Then create two subclasses of poylgon: Rectangle and Triangle. For each subclass modify the initializer accordingly and also override the __str__ to print the object id.

Show/Hide Solution

Lambda functions

Computing in the functional programming paradigm is obtained by applying functions to a set of inputs. To have an idea about the differences with OOP, see this link: https://dev.to/documatic/when-to-use-functional-programming-vs-oop-122n

Three mechanisms are available in python:

  • map : map(f, input_list) applies the function f to all the elements of input_list;

  • filter : filter(f, input_list) filters input_list based on a function f that returns true or false for each of the input elements;

  • reduce : reduce(f, input_list) applies the function f to the first two elements of the input list, then it applies it to the result and to the third element and so on until the end of the list is reached and one value only is returned.

Note that the reduce function is part of the functools module and needs to be imported with:

from functools import reduce

Let’s see some examples of map and filter.

Example: Let’s define two functions to convert temperatures from fahrenheit to celsius and vice-versa. Let’s finally use them to work out the temperatures that are above and below freezing.

[ ]:
def fahrenheit(T):
    return ((float(9)/5)*T + 32)
def celsius(T):
    return (float(5)/9)*(T-32)

def freezingFiltF(T):
    return celsius(T) < 0
def freezingFiltC(T):
    return T < 0

farTmp = (27.5, 29, 37.5,12, 44, 72,100)

C = map(celsius, farTmp)
print(type(C))
C = tuple(C)
for i in range(len(C)):
    print("{:.2f}°F --> {:.2f}°C".format(farTmp[i],C[i]))

print("\n")
print("Freezing temperatures:")
print(type(filter(freezingFiltF, farTmp)))
ftF = tuple(filter(freezingFiltF, farTmp))
ftC = tuple(filter(freezingFiltC, C))
for i in range(len(ftF)):
    print("{:.2f}°F --> {:.2f}°C".format(ftF[i],ftC[i]))

Note that map and filter respectively return an object map and an object filter which we need to convert into lists or tuples if we want to work with the results.

Examples

Use reduce to:

  1. sum the elements of a list

  2. count how many non-space characters a string has

[ ]:
from functools import reduce

lst = [4,5,6,7]

reducedLst = reduce(int.__add__, lst)
print(reducedLst)
[ ]:
from functools import reduce

myText = "Testing shows the presence, not the absence of bugs"
words = myText.split()

print("Word sizes: {}".format(list(map(len, words))))
cnt = reduce(int.__add__, list(map(len, words)))

print("Dijkstra's quote has {} characters".format(cnt))

All the above examples required the specification of a function. We do not necessarily need to specify a function name in Python thanks to the anonimous functions also known as lambda functions.

Their basic syntax is:

lambda input-parameters: expression

where the keyword lambda proceeds the comma separated list of input parameters and a colon : marks the beginning of the expression. We can anyway give a name to the lambda function with:

myfunct = lambda input-parameters: expression

Some examples of lambda functions follow:

[ ]:
sum_lambda = lambda x, y : x+y
mult_lambda = lambda x,y : x*y
cap_lambda = lambda x : x.capitalize()

print(sum_lambda(10,20))
print(sum_lambda("Hi ", "there!"))
print("\n")
print(mult_lambda(10,20))
print(mult_lambda("Hi! ", 3))
print("\n")
txt = "hi there from luca!"
print(cap_lambda(txt))
print(" ".join(map(cap_lambda, txt.split())))

More interesting uses of lambda involve sorting of tuples through the key parameter:

Example: Let’s count how many occurrences we have for each non-space character in the string “Testing shows the presence, not the absence of bugs”. And print the least and most frequent character, as well as the first and last in alphabetic order.

[3]:
myText = "testing shows the presence, not the absence of bugs"

cnts = [(x,myText.count(x)) for x in myText if x != " " and x != ","]
uniqueCnts = []
for e in cnts:
    if(e not in uniqueCnts):
        uniqueCnts.append(e)
print(uniqueCnts)

get_first = lambda x : x[0]
get_second = lambda x : x[1]

#Get the most and least frequent character(s).
uniqueCnts.sort(key = get_second)
all_least_f = list(filter(lambda x: x[1] == uniqueCnts[0][1], uniqueCnts))
all_most_f = list(filter(lambda x: x[1] == uniqueCnts[-1][1], uniqueCnts))
print("All least frequent: {}".format(all_least_f))
print("All most frequent: {}".format(all_most_f))

#Get the most and least frequent character.
uniqueCnts.sort(key = get_first)
print("The first in alphabetic order: {}".format(uniqueCnts[0]))
print("The last in alphabetic order: {}".format(uniqueCnts[-1]))
[('t', 5), ('e', 8), ('s', 6), ('i', 1), ('n', 4), ('g', 2), ('h', 3), ('o', 3), ('w', 1), ('p', 1), ('r', 1), ('c', 2), ('a', 1), ('b', 2), ('f', 1), ('u', 1)]
[('i', 1), ('w', 1), ('p', 1), ('r', 1), ('a', 1), ('f', 1), ('u', 1), ('g', 2), ('c', 2), ('b', 2), ('h', 3), ('o', 3), ('n', 4), ('t', 5), ('s', 6), ('e', 8)]
All least frequent: [('i', 1), ('w', 1), ('p', 1), ('r', 1), ('a', 1), ('f', 1), ('u', 1)]
All most frequent: [('e', 8)]
[('a', 1), ('b', 2), ('c', 2), ('e', 8), ('f', 1), ('g', 2), ('h', 3), ('i', 1), ('n', 4), ('o', 3), ('p', 1), ('r', 1), ('s', 6), ('t', 5), ('u', 1), ('w', 1)]
The first in alphabetic order: ('a', 1)
The last in alphabetic order: ('w', 1)

Exercises

  1. Create a Consumer class with:

    • wealth as attribute (amount of money)

    • earn method, where earn(m) increments the consumer’s wealth by m

    • a spend method, where spend(m) either decreases wealth by m or returns an error if there are not enough money

    • a __str__ will print the wealth at the current time

Show/Hide Solution

  1. Define a 3D point class (Point3D) which contains three attributes that are the (x,y,z) coordinates in the 3D space and a string (label). Implement a computeDistance method that computes the distance between the point and another point. Remember that if \(a = (x_a,y_a,z_a)\) and \(b = (x_b,y_b,z_b)\), \(distance(a,b) = \sqrt{{(x_a-x_b)}^2 + {(y_a-y_b)}^2 + {(z_a-z_b)}^2}\).

Given the following points:

p = Point3D(0,10,0, "alfa")
p1 = Point3D(10,20,10, "point")
p2 = Point3D(4,9, 10, "other")
p3 = Point3D(8,9,11, "zebra")
p4 = Point3D(0,10,10, "label")
p5 = Point3D(0,10,10, "last")
p6 = Point3D(42,102,10, "fifth")
  1. Write some code to find out the closest pair of 3D points;

  2. Sort the points by distance to the origin (0,0,0) and print them in this order;

  3. Sort the points alphabetically by label (and print them).

Show/Hide Solution

  1. Write a Person class with the following attributes: name, surname and mailbox. A mailbox is a list that contains string messages sent to the person. Each message entry should be a tuple (name, surname, message) where name and surname are the name and surname of the sender of the message, while message is a string with the text of the message. Implement the following methods:

    1. getName : gets the name of the Person;

    2. getSurname : gets the surname of the Person;

    3. sendMessage : that has a Person and the string with the message in input and sends the message to the specified Person;

    4. checkMailbox : returns the number of messages in the mailbox;

    5. readMessages : returns all the messages in the mailbox as a list;

    6. checkMessagesFrom : checks and prints any messages coming from a specific Person (in input);

    7. clearMailbox : clears the mailbox (does not return anything);

Test the class with the following conversation:

luca = Person("Luca", "Bianco")
alberto = Person("Alberto", "Montresor")
david = Person("David", "Leoni")
stranger = ""

luca.sendMessage(alberto, "Hi Alberto, hope things are fine.")
alberto.sendMessage(luca, "I am fine, thanks. Yourself?")
luca.sendMessage(alberto, "Great. Cheers. How about David?")
alberto.sendMessage(david, "You OK?")
david.sendMessage(alberto, "Yep. Thanks")
alberto.sendMessage(luca, "All OK")
luca.sendMessage(stranger, "Who are you?")

and check the mailbox of all the Persons in [luca,alberto,david].

Show/Hide Solution