Che cos’è la programmazione orientata agli oggetti (OOP)?
La programmazione orientata agli oggetti (OOP) ha trovato applicazione ovunque: questo genere di tecnologie è infatti impiegato sia per scrivere sistemi operativi così come software commerciali e open source. Tuttavia, solo quando un progetto raggiunge un certo livello di complessità i vantaggi dell’OOP diventano evidenti. In questo senso, lo stile di programmazione orientato agli oggetti è ancora uno dei paradigmi di programmazione predominanti.
Che cos’è la programmazione orientata agli oggetti e a cosa serve?
Il termine “programmazione orientata agli oggetti” è stato coniato verso la fine degli anni ‘60 da Alan Kay, figura leggendaria nell’ambito della programmazione. Kay ha collaborato allo sviluppo di Smalltalk, il pionieristico linguaggio di programmazione orientato agli oggetti ispirato a Simula, il primo linguaggio in assoluto ad avere caratteristiche OOP. Il concetto di base di Smalltalk esercita ancora oggi un’influenza sulle funzionalità OOP dei moderni linguaggi di programmazione. Tra i linguaggi influenzati da Smalltalk figurano Ruby, Python, Go e Swift.
La programmazione orientata agli oggetti rappresenta uno dei paradigmi di programmazione predominanti, al pari della popolare programmazione funzionale (FP). In generale, gli approcci alla programmazione sono classificabili in due correnti principali: “imperativa” e “dichiarativa”. OOP è una variante dello stile di programmazione imperativo e nello specifico è un ulteriore sviluppo della programmazione procedurale:
- Programmazione imperativa: descrizione delle singole fasi di risoluzione di un problema. Esempio: algoritmo
- Programmazione strutturata
- Programmazione procedurale
- Programmazione orientata agli oggetti
- Programmazione procedurale
- Programmazione dichiarativa: generazione di risultati secondo determinate regole. Esempio: query SQL
- Programmazione funzionale
- Linguaggio di dominio specifico
I termini “procedura” e “funzione” sono spesso usati come sinonimi. Questo perché si tratta di blocchi di codice eseguibili che possono accettare argomenti. In realtà, le funzioni restituiscono un valore, mentre le procedure non lo fanno. Non tutti i linguaggi offrono un supporto esplicito per le procedure.
Teoricamente, qualsiasi problema di programmazione può essere risolto con uno qualsiasi dei paradigmi, dato che tutti i paradigmi sono “Turing completi”. Di conseguenza, l’elemento limitante non è la macchina, ma l’essere umano. Chi programma individualmente o un team di programmazione è in grado di gestire una quantità limitata di complessità. Perciò per la gestione di complessità è normale ricorrere a delle astrazioni. In base all’area di applicazione e al problema, si presta meglio l’uno o l’altro stile di programmazione.
Gran parte dei linguaggi moderni sono cosiddetti linguaggi multi-paradigma, cioè consentono di programmare in diversi stili di programmazione. Viceversa, esistono linguaggi che supportano un solo stile di programmazione. Ciò vale soprattutto per i linguaggi strettamente funzionali come Haskell:
Paradigma | Caratteristiche | Particolarmente adatto a | Linguaggi | |
---|---|---|---|---|
Imperativo | OOP | Oggetti, classi, metodi, eredità, polimorfismo | Modellazione, progettazione di sistemi | Smalltalk, Java, Ruby, Python, Swift |
Imperativo | Procedurale | Flusso di controllo, iterazione, procedure/funzioni | Elaborazione sequenziale dei dati | C, Pascal, Basic |
Dichiarativo | Funzionale | Immutabilità, funzioni pure, lambda calcolo, ricorsione, sistemi di tipo | Elaborazione parallela dei dati, applicazioni matematiche e scientifiche, parser e compilatori | Lisp, Haskell, Clojure |
Dichiarativo | Linguaggio di dominio specifico (DSL) | Linguaggio espressivo e ampio | Applicazioni di dominio specifiche | SQL, CSS |
Anche il CSS è un linguaggio Turing completo. In sostanza, significa che i calcoli scritti in altri linguaggi possono essere risolti anche mediante CSS.
La programmazione orientata agli oggetti rientra nella programmazione imperativa ma nasce dalla programmazione procedurale. Quest’ultima opera principalmente con dati inerti che vengono elaborati da un codice eseguibile:
- Dati: valori, strutture di dati, variabili
- Codice: espressioni, strutture di controllo, funzioni
La differenza tra la programmazione orientata agli oggetti e quella procedurale è proprio questa: OOP raggruppa dati e funzioni in oggetti. Sostanzialmente, un oggetto è una struttura dati vivente, in quanto gli oggetti non sono inerti, ma sono dotati di un comportamento. Si tratta quindi di oggetti paragonabili a macchine o a organismi unicellulari. I dati vengono semplicemente gestiti, invece con gli oggetti si interagisce o, meglio, gli oggetti interagiscono tra di loro.
Ci serviamo di un esempio per comprendere meglio questa differenza. Una variabile Integer in Java o C++ può contenere un solo valore. Non si tratta di una struttura di dati, ma di un “tipo primitivo” o “primitive”:
int number = 42;
JavaLe operazioni sui primitive si effettuano mediante operatori o funzioni definite esternamente. Di seguito, l’esempio della funzione successor, la quale restituisce il numero successivo a un numero intero:
int successor(int number) {
return number + 1;
}
// returns `43`
successor(42)
JavaIn linguaggi come Python e Ruby, di contro, “everything is an object” (tutto è un oggetto). Un semplice numero include il valore effettivo e un insieme di metodi che definiscono le operazioni sul valore. Riportiamo l’esempio della funzione succ incorporata in Ruby:
42.succ
RubyIn primo luogo, è pratico perché le funzionalità di un tipo di dati sono raggruppate. Non è possibile richiamare un metodo se non corrisponde al tipo. Il metodo, però, può fare ancora di più. In Ruby, il ciclo For è realizzato come metodo di un numero. Prendiamo ad esempio i numeri da 51 a 42:
51.downto(42) { |n| print n, ".. " }
RubyMa da dove provengono i metodi? La maggior parte dei linguaggi definisce gli oggetti attraverso le classi. Gli oggetti sono istanziati da classi e quindi vengono chiamati anche istanze. Per classe si intende un modello per la creazione di oggetti simili che hanno gli stessi metodi. Quindi, nei linguaggi OOP puri, le classi agiscono come tipi. Tale aspetto emerge chiaramente nella programmazione orientata agli oggetti in Python; infatti, la funzione type restituisce una classe come tipo di un valore:
type(42) # <class 'int'>
type('Walter White') # <class 'str'>
PythonCome funziona la programmazione orientata agli oggetti?
Se si chiede a una persona con un po’ di esperienza nella programmazione che cosa sia OOP, probabilmente la sua risposta sarà un vago: “qualcosa sulle classi”. A dire il vero, però, le classi non sono il punto centrale della questione. Il concetto di base della programmazione orientata agli oggetti di Alan Kay è più semplice e si può riassumere così:
- Gli oggetti incapsulano il proprio stato interno.
- Gli oggetti ricevono messaggi tramite i propri metodi.
- L’assegnazione dei metodi avviene dinamicamente in fase di esecuzione.
Approfondiamo di seguito questi tre punti critici.
Gli oggetti incapsulano il proprio stato interno
Al fine di comprendere cosa si intenda per incapsulamento, adottiamo l’esempio di un’automobile. Un’auto ha un determinato stato, stabilito ad esempio dalla percentuale di carica della batteria, dal livello di carburante nel serbatoio, dal funzionamento o meno del motore. Rappresentando un’auto di questo tipo come un oggetto, le proprietà interne dovrebbero poter essere modificate solo tramite interfacce definite.
Osserviamo alcuni esempi. Il nostro oggetto car rappresenta un’automobile. Al suo interno, lo stato è memorizzato in variabili. Questo oggetto controlla i valori delle variabili; ad esempio, possiamo assicurarci che l’energia venga utilizzata per avviare il motore. Mettiamo in moto l’auto inviando un messaggio di start:
car.start()
PythonIn questo momento, è l’oggetto a decidere cosa accadrà: qualora il motore sia già in funzione, il messaggio viene ignorato, oppure viene emesso un nuovo messaggio. In assenza di carica sufficiente della batteria o di serbatoio vuoto, il motore rimane spento. Quando tutte le condizioni sono soddisfatte, il motore finalmente si avvia e lo stato interno viene adattato. Per esempio, una variabile booleana motor_running viene impostata su “True” e la carica della batteria diminuisce in base alla carica necessaria per l’avviamento. Illustriamo schematicamente come potrebbe apparire il codice all’interno dell’oggetto:
# starting car
motor_running = True
battery_charge -= start_charge
PythonL’importante è che lo stato interno non possa essere modificato direttamente dall’esterno. Diversamente, si potrebbe impostare motor_running su “True” anche se la batteria è scarica. Tuttavia, ciò non rispecchierebbe la situazione reale.
Inviare messaggi/richiamare metodi
Gli oggetti, come appena osservato, reagiscono ai messaggi e come reazione possono cambiare il proprio stato interno. Questi messaggi vengono chiamati metodi; dal punto di vista tecnico, si tratta di funzioni legate a un oggetto. Il messaggio comprende il nome del metodo ed eventualmente altri argomenti. Un oggetto che riceve è chiamato ricevitore (“receiver” in inglese). Possiamo riassumere lo schema generale della ricezione dei messaggi da parte degli oggetti nel modo seguente:
# call a method
receiver.method(args)
PythonFacciamo un altro esempio: supponiamo di programmare uno smartphone. I diversi oggetti rappresentano le funzionalità, ad esempio le funzioni del telefono come la torcia, una chiamata, un messaggio di testo, ecc. In genere, i singoli sottocomponenti sono a loro volta modellati come oggetti. Di conseguenza, la rubrica è un oggetto, proprio come ogni contatto che contiene e anche il numero di telefono di un contatto. Questo permette di modellare facilmente i processi a partire dalla realtà:
# find a person in our address book
person = contacts.find('Walter White')
# let's call that person's work number
call = phone.call(person.phoneNumber('Work'))
...
# after some time, hang up the phone
call.hangUp()
PythonAssegnazione dinamica dei metodi
La terza caratteristica essenziale della definizione originale di OOP di Alan Kay è l’assegnazione dinamica dei metodi nel tempo di esecuzione. In altre parole, la decisione su quale codice eseguire quando viene richiesto un metodo si verifica soltanto al momento dell’esecuzione del programma. Il comportamento di un oggetto può quindi essere modificato in questo frangente.
L’assegnazione dinamica dei metodi comporta notevoli conseguenze per l’implementazione tecnica delle funzionalità OOP nei linguaggi di programmazione. Nella pratica, raramente occorre averci a che fare. A ogni modo, analizziamo un esempio. Modelliamo la torcia dello smartphone come un oggetto flashlight. Esso reagisce ai messaggi on, off e intensity:
// turn on flashlight
flashlight.on()
// set flashlight intensity to 50%
flashlight.intensity(50)
// turn off flashlight
flashlight.off()
JavaScriptPoniamo che la torcia si rompa e decidiamo di emettere un avviso a ogni accesso. Uno degli approcci consiste nel sostituire tutti i metodi con un nuovo metodo. In JavaScript, ad esempio, è abbastanza semplice. Definiamo la nuova funzione out_of_order e sovrascriviamo i metodi esistenti con essa:
function out_of_order() {
console.log('Flashlight out of order. Please service phone.')
return false;
}
flashlight.on = out_of_order;
flashlight.off = out_of_order;
flashlight.intensity = out_of_order;
JavaScriptIn seguito, ogni volta che si tenta di interagire con la torcia, sarà sempre attivato il comando out_of_order:
// calls `out_of_order()`
flashlight.on()
// calls `out_of_order()`
flashlight.intensity(50)
// calls `out_of_order()`
flashlight.off()
JavaScriptDa dove provengono gli oggetti? Istanziazione e inizializzazione
Abbiamo visto finora come gli oggetti ricevono i messaggi e reagiscono a essi. Da dove provengono però gli oggetti? Passiamo ora ad analizzare il concetto centrale di istanziazione. L’istanziazione è il processo tramite il quale un oggetto prende vita. Nei vari linguaggi OOP esistono diversi meccanismi di istanziazione. Solitamente si ricorre a uno o più dei seguenti meccanismi:
- Definizione per oggetto letterale
- Istanziazione con funzione costruttore
- Istanziazione da una classe
Sotto questo aspetto, JavaScript spicca perché oggetti quali numeri o stringhe possono essere definiti direttamente come letterali. Per fare un esempio semplice: istanziamo un oggetto person vuoto e poi gli assegniamo la proprietà name e un metodo greet. Da questo momento in poi, il nostro oggetto sarà in grado di salutare un’altra persona e di pronunciare il proprio nome:
// instantiate empty object
let person = {};
// assign object property
person.name = "Jack";
// assign method
person.greet = function(other) {
return `"Hi ${other}, I'm ${this.name}"`
};
// let's test
person.greet("Jim")
JavaScriptAbbiamo istanziato un oggetto unico. In ogni caso, è frequente che si voglia ripetere l’istanziazione per creare una serie di oggetti simili. Anche questa evenienza può essere facilmente affrontata in JavaScript. Generiamo una cosiddetta funzione costruttore che assembla un oggetto quando viene interpellata. La nostra funzione costruttore, chiamata Person, assume un nome e un’età e crea un nuovo oggetto quando interpellata:
function Person(name, age) {
this.name = name;
this.age = age;
this.introduce_self = function() {
return `"I'm ${this.name}, ${this.age} years old."`
}
}
// instantiate person
person = new Person('Walter White', 42)
// let person introduce themselves
person.introduce_self()
JavaScriptNotate l’uso della parola chiave this, una caratteristica presente anche in altri linguaggi come Java, PHP e C++, e che spesso è causa di confusione per chi è alle prime armi con OOP. In poche parole, si tratta di un segnaposto per un oggetto istanziato. Al momento dell’esecuzione di un metodo,thisfa riferimento al ricevitore, indicando un’istanza specifica dell’oggetto. In altri linguaggi, come Python e Ruby, al posto dithissi usa la parola chiaveself, con la medesima funzione.
Inoltre, in JavaScript è necessaria la parola chiave newper creare correttamente l’istanza dell’oggetto. Questo si verifica soprattutto in Java e C++, che distinguono tra “stack” e “heap” per la memorizzazione dei valori in memoria. Il terminenew viene utilizzato in entrambi i linguaggi per distribuire memoria su heap. JavaScript, come Python, memorizza tutti i valori in heap, per cui new diventa superfluo. Python dimostra che è possibile farne a meno.
La terza e più diffusa modalità di creazione di istanze di oggetti prevede l’uso di classi. La classe gioca un ruolo simile a quello di un costruttore in JavaScript: sono entrambi un modello che permette di istanziare oggetti simili in caso di necessità. Al tempo stesso, in linguaggi come Python e Ruby, le classi sostituiscono i tipi utilizzati in altri linguaggi. Più avanti riporteremo un esempio di classe.
Quali sono i vantaggi e gli svantaggi di OOP?
A partire dall’inizio del XXI secolo, la programmazione orientata agli oggetti ha subito un crescente numero di critiche. Linguaggi moderni e funzionali con immutabilità e sistemi di tipi forti sono considerati più stabili, affidabili e performanti. Nonostante ciò, OOP trova largo impiego e presenta notevoli vantaggi. L’importante è scegliere lo strumento giusto per ogni problema, invece di affidarsi a un’unica metodologia.
Vantaggio: incapsulamento
OOP offre un vantaggio evidente: il raggruppamento delle funzionalità. Anziché raggruppare diverse variabili e funzioni in un insieme disordinato, le si può combinare in unità coerenti. La differenza è dimostrata da un esempio: prendiamo a modello un autobus e utilizziamo due variabili e una funzione. Le persone possono salire a bordo dell’autobus fino a quando non è pieno:
# list to hold the passengers
bus_passengers = []
# maximum number of passengers
bus_capacity = 12
# add another passenger
def take_bus(passenger)
if len(bus_passengers) < bus_capacity:
bus_passengers.append(passenger)
else:
raise Exception("Bus is full")
PythonAnche se questo codice funziona, è problematico. La funzione take_bus accede alle variabili bus_passengers e bus_capacity senza trasmetterle come argomenti. Ciò comporta problemi con il codice esteso, dato che le variabili devono essere fornite globalmente o passate a ogni richiesta. Per di più, così è possibile “barare”. Infatti, possiamo continuare ad aggiungere passeggeri all’autobus anche se di fatto è pieno:
# bus is full
assert len(bus_passengers) == bus_capacity
# will raise exception, won't add passenger
take_bus(passenger)
# we cheat, adding an additional passenger directly
bus_passengers.append(passenger)
# now bus is over capacity
assert len(bus_passengers) > bus_capacity
PythonNulla ci impedisce, inoltre, di aumentare la capacità dell’autobus. Ciò viola però le ipotesi sulla realtà fisica, perché la capacità fisica di un autobus esistente è limitata e non può essere modificata a piacimento:
# can't do this in reality
bus_capacity += 1
PythonIncapsulare lo stato interno degli oggetti protegge da modifiche insensate o indesiderate. Riportiamo la stessa funzionalità nel codice orientato agli oggetti. Stabiliamo una classe di autobus e istanziamo un autobus a capacità limitata. Aggiungere persone è possibile solo attraverso il metodo corretto:
class Bus():
def __init__(self, capacity):
self._passengers = []
self._capacity = capacity
def enter(self, passenger):
if len(self._passengers) < self._capacity:
self._passengers.append(passenger)
print(f"{passenger} has entered the bus")
else:
raise Exception("Bus is full")
# instantiate bus with given capacity
bus = Bus(2)
bus.enter("Jack")
bus.enter("Jim")
# will fail, bus is full
bus.enter("John")
PythonVantaggio: modellare sistemi
La programmazione orientata agli oggetti si presta in modo particolare alla modellazione dei sistemi. OOP è intuitivo dal punto di vista umano, in quanto anche noi pensiamo in termini di oggetti che possono essere classificati. Per oggetti si intendono sia cose fisiche che concetti astratti.
Anche l’ereditarietà tramite gerarchie di classi presente in molti linguaggi OOP corrisponde a modelli di pensiero umani. Esaminiamo l’ultimo punto con l’aiuto di un esempio. Gli animali sono concetti astratti. Gli animali esistenti sono sempre manifestazioni concrete di una specie. In base alla specie, gli animali hanno caratteristiche diverse. Un cane non è in grado di arrampicarsi o di volare, quindi si limita a movimenti nello spazio bidimensionale:
# abstract base class
class Animal():
def move_to(self, coords):
pass
# derived class
class Dog(Animal):
def move_to(self, coords):
match coords:
# dogs can't fly nor climb
case (x, y):
self._walk_to(coords)
# derived class
class Bird(Animal):
def move_to(self, coords):
match coords:
# birds can walk
case (x, y):
self._walk_to(coords)
# birds can fly
case (x, z, y):
self._fly_to(coords)
PythonSvantaggi della programmazione orientata agli oggetti
Lo svantaggio principale di OOP è il lessico, inizialmente difficile da comprendere. Dovete imparare concetti completamente nuovi, il cui significato e scopo non sono sempre chiari a partire da semplici esempi. Commettere errori è facile; la modellazione delle gerarchie di eredità, in particolare, richiede molta abilità ed esperienza.
Una delle critiche più frequenti a OOP è l’incapsulamento dello stato interno, che in realtà sarebbe concepito come un vantaggio. Ciò comporta difficoltà nella parallelizzazione del codice OOP. Se un oggetto viene trasmesso a diverse funzioni parallele, lo stato interno potrebbe cambiare tra le chiamate di funzione. Oltre a ciò, a volte occorre accedere a informazioni incapsulate altrove all’interno di un programma.
In genere, la natura dinamica della programmazione orientata agli oggetti comporta una perdita di prestazioni. Questo perché il numero di ottimizzazioni statiche possibili è inferiore. Anche i sistemi di tipi dei linguaggi OOP puri, tendenzialmente meno pronunciati, impediscono alcuni controlli statici. Eventuali errori diventano visibili solo in fase di esecuzione. Nuovi sviluppi, come il linguaggio JavaScript TypeScript, sono però in grado di contrastare questa situazione.
Quali linguaggi di programmazione supportano o sono adatti a OOP?
Pressoché tutti i linguaggi multi-paradigma sono adatti alla programmazione orientata agli oggetti. Tra questi figurano i ben noti linguaggi di programmazione web PHP, Ruby, Python e JavaScript. Per contro, i principi di OOP sono largamente incompatibili con l’algebra relazionale alla base di SQL. Onde evitare il fenomeno di “impedance mismatch” (disallineamento di impedenza), si ricorre a speciali livelli di traduzione noti come “Object Relational Mappers” (ORM).
Persino i linguaggi puramente funzionali come Haskell non forniscono un supporto nativo per OOP. L’implementazione di OOP in C richiede uno sforzo notevole. Curiosamente, Rust è un linguaggio moderno che funziona senza classi. struct ed enum sono invece utilizzate come strutture di dati il cui comportamento è definito dalla parola chiave impl. I comportamenti possono essere raggruppati con i cosiddetti traits; in questo modo vengono rappresentati anche l’ereditarietà e il polimorfismo. La struttura del linguaggio riflette la best practice OOP “Composition over Inheritance”.