Object Oriented Programming (OOP) e Classi in Python¶

Introduzione¶

Questo notebook si basa sulle lezioni 8 e 9 di: https://ocw.mit.edu/courses/electrical-engineering-and-computer-science/6-0001-introduction-to-computer-science-and-programming-in-python-fall-2016/

Python contiene molti tipi di dati:

  • 1234 (int)
  • 0.27 (float)
  • 'Hello' (str)
  • [1,2,3,4] (list)
  • {1:'a', 2:'b', 3:'c'} (dict)
  • numpy.array([1,2]) (array)

Ciascuno è un oggetto, caratterizzato da

  • un tipo (type)
  • una rappresentazione interna, tipicamente non nota e non modificabile dall'utilizzatore
  • un set di procedure che permettono di interagire con gli esemplari (instance) di un oggetto

Per esempio:

  • 1234 e 356 sono esemplari di int

  • 'Hello' e 'World' sono esemplari di str

  • 1234 + 356 è la procedura che permette di sommare due interi

  • 'Hello' + 'World' è la procedura che permette di concatenare due stringhe

In Python TUTTO È UN OGGETTO¶

  • ha un tipo
  • può essere creato
  • può essere manipolato
  • può essere distrutto (cancellato dalla memoria)

La metodologia Object-Oriented Programming (OOP) permette di separare la rappresentzione interna dall'interfaccia attraverso cui interagire con gli oggetti e dai dettagli delle manipolazioni a cui gli oggetti possono essere soggetti. Per fare un esempio tratto dalla vita quotidiana, dato un esemplare di oggetto automobile una delle sue caratteristiche è la velocità. Per ottenere che l'esemplare aumenti la sua velocità devo applicare la procedura aumentare la pressione sul pedale dell'acceleratore. Tutti gli esemplari di oggetto automobile aumentano la propria velocità usando la stessa procedura. Non ho bisogna di conoscere i dettagli di come un aumento della pressione sull'acceleratore porta al risultato di aumentare la velocità. Un eventuale miglioramento dei meccanismi interni non richiede una modifica dell'interfaccia pedale dell'acceleratore e quindi del modo con cui l'utilizzatore interagisce con l'esemplare.

Definire una classe corrisponde a definire una oggetto su misura delle nostre esigenze che contiene una serie definita di dati e di metodi che agiscono su questi dati.

Bisogna distinguere chiaramente fra creare una classe e utilizzare un esemplare di una classe:

  • Creare una classe richiede:
    • Definire il nome della classe
    • Definire gli attributi (dati e metodi) della classe
  • Utilizzare un esemplare della classe richiede:
    • Creare degli esemplari della classe
    • Eseguire operazioni sugli esemplari

Creare una classe¶

In [8]:
#keyword     nome     classe da cui eredita (opzionale)

class      Coordinate(object):
    # definire qui gli  attributi (dati e procedure)

    def __init__(self,p,q): # metodo indispensabile. Inizializza gli esemplari. 
                            # a self viene sostituito il nome assegnato a ogni esemplare
                            # p e q sono input che vengono assegnati agli attributi `x` e `y`
        self.x = p          
        self.y = q

Utilizzare esemplari della classe¶

In [10]:
c = Coordinate(3,4)
origin = Coordinate(0,0)
# su `c` e `origin` possiamo utilizzare solo i metodi `x` e `y`
print(c.y)
print(origin.x)
4
0

Aggiungiamo alla classe Coordinate un metodo per calcolare la distanza fra due punti. Uno dei due punti viene identificato con self in modo da poter chiamare il metodo su un singolo punto,other che è anch'esso esemplare della classe.
A parte l'uso di self e della notazione . per la chiamata, i metodi interni ad una classe si comportano esattamente come funzioni: prendono input, eseguono operazioni, restituiscono risultati.

In [18]:
class Coordinate(object):

    def __init__(self,p,q): 
        self.x = p          
        self.y = q
        
    def distance(self, other):
        x_diff_sq = (self.x-other.x)**2
        y_diff_sq = (self.y-other.y)**2
        return (x_diff_sq + y_diff_sq)**0.5

Come usare i metodi di una classe¶

In [7]:
c = Coordinate(3,4)
zero = Coordinate(0,0)
print(c.distance(zero))  # c viene automaticamente assegnato a `self`
                         # essendo c un esemplare di Coordinate, `distance` è automaticamente disponibile
5.0

distance può anche essere chiamata esplicitamente come una funzione contenuta nel modulo Coordinate. Gli oggetti che vengono passati come input devono essere entrambi esemplari di Coordinate. In caso contrario distance non potrebbe accedere alle proprietà x e y degli oggetti e si avrebbe un errore.

In [9]:
print(Coordinate.distance(c, zero)) 
5.0
In [20]:
r = (2,3)
s = (0,0)
Coordinate.distance(r,s)
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[20], line 3
      1 r = (2,3)
      2 s = (0,0)
----> 3 Coordinate.distance(r,s)

Cell In[18], line 8, in Coordinate.distance(self, other)
      7 def distance(self, other):
----> 8     x_diff_sq = (self.x-other.x)**2
      9     y_diff_sq = (self.y-other.y)**2
     10     return (x_diff_sq + y_diff_sq)**0.5

AttributeError: 'tuple' object has no attribute 'x'

Stampare un esemplare di una classe e metodo __str__¶

In [11]:
c = Coordinate(3,4)
print(c)
<__main__.Coordinate object at 0x1035000a0>

Il comportamento di default di print su un membro di una classe non è molto utile. Può essere modificato definendo il metodo speciale str. Supponiamo di volere che print(c) restituisca <3,4>. Possiamo fare così:

In [12]:
class Coordinate(object):

    def __init__(self,p,q): 
        self.x = p          
        self.y = q
        
    def distance(self, other):
        x_diff_sq = (self.x-other.x)**2
        y_diff_sq = (self.y-other.y)**2
        return (x_diff_sq + y_diff_sq)**0.5
    
    def __str__(self):
        return "<"+str(self.x)+","+str(self.y)+">"
In [14]:
c = Coordinate(3,4)
print(c)
<3,4>

Esistono molti altri metodi speciali standard. Per esempio:

__add__(self, other) --> self + other
__sub__(self, other) --> self - other
__eq__(self, other) --> self == other
__lt__(self, other) --> self < other
__len__(self) --> len(self)

Ciascuno di questi metodi può essere ridefinito come abbiamo fatto per __str__.

Un esempio piú elaborato¶

Definiamo una classe Fraction che rappresenti una frazione come una coppia numeratore-denominatore e permetta di eseguire le operazioni fra frazioni in modo esatto, senza trasformarle in floating point.

In [45]:
# Better solution in https://stackoverflow.com/questions/36539460/implementing-negation-by-overriding-neg-in-python
class Fraction():
    
    # missing check that denominators are non zero and that input are integers
    def __init__(self,n,d=1):                     # default value for d 
        # built in simplification
        for div in range(2,min(abs(n),abs(d))+1): # Notice start- and end-point
                                                  # abs needed for negative fractions
            while n%div==0 and d%div==0: # can have multiple powers of div
                n=n/div
                d=d/div
        self.n = int(n)
        self.d = int(d)
        
    def __str__(self):
        if self.d ==1:
            return str(self.n)  # don't print denominator if equal 1
        else:
            return str(self.n)+"/"+str(self.d)
        
    def __add__(self, other):  # defines self + other
        nr = self.n*other.d + self.d*other.n
        dr = self.d*other.d
        res = Fraction(nr,dr)
        return res

    def __sub__(self, other):  # defines self - other
        nr = self.n*other.d - self.d*other.n
        dr = self.d*other.d
        res = Fraction(nr,dr)
        return res
    
    def __mul__(self, other):  # defines self*other
        nr = self.n*other.n
        dr = self.d*other.d
        res = Fraction(nr,dr)
        return res

    def __truediv__(self, other):  # defines self/other
        nr = self.n*other.d
        dr = self.d*other.n
        res = Fraction(nr,dr)
        return res

    def __neg__(self):  # defines - self
        nr = -self.n
        dr = self.d
        res = Fraction(nr,dr)
        return res
    
    def __eq__(self, other):  # defines self==other
        if self.n == other.n and self.d == other.d:
            return True
        else:
            return False
        
   
        

Alcuni esempi di utilizzo della classe Fraction.

In [25]:
a = Fraction(4,6)
print(a)
2/3
In [11]:
b = Fraction(3,9)
print(b)
1/3
In [4]:
print(a+b)
1/1
In [5]:
print(b-a)
-1/3
In [82]:
print(a==b)
False
In [83]:
print(a==a)
True
In [12]:
print(a*b)
2/9
In [13]:
print(a/b)
2/1
In [54]:
print(a.__neg__())
print(a.__neg__().n)
minusa =-a
print('type:',type(minusa),'\nvalue of minusa:',minusa,'\nnumerator:',minusa.n,'\ndenominator:',minusa.d)
-2/3
-2
type: <class '__main__.Fraction'> 
value of minusa: -2/3 
numerator: -2 
denominator: 3
In [23]:
f = Fraction(3)
In [24]:
print(f)
3

Accedere e modificare i dati di un esemplare: getters and setters¶

Abbiamo visto che se nell' __init__ di una classe C è definito l'attributo x è possibile accedere al valore dell'attributo per un esemplare es con la notazione es.x.
È però preferibile e fortemente incoraggiato accedere e modificare, dall'esterno della classe, gli attributi di un esemplare attraverso metodi appositi, detti getters e setters, che devono essere definiti all'interno della classe.

In [36]:
class Animal(object):
    def __init__(self, age): # è possibile e necessario dichiarare solamente `age` quando 
                             # una instance viene creata 
        self.age = age
        self.name = None

    def get_age(self):    # getter. Restituiscono l'informazione. 
                          # Posso stamparla o manipolarla ulteriormente.
        return self.age
        
    def get_name(self):   # getter
        return self.name

    def set_age(self, newage):        # setter
        self.age = newage

    def set_name(self, newname=""):   # setter con valore di default
        self.name = newname
            
    def __str__(self):
        return "animal:"+str(self.name)+":"+str(self.age)
In [37]:
cane = Animal(3)
In [40]:
print('age:',cane.get_age())
print('name:',cane.get_name())
age: 3
name: None
In [43]:
cane.set_name('Fido')
In [44]:
print('name:',cane.get_name())
name: Fido

Una delle idee basilari dell'Object Oriented programmi è di interagire con gli oggetti solamente attraverso le funzioni della classe, nascondendo i dettagli interni dell'implementazione.

Inheritance¶

Una classe può ereditare metodi e attributi da una superclasse già esistente.

Dalla (super)classe Animal possiamo costruire sottoclassi che possiedono metodi e attributi particolari.

Esempio 1:¶

Una sottoclasse con due metodi addizionali e una implementazione diversa del metodo __str__:

In [67]:
class Cat(Animal): # `Cat` eredita tutti gli attributi e i metodi di `Animal`

    def speak(self):     # Il metodo `speak` stampa un' informazione. Restituisce `None`.
        print("meow")
    
    def purr(self):
        print("Purr")

    def __str__(self):
        return "cat:"+str(self.name)+":"+str(self.age)

Tutti i metodi di Animal sono applicabili a una instance di Cat:

In [68]:
gatto = Cat(5)
gatto.set_name('Fuffy')
In [69]:
print('age:',gatto.get_age())
print('name:',gatto.get_name())
age: 5
name: Fuffy

Metodi nuovi o modificati

In [70]:
gatto.speak()
gatto.purr()
print(gatto)
meow
Purr
cat:Fuffy:5

Esempio 2:¶

Una sottoclasse con metodi e attributi addizionali:

In [72]:
class Person(Animal):  # `Person` eredita tutti gli attributi e i metodi di `Animal`. Non eredita da `Cat`.
    
    def __init__(self, name, age):  # La creazione di una instance di `Person` richiede un nome e un'età
        Animal.__init__(self, age)
        self.set_name(name)
        self.friends = []           # attributo non presente nella superclasse

    def get_friends(self):          # getter 
        return self.friends
    
    def add_friend(self, fname):    # setter appropriato per una lista
        if fname not in self.friends:
            self.friends.append(fname)

    def speak(self):    # metodo con nome identico ad un metodo di `Cat` ma implementazione diversa
        print("hello")
        
    def age_diff(self, other):
        diff = self.age - other.age
        print(abs(diff), "year difference")

    def __str__(self):
        return "person:"+str(self.name)+":"+str(self.age)
In [75]:
p1 = Person("Bob",22)
p1.add_friend("Carol")
p1.add_friend("George")
In [76]:
p1.get_friends()
Out[76]:
['Carol', 'George']

Una Person non può fare le fusa:

In [77]:
p1.purr()
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-77-6ea84170e471> in <module>
----> 1 p1.purr()

AttributeError: 'Person' object has no attribute 'purr'

Esempio 3:¶

Una sottoclasse che eredita da un'altra sottoclasse con metodi e attributi addizionali e/o modificati

In [82]:
import random

class Student(Person): # `Student` eredita metodi e attributi da `Person`

    def __init__(self, name, age, major=None):
        Person.__init__(self, name, age)
        self.major = major

    def get_major(self):            # getter 
        return self.major
    
    def change_major(self, major):  # setter
        self.major = major

    def speak(self):
        r = random.random()
        if r < 0.25:
            print("I have homework")
        elif 0.25 <= r < 0.5:
            print("I need sleep")
        elif 0.5 <= r < 0.75:
            print("I should eat")
        else:
            print("I am watching tv")

    def __str__(self):
        return "student:"+str(self.name)+":"+str(self.age)+":"+str(self.major)

Uno Student, essendo una Person, può avere amici:

In [83]:
s1 = Student(age= 19,name="Peter")
In [84]:
s1.add_friend("Susan")
s1.get_friends()
Out[84]:
['Susan']

Ha una raffinata capacità di conversazione:

In [87]:
s1.speak()
s1.speak()
s1.speak()
I should eat
I am watching tv
I am watching tv

Esempio 4:¶

Una sottoclasse che possiede un attributo condiviso con tutti gli esemplari della classe

In [88]:
# Note to self: opportuno commentare le parti di codice che non si vogliono attivare immediatamente
# Evita di riscrivere più volte la parte iniziale 
class Rabbit(Animal):
    tag = 1
    
    def __init__(self, age, parent1=None, parent2=None):
        Animal.__init__(self, age)
        self.parent1 = parent1
        self.parent2 = parent2
        self.rid = Rabbit.tag
        Rabbit.tag += 1               # ogni volta che viene creato un esemplare tag aumenta di uno

    def get_rid(self):             
        return str(self.rid).zfill(3)   # zfill(3) aggiunge gli eventuali zeri iniziali per arrivare a tre caratteri 

    def get_parent1(self):
        return self.parent1

    def get_parent2(self):
        return self.parent2
    
    def __add__(self, other):
# returning object of same type as this class
        return Rabbit(0, self, other)

    def __eq__(self, other):    # L'operatore == controlla che due esemplari abbiano gli stessi due genitori 
        parents_same = (self.parent1.rid == other.parent1.rid 
            and self.parent2.rid == other.parent2.rid)
        parents_opposite = (self.parent2.rid == other.parent1.rid
            and self.parent1.rid == other.parent2.rid)
        return parents_same or parents_opposite
In [89]:
r1 = Rabbit(1)
In [90]:
r2 = Rabbit(1,"Leopold","Margarete")
In [94]:
print('id of r1:',r1.get_rid())
print('id of r1:',r2.get_rid())
id of r1: 001
id of r1: 002

Esempio 5¶

Una classe con metodi generici che vengono specificati in sottoclassi.

La classe 'Shape' contiene l'attributo name e i metodi generici area e perimeter.

In [1]:
class Shape:
    def __init__(self, name):
        self.name = name
    
    def area(self):
        pass
    
    def perimeter(self):
        pass

Introduciamo due classi (Circle and Rectangle), che estendono Shape e contengono gli attributi necessari per calcolare area e perimetroa. I metodi generici vengono sovrascritti per ottenere il risultato corretto. L'attributo name viene inizializzato al volore voluto usando il costruttore super().

In [ ]:
# super() va chiarito
In [2]:
import math

class Circle(Shape):
    def __init__(self, radius):
        super().__init__('circle')
        self.radius = radius
        
    def area(self):
        return math.pi * math.pow(self.radius, 2)
    
    def perimeter(self):
        return 2 * math.pi * self.radius
    
class Rectangle(Shape):      
    def __init__(self, b, h):
        super().__init__('rectangle')
        self.b = b
        self.h = h
        
    def perimeter(self):
        return 2 * (self.b + self.h)
    
    def area(self):
        return self.b * self.h
              

Creiamo un oggetto Circle con raggio=5 un oggetto Rectangle con base=3 and altezza=2.

In [6]:
my_circle = Circle(radius=5)
# Alternative way
circle1 = Circle(5)
my_rectangle = Rectangle(b=3, h=2)
In [7]:
type(my_circle)      # perchè stampa __main__.Circle? 
Out[7]:
__main__.Circle

Stampiamo l'area e il perimetro di ciascun oggetto:

In [8]:
print(f"my_ircle area: {my_circle.area():.2f}, perimeter: {circle.perimeter():.2f}")
print(f"circle1 area: {circle1.area():.2f}, perimeter: {circle1.perimeter():.2f}")
print(f"my_rectangle area: {my_rectangle.area():.2f}, perimeter: {rectangle.perimeter():.2f}")
Circle area: 78.54, perimeter: 31.42
Circle1 area: 78.54, perimeter: 31.42
Rectangle area: 6.00, perimeter: 10.00
In [11]:
print(f"my_circle radius: {my_circle.radius:.2f}")
print(f"my_circle name: '{my_circle.name}'")
my_circle radius: 5.00
my_circle name: 'circle'

Scriviamo una funzione print_shape che prende un esemplare di Shape e stampa il suo nome, l'area e il perimetro. Poi le pasiamo gli oggetti che abbiamo creato.

In [20]:
def print_shape(shape: Shape):
    print(f"{shape.name} area: {shape.area():.2f}, {shape.perimeter():.2f} perimeter")
    
print_shape(circle)
print_shape(rectangle)
circle area: 78.54, 31.42 perimeter
rectangle area: 6.00, 10.00 perimeter

Se si cerca di stampare direttamente le istanze:

In [21]:
print(circle)
print(rectangle)
<__main__.Circle object at 0x00000274FF826088>
<__main__.Rectangle object at 0x00000274FF826848>
In [ ]:
 
In [ ]:
#Add  decoratori + @classmethod + @staticmethod