In uno dei capitoli introduttivi di questa guida abbiamo visto che Python è un linguaggio multi-paradigma, che supporta cioè sia la programmazione procedurale (che fa uso delle funzioni), sia la programmazione funzionale (includendo iteratori e generatori), sia la programmazione ad oggetti (includendo funzionalità come l'ereditarietà singola e multipla, l'overloading degli operatori, e il duck typing).
In questo capitolo introdurremo i concetti della programmazione orientata agli oggetti, e vedremo alcuni semplici esempi. Nei capitoli successivi vedremo più in dettaglio come usare gli oggetti in Python, la sintassi necessaria per crearli, e le operazioni che supportano.
Gli oggetti
Mentre nella programmazione procedurale le funzioni (o procedure) sono l'elemento organizzativo principale, nella programmazione ad oggetti (anche conosciuta come OOP, ovvero object-Oriented Programming) l'elemento organizzativo principale sono gli oggetti.
Nella programmazione procedurale, i dati e le funzioni sono separate, e questo può creare una serie di problemi, tra cui:
- è necessario gestire dati e funzioni separatamente;
- è necessario importare le funzioni che vogliamo usare;
- è necessario passare i dati alle funzioni;
- è necessario verificare che i dati e le funzioni siano compatibili;
- è più difficile estendere e modificare le funzionalità;
- il codice è più difficile da mantenere;
- è più facile introdurre bug.
Nella programmazione ad oggetti, gli oggetti svolgono la funzione di racchiudere in un'unica unità organizzativa sia i dati che il comportamento. Questo ha diversi vantaggi:
- dati e funzioni sono raggruppati;
- è facile sapere quali operazioni possono essere eseguite sui dati;
- non è necessario importare funzioni per eseguire queste operazioni;
- non è necessario passare i dati alle funzioni;
- le funzioni sono compatibili con i dati;
- è più facile estendere e modificare le funzionalità;
- il codice è più semplice da mantenere;
- è più difficile introdurre bug.
Vediamo un semplice esempio: abbiamo la base e l'altezza di 100 diversi rettangoli e vogliamo sapere area e perimetro di ogni rettangolo. Usando un approccio procedurale, possiamo risolvere il problema creando due funzioni separate che accettano base e altezza:
>>> # definiamo due funzioni per calcolare area e perimetro
>>> def calc_rectangle_area(base, height):
... """Calculate and return the area of a rectangle."""
... return base * height
...
>>> def calc_rectangle_perimeter(base, height):
... """Calculate and return the perimeter of a rectangle."""
... return (base + height) * 2
...
Possiamo poi creare una lista di tuple casuali (base, altezza), iterarla con un for
e passare i valori alle funzioni:
>>> from random import randrange
>>> # creiamo una lista di 100 tuple (base, altezza) con valori casuali
>>> rects = [(randrange(100), randrange(100)) for x in range(100)]
>>> rects
[(16, 39), (92, 96), (60, 72), (99, 32), (39, 5), (43, 6), (51, 28), ...]
>>> # iteriamo la lista di rettangoli e printiamo
>>> # base, altezza, area, perimetro di ogni rettangolo
>>> for base, height in rects:
... print('Rect:', base, height)
... print(' Area:', calc_rectangle_area(base, height))
... print(' Perimeter:', calc_rectangle_perimeter(base, height))
...
Rect: 16 39
Area: 624
Perimeter: 110
Rect: 92 96
Area: 8832
Perimeter: 376
Rect: 60 72
Area: 4320
Perimeter: 264
...
Usando la programmazione orientata agli oggetti, possiamo invece creare una classe che rappresenta l'oggetto rettangolo. Invece che rappresentare i rettangoli come una lista di tuple, usiamo la classe per creare 100 istanze della classe Rettangolo, e invece che chiamare le funzioni passando la base e l'altezza, chiamiamo i metodi dell'istanza:
>>> # definiamo una classe che rappresenta un rettangolo generico
>>> class Rectangle:
... def __init__(self, base, height):
... """Initialize the base and height attributes."""
... self.base = base
... self.height = height
... def calc_area(self):
... """Calculate and return the area of the rectangle."""
... return self.base * self.height
... def calc_perimeter(self):
... """Calculate and return the perimeter of a rectangle."""
... return (self.base + self.height) * 2
...
Nella prossima lezione vedremo piu in dettaglio come definire le classi, ma come possiamo vedere dal prossimo esempio, creare e usare istanze è abbastanza intuitivo:
>>> # creiamo un'istanza della classe Rectangle con base 3 e altezza 5
>>> myrect = Rectangle(3, 5)
>>> myrect.base # l'istanza ha una base
3
>>> myrect.height # l'istanza ha un'altezza
5
>>> myrect.calc_area() # è possibile calcolare l'area direttamente
15
>>> myrect.calc_perimeter() # e anche il perimetro
16
Ora che abbiamo un'idea di base del funzionamento delle classi, possiamo creare i 100 rettangoli e calcolare area e perimetro:
>>> from random import randrange
>>> # creiamo una lista di 100 istanze di Rectangle con valori casuali
>>> rects = [Rectangle(randrange(100), randrange(100)) for x in range(100)]
>>> # iteriamo la lista di rettangoli e printiamo
>>> # base, altezza, area, perimetro di ogni rettangolo
>>> for rect in rects:
... print('Rect:', rect.base, rect.height)
... print(' Area:', rect.calc_area())
... print(' Perimeter:', rect.calc_perimeter())
...
Rect: 78 44
Area: 3432
Perimeter: 244
Rect: 0 85
Area: 0
Perimeter: 170
Rect: 32 2
Area: 64
Perimeter: 68
Come possiamo vedere confrontando i due esempi, usando la programmazione ad oggetti possiamo lavorare direttamente con oggetti singoli (le istanze di Rectangle
). La lista non contiene più tuple, ma rettangoli, e per calcolare area e perimetro non è più necessario passare la base e l'altezza esplicitamente. Inoltre, calc_area()
e calc_perimeter()
sono associati all'istanza, e quindi non è necessario importare le funzioni, non si rischia di usare la funzione sbagliata (ad esempio una funzione che calcola l'area di un triangolo), non si rischia di passare la base o l'altezza del rettangolo sbagliato o passarli nell'ordine sbagliato.
Termini e concetti
Nei precedendi esempi abbiamo introdotto alcuni termini e concetti nuovi che sono usati comunemente nella programmazione ad oggetti.
Classi
Le classi sono usate per definire le caratteristiche di un oggetto, i suoi attributi (ad esempio la base e l'altezza) e i suoi metodi (ad esempio calc_area()
e calc_perimeter()
). Le classi sono "astratte" - non si riferiscono a nessun oggetto specifico, ma rappresentano un modello che può essere usato per creare istanze. Ad esempio la classe Rectangle
specifica che i rettangoli hanno una base, un'altezza, un'area e un perimetro, ma la classe non si riferisce a nessun rettangolo in particolare.
Istanze
Le istanze sono oggetti creati a partire da una classe. Ad esempio Rectangle(3, 5)
ci restituisce un'istanza della classe Rectangle
che si riferisce a uno specifico rettangolo che ha base 3
e altezza 5
. Una classe può essere usata per creare diverse istanze dello stesso tipo ma con attributi diversi, come i 100 diversi rettangoli che abbiamo visto nell'esempio precedente. È possibile usare i metodi definiti dalla classe con ogni istanza, semplicemente facendo istanza.metodo()
(ad esempio myrect.calc_area()
).
Attributi
Gli attributi sono dei valori associati all'istanza, come ad esempio la base e l'altezza del rettangolo. Gli attributi di ogni istanza sono separati: ogni istanza di Rectangle
ha una base e un'altezza diversa. Per accedere a un attributo basta fare istanza.attributo
(ad esempio myrect.base
).
Metodi
I metodi descrivono il comportamento dell'oggetto, sono simili alle funzioni, e sono specifici per ogni classe. Ad esempio sia la classe Rectangle
che la classe Triangle
possono definire un metodo chiamato calc_area()
, che ritornerà risultati diversi in base al tipo dell'istanza. I metodi possono accedere altri attributi e metodi dell'istanza: questo ci permette ad esempio di chiamare myrect.calc_area()
senza dover passare la base e l'altezza esplicitamente. Per chiamare un metodo basta fare istanza.metodo()
(ad esempio myrect.calc_area()
).
Ereditarietà
Un altro concetto importante della programmazione è l'ereditarietà. L'ereditarietà ci permette di creare una nuova classe a partire da una classe esistente e di estenderla o modificarla.
Per esempio possiamo creare una classe Square
che eredita dalla classe Rectangle
. Dato che i 4 lati di un quadrato hanno la stessa lunghezza, non è più necessario richiedere base e altezza separatamente, quindi nella classe Square
possiamo modificare l'inizializzazione in modo da richiedere la lunghezza di un singolo lato. Così facendo possiamo definire una nuova classe che invece di accettare e definire i due attributi base
e height
definisce e accetta un singolo attributo side
. Dato che il quadrato è un tipo particolare di rettangolo, i metodi per calcolare area e perimetro funzionano senza modifiche e possiamo quindi utilizzare calc_area()
e calc_perimeter()
ereditati automaticamente dalla classe Rectangle
senza doverli ridefinire.
È inoltre possibile definire gerarchie di classi, ad esempio si può definire la clase Husky
che eredita dalla classe Cane
che eredita dalla classe Mammifero
che eredita dalla classe Animale
. Ognuna di queste classi può definire attributi e comportamenti comuni a tutti gli oggetti di quella classe, e le sottoclassi possono aggiungerne di nuovi.
Python supporta anche l'ereditarietà multipla: è possibile definire nuovi classi che ereditano metodi e attributi da diverse altre classi, combinandoli.
Nella prossima lezione vedremo esempi concreti di utilizzo dell'ereditarietà.
Superclassi e sottoclassi
Se la classe Square
eredita dalla classe Rectangle
, possiamo dire che Rectangle
è la superclasse (o classe base - base class in inglese) mentre Square
è la sottoclasse (o subclasse). L'operazione di creare una sottoclasse a partire da una classe esistente è a volte chiamata subclassare.
Overloading degli operatori
In Python, le classi ci permettono anche di ridefinire il comportamento degli operatori: questa operazione è chiamata overloading degli operatori. È possibile definire metodi speciali che vengono invocati automaticamente quando un operatore viene usato con un'istanza della classe.
Ad esempio possiamo definire che myrect1 < myrect2
ritorni True
quando l'area di myrect1
è inferiore a quella di myrect2
, oppure possiamo definire che myrect1 + myrect2
ritorni un nuova istanza di Rectangle
creata dalla combinazione di myrect1
e myrect2
.
Quando usare la programmazione ad oggetti
Anche se la programmazione ad oggetti è uno strumento molto utile e potente, non è la soluzione a tutti i problemi. Spesso creare una semplice funzione è sufficiente e non è necessario definire una classe.
In genere la programmazione ad oggetti può essere la soluzione adatta se:
- la classe che vogliamo creare rappresenta un oggetto (es.
Rectangle
,Person
,Student
,Window
,Widget
,Connection
, ecc.); - vogliamo associare all'oggetto sia dati che comportamenti;
- vogliamo creare diverse istanze della stessa classe.
La programmazione ad oggetti potrebbe non essere la soluzione migliore se:
- la classe che vogliamo creare non rappresenta un oggetto, ma ad esempio un verbo (es.
Find
,Connect
, ecc.); - vogliamo solo rappresentare dati (meglio usare una struttura dati come
list
,dict
,namedtuple
, ecc.) o solo comportamenti (meglio usare funzioni, eventualmente raggruppate in un modulo separato); - vogliamo creare una sola istanza della stessa classe (meglio usare un modulo per raggruppare dati e funzioni).
Ovviamente ci sono anche delle eccezioni (ad esempio il pattern singleton, che definisce una classe che prevede una sola istanza). Python è un linguaggio multiparadigma, ed è quindi importante scegliere il paradigma che meglio si adatta alla situazione.
Nella prossima lezione vedremo in dettaglio come definire e usare classi in Python.