Python e la programmazione orientata agli oggetti (OOP)
Dalla versione 3 in poi, Python ha spostato l’attenzione sulla programmazione orientata agli oggetti. Il linguaggio segue la filosofia “everything is an object”, ovvero “tutto è un oggetto”.
A differenza di Java, C++ e Python 2.x, Python 3 non distingue fra tipi primitivi e oggetti. Pertanto, cifre, stringhe, liste e addirittura funzioni e classi sono trattate come oggetti.
Rispetto ad altri linguaggi, la programmazione orientata agli oggetti in Python usa le classi in modo molto flessibile, con poche limitazioni. Da questo punto di vista, Python può considerarsi diametralmente opposto a Java, il cui sistema è molto rigido. Di seguito vi spieghiamo in modo semplice e chiaro come funziona la programmazione orientata agli oggetti con Python.
A cosa serve la programmazione orientata agli oggetti con Python?
La programmazione orientata agli oggetti è una forma di programmazione imperativa. Gli oggetti coniugano dati e funzionalità. Un oggetto incapsula (contiene) il suo stato; l’accesso avviene tramite un’interfaccia pubblica, ovvero l’interfaccia dell’oggetto. Questa è definita dai suoi metodi. Gli oggetti interagiscono fra loro mediante messaggi generati tramite la chiamata dei metodi.
Se desiderate comprendere meglio le basi di questo argomento, leggete gli articoli “Cos’è la programmazione orientata agli oggetti”, “Paradigmi di programmazion” e “Tutorial su Python”.
Incapsulare oggetti in Python con la programmazione orientata agli oggetti
Nell’esempio riportato di seguito vedremo come è possibile usare la OOP in Python per incapsulare oggetti. Immaginiamo di scrivere del codice per una cucina, un bar o un laboratorio. Modelliamo i contenitori, ad esempio bottiglie, bicchieri, tazze: in sostanza, qualsiasi oggetto che presenta un volume e che può essere riempito. A questo proposito una categoria di cose è definita “classe”.
Gli oggetti che sono dei contenitori presentano uno stato che può essere modificato. I contenitori possono essere riempiti, svuotati e molto altro. Se il contenitore è dotato di un coperchio, lo possiamo aprire e chiudere. Per logica, invece, una volta definito il volume del contenitore questo non può più essere modificato. In relazione allo stato di un contenitore è possibile fare diverse considerazioni che rispondono alle seguenti domande:
- “Il bicchiere è pieno?”
- “Qual è il volume della bottiglia?”
- “Il contenitore ha un coperchio?”
Inoltre, può essere utile fare interagire gli oggetti l’uno con l’altro. Ad esempio, dovrebbe essere possibile versare il contenuto di un bicchiere in una bottiglia. Di seguito esamineremo come modificare lo stato di un oggetto in Python mediante la programmazione orientata agli oggetti. Le modifiche dello stato illustrate di seguito (ovvero le relative domande), si realizzano chiamando i vari metodi:
cup = Container(400)
assert cup.volume() == 400
assert not cup.is_full()
# add some water to the cup
cup.add('Water', 250)
assert cup.volume_filled() == 250
# add more water, filling the cup
cup.add('Water', 150)
assert cup.is_full()
PythonDefinire i tipi in Python con la programmazione orientata agli oggetti
I tipi di dati rappresentano un concetto fondamentale della programmazione. Diversi dati possono essere usati in vari modi. I numeri si elaborano mediante operazioni aritmetiche, le catene di caratteri (string) possono essere ispezionate:
# addition works for two numbers
39 + 3
# we can search for a letter inside a string
'y' in 'Python'
PythonI tentativi di sommare un numero e un carattere oppure di cercare una lettera all’interno di un numero causano un errore di tipo:
# addition doesn’t work for a number and a string
42 + 'a'
# cannot search for a letter inside a number
'y' in 42
PythonI tipi contenuti in Python sono astratti; un numero può rappresentare qualsiasi cosa: distanza, tempo, denaro. Il nome della variabile non è rappresentativo del valore assegnato:
# are we talking about distance, time?
x = 51
PythonCosa succede però quando desideriamo modellare dei concetti specialistici? In Python anche in questo caso si usa la programmazione orientata agli oggetti. Gli oggetti sono strutture di dati dotate di un tipo identificabile, il quale risulta visibile mediante la funzione type()
integrata:
# class 'str'
type('Python')
# class 'tuple'
type(('Walter', 'White'))
PythonRealizzare astrazioni in Python mediante la programmazione orientata agli oggetti
La programmazione si serve di astrazioni per ridurre la complessità del codice. Questo consente al programmatore di operare a un più alto livello. Ad esempio, la domanda “il bicchiere è pieno?” equivale alla domanda “il volume di ciò che è contenuto nel bicchiere è pari al volume del bicchiere stesso”? La prima versione presenta un maggior grado di astrazione, è più corta e incisiva e pertanto da preferirsi alla seconda. Le astrazioni consentono di creare e valutare sistemi più complessi:
# instantiate an empty glass
glass = Container(250)
# add water to the glass
glass.add('Water', 250)
# is the glass full?
assert glass.is_full()
# a longer way to ask the same question
assert glass.volume_filled() == glass.volume()
PythonIn Python, con la OOP è possibile trasferire concetti astratti a nuove idee. Illustriamo questo aspetto con l’esempio dell’operatore addizione in Python. Il segno “più” (+) somma due numeri ma può anche essere usato per unire i contenuti di una lista:
assert 42 + 9 == 51
assert ['Jack', 'John'] + ['Jim'] == ['Jack', 'John', 'Jim']
PythonOra trasferiamo il concetto di addizione al nostro modello. Definiamo l’operatore addizione per i contenitori. Questo ci consente di scrivere un codice che assomiglia moltissimo al linguaggio naturale. Più avanti illustreremo l’implementazione del codice, ora ci limitiamo a mostrare un esempio chiaro della sua applicazione:
# pitcher with 1000 ml capacity
pitcher = Container(1000)
# glass with 250 ml capacity
glass = Container(250)
# fill glass with water
glass.fill('Water')
# transfer the content from the glass to the pitcher
pitcher += glass
# pitcher now contains water from glass
assert pitcher.volume_filled() == 250
# glass is empty
assert glass.is_empty()
PythonCome funziona la programmazione orientata agli oggetti in Python?
Gli oggetti uniscono dati e funzionalità, entrambi definiti come attributi. A differenza di Java, PHP e C++, la programmazione orientata agli oggetti in Python non presenta parole chiave come private e protected volte a limitare l’accesso agli attributi. Al loro posto si usa una convenzione: gli attributi che iniziano con un trattino basso sono considerati non pubblici. Può trattarsi di attributi di dati secondo lo schema _internal_attr
oppure di metodi secondo lo schema _internal_method()
.
In Python i metodi si definiscono con la variabile self come primo parametro. Tutti gli accessi agli attributi di un oggetto dall’interno dell’oggetto stesso avvengono facendo un riferimento a self. In Python, self funge da segnaposto per un’istanza concreta e quindi svolge lo stesso ruolo della parola chiave this, normalmente usata in Java, PHP, JavaScript e C++.
In concomitanza con la convenzione spiegata sopra, si ottiene un semplice schema per l’incapsulamento: l’accesso a un attributo interno come riferimento self._internal
è corretto, poiché avviene all’interno dell’oggetto stesso. Gli accessi dall’esterno del tipo obj._internal
infrangono la regola dell’incapsulamento e devono essere evitati:
class ExampleObject:
def public_method(self):
self._internal = 'changed from inside method'
# instantiate object
obj = ExampleObject()
# this is fine
obj.public_method()
assert obj._internal == 'changed from inside method'
# works, but not a good idea
obj._internal = 'changed from outside'
PythonClassi
Una classe funge da modello per un oggetto. Si dice che un oggetto è un’istanza di una classe, ovvero viene creato secondo il modello. La convenzione vuole che i nomi delle classi definite dall’utente inizino con la lettera maiuscola.
Diversamente da Java, C++, PHP e JavaScript, nella programmazione orientata agli oggetti in Python non esiste la parola chiave new. Invece, il nome della classe viene richiamato sottoforma di funzione e funge da metodo costruttore, il quale restituisce una nuova istanza. In modo implicito, il costruttore esegue una chiamata al metodo di inizializzazione __init__()
.
Ora esaminiamo gli schemi menzionati sopra sulla base di un esempio concreto. Modelliamo il concetto di contenitore sotto forma di classe con il nome Container e definiamo i metodi relativi alle principali interazioni:
Metodo | Spiegazione |
---|---|
__init__
|
Inizializza un nuovo contenitore con valori iniziali. |
__repr__
|
Indica la rappresentazione del contenitore (solitamente sotto forma di testo). |
volume
|
Indica la capienza del contenitore. |
volume_filled
|
Indica il volume di riempimento del contenitore. |
volume_available
|
Indica il volume restante all’interno del contenitore. |
is_empty
|
Indica se il contenitore è vuoto. |
is_full
|
Indica se il contenitore è pieno. |
empty
|
Svuota il contenitore e restituisce il suo contenuto. |
_add
|
Metodo interno che aggiunge una sostanza, senza eseguire alcun tipo di controllo. |
add
|
Metodo pubblico che aggiunge la quantità di sostanza definita in base al volume disponibile. |
fill
|
Riempie lo spazio libero del contenitore con una sostanza. |
pour_into
|
Versa tutto il contenuto di un contenitore in un altro contenitore. |
__add__
|
Implementa l’operatore addizione per i contenitori; richiama il metodo pour_into .
|
Di seguito trovate il codice riferito alla classe Container. Dopo aver eseguito questo snippet nel vostro terminale locale REPL, potrete testare anche gli altri esempi presenti nell’articolo:
class Container:
def __init__(self, volume):
# volume in ml
self._volume = volume
# start out with empty container
self._contents = {}
def __repr__(self):
"""
Textual representation of container
"""
repr = f"{self._volume} ml Container with contents {self._contents}"
return repr
def volume(self):
"""
Volume getter
"""
return self._volume
def is_empty(self):
"""
Container is empty if it has no contents
"""
return self._contents == {}
def is_full(self):
""
Container is full if volume of contents equals capacity
"""
return self.volume_filled() == self.volume()
def volume_filled(self):
"""
Calculate sum of volumes of contents
"""
return sum(self._contents.values())
def volume_available(self):
"""
Calculate available volume
"""
return self.volume() - self.volume_filled()
def empty(self):
"""
Empty the container, returning its contents
"""
contents = self._contents.copy()
self._contents.clear()
return contents
def _add(self, substance, volume):
"""
Internal method to add a new substance / add more of an existing substance
"""
# update volume of existing substance
if substance in self._contents:
self._contents[substance] += volume
# or add new substance
else:
self._contents[substance] = volume
def add(self, substance, volume):
"""
Public method to add a substance, possibly returning left over
"""
if self.is_full():
raise Exception("Cannot add to full container")
# we can fit all of the substance
if self.volume_filled() + volume <= self.volume():
self._add(substance, volume)
return self
# we can fit part of the substance, returning the left over
else:
leftover = volume - self.volume_available()
self._add(substance, volume - leftover)
return {substance: leftover}
def fill(self, substance):
"""
Fill the container with a substance
"""
if self.is_full():
raise Exception("Cannot fill full container")
self._add(substance, self.volume_available())
return self
def pour_into(self, other_container):
"""
Transfer contents of container to another container
"""
if other_container.volume_available() < self.volume_filled():
raise Exception("Not enough space")
# get the contents by emptying container
contents = self.empty()
# add contents to other container
for substance, volume in contents.items():
other_container.add(substance, volume)
return other_container
def __add__(self, other_container):
"""
Implement addition for containers:
`container_a + container_b` <=> `container_b.pour_into(container_a)`
"""
other_container.pour_into(self)
return self
PythonOra vediamo alcuni esempi d’implementazione del nostro contenitore: creiamo l’istanza bicchiere e lo riempiamo d’acqua. Come previsto, dopo l’implementazione, il bicchiere sarà pieno:
glass = Container(300)
glass.fill('Water')
assert glass.is_full()
PythonSuccessivamente svuotiamo il bicchiere e quindi otteniamo la quantità di acqua contenuta. La nostra implementazione sembra funzionare, infatti ora il bicchiere è vuoto:
contents = glass.empty()
assert contents == {'Water': 300}
assert glass.is_empty()
PythonOra passiamo a un esempio più complesso. In una brocca (pitcher) misceliamo vino e succo d’arancia. Per farlo creiamo prima i contenitori di cui abbiamo bisogno e ne riempiamo due con gli ingredienti:
pitcher = Container(1500)
bottle = Container(700)
carton = Container(500)
# fill ingredients
bottle.fill('Red wine')
carton.fill('Orange juice')
PythonOra usiamo l’operatore addizione/assegnazione += per versare il contenuto dei due contenitori (bottle e carton) nella brocca (pitcher).
# pour ingredients into pitcher
pitcher += bottle
pitcher += carton
# check that everything worked
assert pitcher.volume_filled() == 1200
assert bottle.is_empty() and carton.is_empty()
PythonL’esempio funziona perché la nostra classe Container ha implementato il metodo __add__()
. Sotto la scocca l’assegnazione pitcher += bottle diventa pitcher = pitcher + bottle. Inoltre pitcher + bottle di Python si traduce nella chiamata di metodo pitcher. __add__(bottle)
. Il nostro metodo __add__()
restituisce il Receiver, in questo caso la brocca (pitcher) e quindi l’assegnazione funziona.
Attributi statici
Finora abbiamo esaminato come è possibile accedere agli attributi di oggetti: dall’esterno mediante metodi pubblici, dall’interno mediante un riferimento a self. La condizione interna degli oggetti si realizza mediante gli attributi che appartengono al relativo oggetto. Anche i metodi degli oggetti sono legati a una specifica istanza. Tuttavia, esistono anche attributi che appartengono a classi, il che ha senso perché in Python anche le classi sono oggetti.
Gli attributi delle classi si definiscono anche attributi “statici”, perché esistono fin da prima dell’istanziazione di un oggetto. Può trattarsi sia di attributi che di metodi. Questo è utile per le costanti che sono uguali per tutte le istanze di una classe, nonché per i metodi che non operano su self. L’implementazione delle routine di conversione avviene spesso sotto forma di metodi statici.
A differenza di linguaggi come Java e C++, Python non si serve della parola chiave static per operare una distinzione esplicita fra attributi e classi di un oggetto. Al suo posto viene invece usato il decoratore @staticmethod. Ad esempio, esaminiamo come potrebbe essere un metodo statico per la classe Container. Implementiamo una routine di conversione, per passare da millilitri a once:
# inside of class `Container`
...
@staticmethod
def floz_from_ml(ml):
return ml * 0.0338140227
PythonL’accesso agli attributi statici avviene come sempre mediante un riferimento all’attributo secondo lo schema obj.attr
. L’unica differenza rispetto agli esempi precedenti è che ora è il nome della classe a trovarsi a sinistra del punto: ClassName.static_method()
. Tale annotazione risulta consistente, dato che nella programmazione orientata agli oggetti in Python anche le classi sono oggetti. Per richiamare quindi la routine di conversione della nostra classe Container, scriviamo:
floz = Container.floz_from_ml(1000)
assert floz == 33.8140227
PythonInterfacce
Si definisce Interface (in italiano: “interfaccia”) l**’insieme di tutti i metodi pubblici di un oggetto**. L’interfaccia definisce e documenta il comportamento di un oggetto e funge da API. A differenza di C++, Python concepisce l’interfaccia (file dell’header) e l’implementazione sullo stesso livello. Allo stesso modo, a differenza di Java e PHP, non esiste la parola chiave interface. In questi linguaggi, le interfacce contengono firme di metodi e servono a descrivere funzionalità in relazione fra loro.
In Python l’informazione relativa ai metodi disponibili per un oggetto e alla classe di cui l’oggetto è istanza, è determinata dinamicamente in fase di esecuzione. Pertanto, il linguaggio non ha bisogno di interfacce esplicite. Invece, Python si serve del principio del “Duck Typing”:
“If it walks like a duck and it quacks like a duck, then it must be a duck” — Fonte: https://docs.python.org/3/glossary.html#term-duck-typing Traduzione: “Se cammina come un’anatra e starnazza come un’anatra, allora si tratterà di un’anatra.” (tradotto da IONOS)
Ma cosa si intende esattamente con Duck Typing? In breve, in Python l’oggetto di una classe può essere usato come un’altra classe, ammesso che contenga il metodo necessario. A titolo esemplificativo, immaginiamo un’anatra finta: questa emette suoni da anatra, nuota come un’anatra e anche le anatre la percepiscono proprio come un’anatra.
Ereditarietà
Come nella maggior parte dei linguaggi orientati agli oggetti, anche la OOP in Python usa il concetto di ereditarietà: una classe può definirsi una specializzazione di una classe genitore. Continuando il processo si ottiene una gerarchia di classi ad albero, con la classe predefinita Object come radice. Come in C++ (ma a differenza di Java e PHP), Python consente l’ereditarietà multipla: una classe può ereditare proprietà e comportamento da più di un genitore.
L’ereditarietà multipla può essere usata in modo flessibile. In questo modo è possibile realizzare i cosiddetti “mixins” in Ruby o “traits” in PHP. In Python l’ereditarietà multipla può essere usata anche al posto della suddivisione delle funzionalità in interfacce e classi astratte, tipica di Java.
Vediamo ora come funziona l’ereditarietà multipla in Python riprendendo il nostro esempio del contenitore. Alcuni contenitori possono essere dotati di un coperchio. A tal proposito desideriamo specializzare il comportamento della nostra classe Container. Di conseguenza definiamo una nuova classe SealableContainer, che eredita dalla classe Container. Successivamente definiamo la nuova classe Sealable, che contiene i metodi per mettere e togliere un coperchio. Poiché la classe Sealable serve solo a dotare un’altra classe di ulteriori implementazioni di metodo, si tratta di un “mixin”:
class Sealable:
"""
Implementation needs to:
- initialize `self._seal`
"""
def is_sealed(self):
return self._seal is not None
def is_open(self):
return not self.is_sealed()
def is_closed(self):
return not self.is_open()
def open(self):
"""
Opening removes and returns the seal
"""
seal = self._seal
self._seal = None
return seal
def seal_with(self, seal):
"""
Closing attaches the seal and returns the Sealable
"""
self._seal = seal
return self
PythonLa nostra classe SealableContainer eredita dalla classe Container e dal mixin Sealable. Sovrascriviamo il metodo __init__()
e definiamo due nuovi parametri che consentono di impostare nell’istanza il contenuto e lo stato di chiusura del SealableContainer. Questo è necessario al fine di generare contenitori chiusi con contenuto. All’interno del metodo __init__()
richiamiamo l’inizializzazione della classe genitore mediante super():
class SealableContainer(Container, Sealable):
"""
Start out with empty, open container
"""
def __init__(self, volume, contents = {}, seal = None):
# initialize 'Container'
super().__init__(volume)
# initialize contents
self._contents = contents
# initialize `self._seal`
self._seal = seal
def __repr__(self):
"""
Append 'open' / 'closed' to textual container representation
"""
state = "Open" if self.is_open() else "Closed"
repr = f"{state} {super().__repr__()}"
return repr
def empty(self):
"""
Only open container can be emptied
"""
if self.is_open():
return super().empty()
else:
raise Exception("Cannot empty sealed container")
def _add(self, substance, volume):
"""
Only open container can have its contents modified
"""
if self.is_open():
super()._add(substance, volume)
else:
raise Exception("Cannot add to sealed container")
PythonAnalogamente al metodo __init__()
, sovrascriviamo altri metodi in modo mirato al fine di differenziare il nostro SealableContainer dal contenitore non chiuso. Sovrascriviamo __repr__()
in modo da creare un output anche dello stato aperto/chiuso. Inoltre, sovrascriviamo i metodi empty()
e _add()
, che hanno senso solo quando il contenitore è aperto. In questo modo forziamo l’apertura di un contenitore chiuso prima che questo possa essere svuotato o riempito. Anche in questo caso, usiamo super()
per accedere alle funzionalità esistenti della classe genitore.
Prendiamo un esempio: vogliamo miscelare gli ingredienti di un Cuba Libre. Per questo abbiamo bisogno di un bicchiere, di una bottiglia di Coca Cola e di un bicchierino da liquore contenente 20 cl di rum:
glass = Container(330)
cola_bottle = SealableContainer(250, contents = {'Cola': 250}, seal = 'Bottlecap')
shot_glass = Container(40)
shot_glass.add('Rum', 20)
PythonVersiamo un po’ di ghiaccio nel bicchiere e aggiungiamo il rum. Dato che la bottiglia di Coca Cola è chiusa, prima la apriamo e solo dopo versiamo il contenuto nel bicchiere:
glass.add('Ice', 50)
# add rum
glass += shot_glass
# open cola bottle
if cola_bottle.is_closed():
cola_bottle.open()
# pour cola into glass
glass += cola_bottle
Python