Classi

Nella lezione di oggi approfondiamo un elemento estremamente interessante del linguaggio Python, le classi. Per capire l'importanza di questa nozione basta pensare che la possibilità di utilizzare le classi è una delle caratteristiche che porta a una macro divisione tra i linguaggi di programmazione. In particolare i linguaggi di programmazione dotati di questa caratteristica prendono il nome di linguaggi di programmazione orientati agli oggetti. La programmazione ad oggetti prevede di raggruppare in un blocco di codice chiamato classe, la dichiarazione delle strutture dati e delle funzioni che operano su tali strutture.Le classi, quindi, costituiscono dei modelli astratti, che a tempo di esecuzione vengono invocate per creare una realizzazione del modello astratto detta istanza. Queste ultime sono dotati di attributi, le strutture dati e di metod, le funzioni. Vediamo ora come definire una classe e come iniziallizare un istanza:

In [1]:
class Point2D:
    pass
X = Point2D()

E' importante notare che i vincoli che abbiamo imposto sui nomi delle variabili valgono anche per le calssi. Osserviamo che nel codice precedente abbiamo costruito la classe modello Point 2D che ha tipologia class come possiamo osservare nello script successivo. Dopo di che abbiamo inizializato un istanza della classe con il nome X, in particolare possiamo verifcare che la tipologia della istanza è < class '__main__.Point2D' > il che ci dice che è un istanza della classe Point2D dichiarata direttamente nel sorgente.

In [2]:
print("Tipologia della classe {}, tiplogia della istanza {}.".format(type(Point2D),type(X)))
Tipologia della classe <class 'type'>, tiplogia della istanza <class '__main__.Point2D'>.

Come possiamo immaginare dalla forma della stringa che restituisce l'istruzione type quando applicata su una classe possiamo concatenare le classi.

In [3]:
class Circle:
    class Point2D:
        pass
S = Circle()
print(type(S))
<class '__main__.Circle'>
In [4]:
type(S.Point2D())
Out[4]:
__main__.Circle.Point2D

Vediamo ora come popolare la classe con delle strutture dati, vediamo inoltre come si comportano le struture dati di un istanza per chiarificare la differenza tra classe e istanza.

In [5]:
class Point2D:
    x = 0;
    y = 0;
P = Point2D(); Q = Point2D()
P.x = 0; P.y = 0
Q.x = 1; Q.y = 1
print("Coordinate punto P ({},{}), coordinate punto Q ({},{})".format(P.x,P.y,Q.x,Q.y))
Coordinate punto P (0,0), coordinate punto Q (1,1)

Osserviamo inolte che le strutture dati all'interno della classe, anche se essa è un oggetto astratto, sono mutabile. Dove intendiamo la mutabilità in senso generale, ovvero se un valore viene assegnato si può cambiare e in tutte le istanza successiva il valore sarà aggiornato, se un valore non è inizializato allora si può aggiungere alla classe e farà parte di tutte le nuove inizialliazioni.

In [6]:
class Point2D:
    y = 1
P = Point2D()
print(P.y)
Point2D.y=2;
Q = Point2D()
print(Q.y)
Point2D.x=1;
R = Point2D()
print("({},{})".format(R.x,R.y))
1
2
(1,2)

Vediamo ora come aggiungere un metodo ad una classe.

In [7]:
class Point2D:
    x = 0;
    y = 0;
    def info(self):
        print("Coordinate del punto sono: ({},{})".format(self.x,self.y))

def info(Q):
    print("Coordinate del punto sono: ({},{})".format(Q.x,Q.y))
    
P = Point2D();
P.y = 1;
P.info()
info(P)
Coordinate del punto sono: (0,1)
Coordinate del punto sono: (0,1)

Osserviamo che se avessimo dichiarato la funzione al di fuori della classe quando la chiamiamo le avremmo passato un argomento, eppure quando dichiariamo la funzione dentro la classe definiamo che essa richieda l' argomento self ma quando la chiamiamo non passiamo alcun argomento. Studiamo questo paradigma di programmazione. Quando ad una funzione passiamo come argomento self,intendiamo che essa riceve in argomento tutte le strutture dati e i metodi della inizializazione della classe dalla quale abbiamo chiamato la funzione. Inoltre questo è il motivo per cui quando chiamiamo la funzione info se essa è stata dichiarata all'interno della classe la sintassi P.info equivale a info(P). Osserviamo che è possibile passare degli argomenti ad una classe come si fa con una funzione, ma per fare ciò dobbiamo capire come funziona il meccanismo di inizializzazione delle classi. Per prima cosa costruiamo una funzione esterna che serve a passare argomenti a una classe al momento dell' inizializzazione.

In [8]:
class Point2D:
    x = 0;
    y = 0;
    def info(self):
        print("Coordinate del punto sono: ({},{})".format(self.x,self.y))
def ini(Q,x,y):
    Q.x = x; Q.y = y;
P = Point2D()
ini(P,1,0)
P.info()
Coordinate del punto sono: (1,0)

Il processo che avviene nello script precedente è il seguente, inizialliziamo l'istanza P della classe Point2D e dopo di che assegnamo dei valori alle strutture dati della classe. Includiamo ora la funzione all' interno della classe e osserviamo che come fatto per la funzione info possiamo utilizzare l'istruzione self per evitare di passare la classe come argomento alla funzione:

In [9]:
class Point2D:
    x = 0;
    y = 0;
    def info(self):
        print("Coordinate del punto sono: ({},{})".format(self.x,self.y))
    def ini(self,x,y):
        self.x = x; self.y = y;
P = Point2D()
P.ini(1,1)
P.info()
Coordinate del punto sono: (1,1)

La abbiamo presa per le lunghe ma siamo ora finalemte pronti ad introdurre la funzione __init__, essa è la funzione che viene chiamata automatichamente quando inizalizziamo la classe. Ovvero, quando inizializziamo la classe e le passiamo degli argomenti equivale a lanciare la funzione __init__ e passarle gli argomenti della classe.

In [10]:
class Point2D:
    x = 0;
    y = 0;
    def __init__(self,x,y):
        self.x = x; self.y = y
    def info(self):
        print("Coordinate del punto sono: ({},{})".format(self.x,self.y))
Q = Point2D(1,1)
Q.info()
Coordinate del punto sono: (1,1)

le funzioni all'interno di una classe con nome __funcname__ hanno specifiche funzioni e vengono detti operatori. Per esempio la funzione __str__ viene chiamata ogni volta che una istanza della classe viene convertita ad una stringa.

In [11]:
class Point2D:
    x = 0;
    y = 0;
    def __init__(self,x,y):
        self.x = x; self.y = y
    def __str__(self):
        return "({},{})".format(self.x,self.y);
    def info(self):
        print("Coordinate del punto sono: ({},{})".format(self.x,self.y))
Q = Point2D(1,1)
Q.info()
print(str(Q))
print(Q)
Coordinate del punto sono: (1,1)
(1,1)
(1,1)

Possiamo osservare il comprtamento della funzione print, ovvero quando cerchiamo di stampare una classe, o meglio l'istanza di una classe, il comando print cerca di convertire l'istanza in una stringa (quindi chiama la funzione str all' interno della classe) e stampa la stringa così ottenuta. Possiamo implementare come si comporta una classe quando la si converte a una qualsiasi tipologia di dato.

In [12]:
class Point2D:
    x = 0;
    y = 0;
    def __init__(self,x,y):
        self.x = x; self.y = y
    def __str__(self):
        return "({},{})".format(self.x,self.y);
    def __int__(self):
        return self.x;
    def info(self):
        print("Coordinate del punto sono: ({},{})".format(self.x,self.y))
Q = Point2D(1,1)
Q.info()
print(int(Q))
Coordinate del punto sono: (1,1)
1

Abbiamo in precedenza parlato di tipologie di dato che sono iterabili, ovvero sulle quali possiamo costruire un ciclo. Vediamo come possiamo rendere una classe iterabile, per fare questo dobbiamo introdurre le funzioni __iter__ e __next__ oltre che l'istruzione raise. Quest'ultima istruzione appartiene al reame degli errori e delle eccezioni che approfondiremo in seguito, ora ci limiteremo a spiegarne la sua utilità in questo contesto. La funzione __iter__ restituisce l'oggetto sul quale andremo a iterare, in questo caso particolare l'istanza stessa. Invece la funzione __next__ verà eseguita per ottenere l'elemento che rappreseta ciascun passo dell'iterazione. Chiaramente se pensiamo ad iterare una lista vogliamo che la funzione __next__ ci restituisce l'elemento successivo al precedente e che si fermi quando arriviamo infondo alla lista. Per imporre la condizione di arresto utilizziamo un blocco condizionale e l'istruzione raise StopIteration.

In [13]:
class Point2D:
    C = [0,0]
    i = 0;
    def __init__(self,x,y):
        self.C[0] = x; self.C[1] = y
    def __str__(self):
        return "({},{})".format(self.C[0],self.C[1]);
    def __int__(self):
        return self.x;
    def info(self):
        print("Coordinate del punto sono: ({},{})".format(self.C[0],self.C[1]))
    def __iter__(self):
        return self;
    def __next__(self):
        self.i = self.i+1;
        if self.i >= 3:
            raise StopIteration
        return self.C[self.i-1]
    
Q = Point2D(2,3)
Q.info()
for x in Q:
    print(x)
Coordinate del punto sono: (2,3)
2
3

Osserviamo però che siccome quando terminiamo la funzione __next__ non azzeriamo il contatore, esso rimarra modificato nell'istanza $Q$ e quindi quando cercheremo di eseguire un altra iterazione su $Q$ questa non porterà risultati. Per risolvere questo problema abbiamo due alternative o azzerare il contatore prima di uscire dalla classa oppure imporlo nullo ogni volta che iniziamo l'iterazione.

In [14]:
print("Seconda iterazione con la vecchia definizione di classe:")
for y in Q:
    print(y)
print("Seconda iterazione dopo aver ridefinito la classe:")
class Point2D:
    C = [0,0]
    i = 0;
    def __init__(self,x,y):
        self.C[0] = x; self.C[1] = y
    def __str__(self):
        return "({},{})".format(self.C[0],self.C[1]);
    def __int__(self):
        return self.x;
    def info(self):
        print("Coordinate del punto sono: ({},{})".format(self.C[0],self.C[1]))
    def __iter__(self):
        self.i = 0;
        return self;
    def __next__(self):
        self.i = self.i+1;
        if self.i >= 3:
            raise StopIteration
        return self.C[self.i-1]
Q = Point2D(1,2)
for x in Q:
    pass
for y in Q:
    print(y)
Seconda iterazione con la vecchia definizione di classe:
Seconda iterazione dopo aver ridefinito la classe:
1
2

Osserviamo che non è necessario iniziallizare all'inizio della classe tutte le variabili che si andranno ad utilizzare ma è buona prassi farlo, e risulta abituale per chi usa linguaggi tipo C,C+. Per esempio possiamo omettere di inizializzare l'indice di iterazione $i$ all' inizio della classe.

In [15]:
class Point2D:
    C = [0,0]
    def __init__(self,x,y):
        self.C[0] = x; self.C[1] = y
    def __str__(self):
        return "({},{})".format(self.C[0],self.C[1]);
    def __int__(self):
        return self.x;
    def info(self):
        print("Coordinate del punto sono: ({},{})".format(self.C[0],self.C[1]))
    def __iter__(self):
        self.i = 0;
        return self;
    def __next__(self):
        self.i = self.i+1;
        if self.i >= 3:
            raise StopIteration
        return self.C[self.i-1]
Q = Point2D(1,2)
for y in Q:
    print(y)
1
2

Osserviamo inoltre che il concetto di scope visto per le funzioni vale anche per le classi e per le funzioni all' interno delle classi.

In [16]:
x = 1
class Test:
    x = 2
    def info(self):
        x = 3
        print("Valore della variabile x all' interno dello scope della funzione: {}".format(x));
        print("Valore della variabile x all' interno dello scope della classe: {}".format(self.x));
print(x)
T = Test();
T.info()
1
Valore della variabile x all' interno dello scope della funzione: 3
Valore della variabile x all' interno dello scope della classe: 2

Osserviamo inoltre che possiamo chimare un metodo appartenente ad una classe senza che essa sia inizializzata in un istanza, come abbiamo visto per le strutturi dati all' inizio.

In [17]:
class Test:
    def greet():
        print("Ciao Mondo");
Test.greet()
Ciao Mondo

Possiamo inoltre aggiungere una funzione ad una classe dopo la sua definizione come abbiamo visto per le strutture dati.

In [18]:
class Test:
    nome = "Perry"
    def greet():
        print("Ciao Mondo");
Test.saluti()
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-18-345a75a743f3> in <module>
      3     def greet():
      4         print("Ciao Mondo");
----> 5 Test.saluti()

AttributeError: type object 'Test' has no attribute 'saluti'
In [19]:
def saluti(self):
    print("Ciao Mondo sono {}".format(self.nome))
Test.saluti = saluti
T=Test();
T.saluti()
Ciao Mondo sono Perry

Vediamo ora un esempio che ci permettera di capire meglio come funzionano le classi e cosa sono le proprietà di eredità. Definiamo ora la classe che rappresenta la famiglia dei canidi, dopo e inizializzeremo l'istanza del cane lupo e della volpe artica.

In [20]:
class Canidae:
    Famiglia= "Canidae"
    def __init__(self,Geno,Specie):
        self.Geno = Geno
        self.Specie = Specie
    def __str__(self):
        return "Famiglia: {}, Geno: {}, Specie: {}.".format(self.Famiglia,self.Geno,self.Specie)
    
Lupo = Canidae("Canis","Lupus")
Volpe = Canidae("Vulpes","Lagopus")
print(Lupo)
print(Volpe)
Famiglia: Canidae, Geno: Canis, Specie: Lupus.
Famiglia: Canidae, Geno: Vulpes, Specie: Lagopus.

Abbiamo creato l'istaza lupo e l'istanza volpe, e possiamo osservare che siccome entrambe appartengono alla classe Canidar hanno l'attributo Famiglia uguale a Canidae.

In [21]:
print(Lupo.Famiglia)
print(Volpe.Famiglia)
Canidae
Canidae

Vogliamo ora creare ora la calasse dei cani e sappiamo che tutti i cani sono canidi dunque vogliamo che la classe Canis erediti tutti gli atributi ( e tutte le funzioni se definite) della classe Canidae. Per fare questo definiamo la classe dei cani come una classe figlia della classe dei canidi,per mezzo della sintassi: class ChildClass(FatherClass).

In [22]:
class Canis(Canidae):
    Geno = "Canis"
    def __init__(self,Specie):
        self.Specie=Specie;
print(type(Canis))
Lupo = Canis("Lupus")
print(Lupo)
Coyote = Canis("Latrans")
print(Coyote)
isinstance(Lupo,Canidae)
<class 'type'>
Famiglia: Canidae, Geno: Canis, Specie: Lupus.
Famiglia: Canidae, Geno: Canis, Specie: Latrans.
Out[22]:
True