 |
Oggetti
Se esiste un termine inflazionato nell'ambito dell'informatica
attuale questo è certamente quello di "programmazione ad oggetti",
od Object Oriented Programming, in sigla OOP. Sembra che tutto quello che
è bello, potente, moderno non possa essere che "orientato agli oggetti".
Eppure, se si cerca di capire di cosa si tratti, senza
aver prima provato effettivamente a programmare nel vecchio e nel nuovo modo,
si rischierà di restare spiazzati. In genere infatti le introduzioni
all'argomento che si trovano sono di due tipi, opposti quanto a difficoltà
ma di solito entrambi praticamente inutili per farsi un'idea dell'argomento,
perché troppo semplicistiche o troppo tecniche.
Le prime, sostanzialmente spiegano che un oggetto "è
un oggetto", e gli esempi che portano sono sempre zoologici (programmiamo
l'oggetto "equino" e poi gli aggiungiamo l'ulteriore proprietà di
avere le strisce, ottenendo così il nuovo oggetto "zebra"), al punto
che ci si può chiedere se la cosa sia adatta solo a dei biologi, o
se possa essere usata per farci davvero qualcosa di utile.
In questo tipo di spiegazioni si mostra come nella OOP
ci sia un meccanismo, l'ereditarietà, che permette di costruire oggetti
più complessi a partire da uno semplice. L'oggetto così spiegato
è però sostanzialmente solo il "modulo", un concetto presente
in linguaggi precedenti l'OOP, come Modula 2 ad esempio. L'ereditarietà
come viene qui presentata è semplicemente una "estensione di moduli",
un servizio che il compilatore ci fa, evitandoci, per esempio, di riscrivere
tutte le caratteristiche di "equino" quando programmiamo "zebra", in modo
che noi dobbiamo solo "aggiungere le strisce".
A volte addirittura ci si ferma prima, e quello che si
presenta è in sostanza l'oggetto come "componente". La programmazione
attraverso componenti ha avuto un largo successo con la diffusione di Visual
Basic, ma i componenti mancano di una caratteristica fondamentale degli oggetti
della OOP, l'ereditarietà, pur possedendone un'altra, l'incapsulamento.
Il secondo tipo di presentazioni invece entra pesantemente
nell'argomento, introducendo una marea di concetti (riusabiltà,
ereditarietà, incapsulamento, polimorfismo, ADT, classi, istanze,
dynamic binding, ecc.), al punto che uno che non debba per necessità
imparare a programmare ad oggetti può pensare che la cosa sia troppo
difficile.
E spesso la cosa è resa più difficile
dal linguaggio ad oggetti usato, cioè il C++ o, più di
recente, Java. Il problema di entrambi è quello di essere derivati
dal C, ottimo linguaggio per la programmazione a livello di sistema, ma con
il difetto di avere una sintassi criptica. Inoltre, cosa che complica ancora
di più la situazione, entrambi sono linguaggi "misti", avendo in sé
sia costrutti OOP che imperativi. Java da questo punto di vista è
molto migliore del C++, pur tuttavia il fatto di avere alcuni dati di tipo
"oggetto" e altri di tipo "primitivo" porta la sua dose di inutili complicazioni.
Per imparare e capire i concetti della OOP è invece
importantissimo utilizzare un linguaggio ad oggetti "puro". In questi le
infinite difficoltà causate dalla mescolanza dei due tipi di
programmazione spariscono del tutto, e i concetti fondamentali della OOP
sono le uniche cose da affrontare. Per la cronaca, gli unici linguaggi "Object
Oriented" puri di una certa diffusione si chiamano Smalltalk ed Eiffel.
Dopo tal premessa si capirà che chi scrive queste
poche righe, dopo aver speso una notevole quantità di tempo per cercare
di comprendere cosa si intendesse per programmazione ad oggetti, cercherà
di stare nel mezzo (assieme alla virtù ;^). Più precisamente,
proverò a spiegarvi approfonditamente in che cosa, e perché,
la programmazione ad oggetti sia diversa da quella ordinaria, e così
tanto superiore a questa, senza tuttavia complicare tale spiegazione con
dettagli sintattici del codice reale di qualche, pur bello, linguaggio OOP.
Innanzi tutto, come è noto, programmare è
mettere assieme dati ed operazioni che su questi agiscono (algoritmi). Nella
programmazione normale (imperativa) il dato è qualcosa di semplice
e "passivo", e la sua caratteristica più importante è il
numero dei bit di cui è costituito: un numero intero "corto" (integer)
fatto con un byte, od un numero reale fatto con due, ad esempio. Il dato
ha sì un suo tipo, e quindi per esempio sui bit di un numero reale
si opera in modo diverso che su quelli di un intero, ma rimane pur tuttavia
un insieme di bit, "passivo". La componente attiva, dominante, è
la funzione, o procedura, che agisce sui bit passivi. Per questo la normale
programmazione, imperativa, viene anche detta procedurale: procedure e funzioni
sono al centro dell'attenzione.
Nella programmazione ad oggetti il rapporto dati-procedure
si inverte, ed i primi prendono il centro della scena e vengono riferiti
con il nome, forse l'avrete intuito, di "oggetti". Le procedure diventano
dei semplici "comportamenti" degli oggetti. Sembra che un oggetto divenga
una cosa intelligente, strutturata, complicata, come un oggetto reale.
Quando si spiega la OOP partendo dagli oggetti, è
inevitabile di finire per pensare che l'oggetto non possa che essere molto
"grosso", cioè essere costruito con molti bit, e fornito di molto
codice che descriva i suoi complicati comportamenti. Per esempio, rifacendoci
agli esempi zoologici di sopra, si è portati a pensare ad oggetti
come "zebra", con un sacco di caratteristiche e proprietà, e un gran
numero di comportamenti particolari. In sostanza, si pensa alla "simulazione"
di oggetti reali complessi. Questo è in realtà proprio l'ambito
in cui è nata la programmazione ad oggetti: il primo linguaggio OO
si chiamava Simula e serviva proprio allo scopo che il nome suggerisce.
Così, non si pensa minimamente che dati semplici,
come un misero numero intero, possano essere oggetti con una loro forma
rudimentale di "intelligenza comportamentale". Sembra un controsenso anche
perché, per fornirgli questa intelligenza e sofisticazione, bisognerebbe
contornare i pochi bit di cui è costituito il numero intero di
chissà quant'altro, che però sarebbe fatto di dati quasi
altrettanto semplici di esso, che a loro volta sembrerebbero richiederne
altri, in un ciclo senza fine. Una zebra può avere un sacco di numeri
che descrivono la sua grandezza, il suo peso, l'età ecc, ma un semplice
intero?
Questa divisione è incoraggiata anche dai linguaggi
di programmazione "misti", cioè non OO puri, come il C++, l'Object
Pascal e, più recentemente, Java. Qui ci sono effettivamente due tipi
di dati diversi, quelli "primitivi", come caratteri, numeri interi e reali,
valori booleani ecc. (cioè diverse combinazioni di bit), con i quali
si "costruiscono" effettivamente i veri dati di tipo "oggetto", che sono
quindi qualcosa di complesso.
Il centrare l'attenzione sull'oggetto, purtroppo, manca
del tutto l'obiettivo di spiegare l'essenza della programmazione ad oggetti.
L'oggetto infatti è solo il "come", non il "perché". La vera
cosa che fa la differenza tra OOP e programmazione ordinaria è
l'astrazione. Se mi permettete un gioco di parole, è dannoso credere
che l'astrazione sia un concetto "astratto". Certo, è pane quotidiano
dei matematici (anzi, si potrebbe dire che la matematica, specie nella sua
forma moderna, è semplicemente pensiero astratto), ma questo non vuol
dire che il normale ragionare umano ne sia privo. Si può al contrario
dire che tutto il pensiero umano è astrazione. Ed è esattamente
questa l'enorme differenza con il "pensiero" dei computer, ed è
da qui che nasce l'impressione che i computer siano molto "pignoli", ma poco
"intelligenti".
La programmazione ad oggetti è solo la modalità
tecnica (tanto per spaventare il coraggioso lettore: l'associazione dinamica
di procedura in dipendenza dallo stato dell'oggetto al momento dell'esecuzione
o, detto all'inglese, il " procedure dynamic binding, at runtime") che i
progettisti di linguaggi di programmazione hanno trovato, per introdurre
la potenza del pensiero astratto nel mondo dei computer, "umanizzando il
loro modo di pensare", se così si può dire.
Ma non restiamo ancora nel vago (diremmo astratto!). Come
primo esempio, che resta parzialmente solo sul versante dell'oggetto come
componente, pensiamo ad un ingegnere cui sia affidato il compito di progettare
un intero aeroplano (potete pensare ad un piccolo aereo, visto che è
troppo irrealistico pensare che una sola persona ne progetti uno grande).
Questo significa forse che si sarà autorizzati a pensare che il brav'uomo
conosca tutti i dettagli di funzionamento, per esempio, del motore ad elica
o della radio di bordo? Ovviamente no. E questo perché quando lui
progetta l'aereo lo fa pensando a componenti già disponibili, come
appunto un motore o una radio, di cui lui deve solo conoscere le caratteristiche
esterne, cioè i servizi che questi componenti mettono a disposizione.
Il suo lavoro consiste nel metterli assieme secondo un progetto coerente
che trasformi un mucchio di ferraglia in un'opera tecnologica funzionante.
Questa metafora coglie una prima parte delle caratteristiche
degli oggetti della OOP, l'incapsulamento, quello che fa sì che si
possa parlare di una parte (il motore) all'interno del tutto (l'aereo), in
modo che per il funzionamento di quest'ultimo sia necessario conoscere solo
le caratteristiche "esterne" del componente (ad esempio, la potenza del motore).
Come detto sopra però, si ha uso di componenti anche in linguaggi
come Visual Basic, che sono molto distanti dalla potenza espressiva dei linguaggi
orientati agli oggetti.
Per capire l'essenza della OOP bisogna arrivare all'idea
di "componente intelligente", a cui è dedicato il secondo esempio.
Immaginate di essere un allenatore di pallacanestro, che
come tutti gli allenatori di pallacanestro, è molto prolifico nel
produrre schemi di gioco che la squadra dovrà poi attuare con precisione.
Per descrivere il gioco userete termini noti ad un cestista (stoppa, fai
un blocco, tira da tre, schiaccia, se non va' subito prendi il rimbalzo,
fai fallo, aspetta lo scadere del tempo, ecc.), termini che ad uno che non
sa giocare possono anche non dir nulla. Usando questi termini, la vostra
descrizione risulterà semplice e lineare, descriverà l'azione
nella sua essenza senza perdersi in dettagli inutili . E tuttavia la descrizione
non esaurirà tutto, perché vi possono capitare giocatori che
la eseguono in modi che non avevate ancora immaginato. E' sempre il vostro
schema, ma se ad eseguirlo avete Michael Jordan, risulterà molto più
soddisfacente, andrà al di là di quello che credevate possibile
mentre lo pensavate.
Un programma Object Oriented è come lo schema
dell'allenatore. Descrive un'azione che degli oggetti (i giocatori) saranno
chiamati ad eseguire. Il punto importante è che i giocatori sanno
cosa vogliono dire le azioni contenute nello schema, hanno una loro intelligenza,
esperienza e capacità, sono dei soggetti attivi, non passivi.
Un programma ordinario si può invece paragonare
a tutto ciò che un appassionato di giochi elettronici, che avesse
a disposizione una squadra di robot (totalmente passivi), sarebbe costretto
a comandare dalla console per far sviluppare la stessa azione. Nella descrizione
di queste manovre mancherà proprio l'astrazione, perché i dettagli
dei movimenti meccanici (di braccia, gambe ecc.), oscureranno il "senso"
dello schema, e per di più, dovrà essere adattata a quel tipo
di robot, alle loro particolari capacità "fisiche".
La programmazione normale prevede che si descrivano le
azioni che i dati "subiscono", avendo il pieno controllo di tutto. Se il
sistema diventa molto grande, come è già una squadra di robot,
è ovvio che un singolo programmatore ad un certo punto non riuscirà
più a gestirlo. La cosa non cambia nemmeno con un team di programmatori,
perché ogni pur piccolo pezzo di codice che ciascuno dovrà
esaminare, dovrà essere capito in tutta la sua estensione, dal più
alto livello, giù giù fino al bit.
Questo preclude la tipica costruzione per gerarchie
e livelli: con componenti semplici se ne costruiscono di più
complessi, e quelli che montano l'oggetto completo non devono affatto
essere più intelligenti di quelli che costruiscono il componente più
minuscolo. Ma soprattutto, chi deve ideare il montaggio finale può
benissimo ignorare il funzionamento interno dei componenti che usa: l'unica
cosa che gli deve interessare, è il fatto se facciano o meno quello
per cui vengono utilizzati. Così ciascun partecipante al progetto
si occuperà del lavoro "al suo livello", astraendo dai dettagli
dei componenti.
Quando si programma nello stile della OOP, si rimane
sempre ad un livello astratto, ordinando delle azioni ad oggetti che si suppone
le sapranno eseguire. Come può capitare ad un allenatore che metta
in campo una persona che ignora il gioco, può essere possibile che
nella realtà ci capiti un oggetto che non sa cosa fare, nel qual caso
si avrà un errore di funzionamento. Ma il principio di dividere il
compito di approntare schemi di gioco (all'allenatore) da quello di conoscere
le giocate (al giocatore) è assolutamente fondamentale per creare
una grande squadra. Allo stesso modo, le civiltà industriali non sono
basate sull'opera di inventori geniali che hanno costruito un aereo dal nulla,
ma sull'opera cooperativa di tante intelligenze, ognuna delle quali inventa
o migliora un piccolo componente, che qualcun altro utilizzerà per
costruire qualcosa di più complesso, e nuovo.
I due esempi di sopra si possono trasportare immediatamente
nel mondo dei computer. Si può per esempio pensare, rispettivamente,
ai programmi di controllo e navigazione di un aereo, e ad un videogioco di
una partita di basket (per semplicità pensate ad una partita completa
che vedete giocare, non ad un programma interattivo come al solito).
Nel caso dell'aereo, chi programma un componente, dovrà
conoscere le caratteristiche ed i comportamenti di un certo numero di altri
componenti, ma solo quelli "ufficiali", non quelli "interni". Per esempio,
il pilota automatico avrà bisogno di sapere come agire sul motore
per aumentare la potenza di spinta, ma altri particolari, come ad esempio
se è un jet o un motore ad elica, potranno rimanere nascosti perché
ininfluenti. Così chi programma il componente "pilota automatico",
potrà pensare che il suo pezzo di software continui ad essere usato
anche quando si cambiasse il tipo di propulsione, proprio perché descrive
l'azione da eseguire in termini sufficientemente astratti da includere anche
quella con un motore non ancora inventato. La programmazione ordinaria invece,
non riesce a fare nulla di tutto questo, proprio perché per comandare
le azioni ha bisogno di nominarle in concreto, e quindi conoscere tutti i
particolari di chi dovrà eseguirle, cioè la precisa procedura
da attivare.
Nell'esempio del videogioco, ci sarà un primo
programmatore che crea il programma della partita, magari copiando pari pari
una partita reale che ha appena visto in TV. E ci potrà essere un
secondo programmatore che dieci anni dopo riutilizza il programma del collega
senza modificarne una virgola, ma facendolo eseguire con altri oggetti
(giocatori, palla, campo di gioco) con caratteristiche molto migliorate,
per esempio nella grafica, nella fluidità dei movimenti dei giocatori
ecc. Nel secondo caso si giocherà una partita astrattamente uguale
a quella che il primo programmatore aveva pensato, anche se, vedendola, questo
non potrà certo dire di aver previsto che sarebbe stata realizzata
così. Allo stesso modo, utilizzando tecniche non OO, un programmatore
avrebbe potuto scrivere un videogioco per la sua epoca, per esempio una versione
di Tetris, cominciando a prescrivere dettagli grafici come la risoluzione
dello schermo a 300x400, in modo che dieci anni dopo, un suo collega che
volesse aggiornarlo alla molto miglior grafica disponibile, non potrebbe
far altro che cercare di capirne la logica interna, e tradurre una per una
le azioni comandate. Naturalmente ci sarebbe una versione astratta del gioco,
ma questa sarebbe rimasta nella testa del primo programmatore, perché
nei bit del programma sarebbe stata incisa solo la sua versione concreta,
con i mezzi di cui al momento disponeva. Il secondo programmatore dovrà
quindi faticare per invertire il processo, dal concreto all'astratto, per
poi riconcretizzare tutto.
Da quanto detto, si può capire che programmare
in stile OO risulta molto più diretto e generale, e che il lavoro
così fatto può essere riutilizzato più e più
volte, sconfiggendo il principale problema della programmazione ordinaria,
quell'impressione che chi programma ha, di fare spesso le stesse cose
in modi tra loro poco diversi, senza tuttavia essere capaci di riutilizzare
parti di programma già scritte.
Dopo questa introduzione, forse lunga ma certamente necessaria
per dare un'idea di dove si intenda arrivare, veniamo finalmente ai mezzi
concreti che la OOP utilizza per raggiungere i suoi scopi.
Vedremo adesso il punto cruciale che differenzia
programmazione ordinaria ed OOP, e cioè il diverso modo di impartire
un comando (=funzione, f) ad un oggetto (=dato, x).
Nella programmazione ordinaria, procedurale, ai dati si
applicano "funzioni" che li trasformano, producendo i risultati. Per la cronaca,
le procedure sono funzioni in cui i dati che subiscono trasformazione non
sono espliciti, e questo tipo di programmazione non è detta funzionale
solo perché il nome è già usato per un altro tipo di
concetti, quelli che riguardano il Lisp ed i linguaggi ad esso affini.
L'applicazione della funzione f al dato x viene indicata di solito con la
stessa simbologia d'uso in matematica:
f(x)
Anche nell'ordine di apparizione dei termini, si vede
che viene prima la funzione, e poi il dato. Il codice fa quindi riferimento
ad una ben determinata funzione (già scritta da qualcuno) che accetta
solo certi tipi di dato, quelli previsti dal suo autore. Quindi scrivere:
f( )
implica già il conoscere l'esatto tipo del dato
x. Per esempio, noi possiamo scrivere una funzione
Raddoppia (x) che agisce su numeri nel seguente
modo:
Raddoppia
(x) = x
+ x
Se pensiamo però di aver fatto un lavoro una volta
per tutte, dobbiamo presto ricrederci. Quando si definisce una funzione in
un normale linguaggio di programmazione infatti, bisogna esplicitamente informare
il compilatore che produce il programma eseguibile dell'esatto tipo del dato
x, se sia esso un intero corto, lungo, oppure ancora un numero reale. E questo
non è un problema di insufficiente capacità di chi ha creato
il linguaggio di programmazione, è una necessità inderogabile.
In Pascal per esempio dovremo scrivere:
function Raddoppia
(x :
integer) : integer
begin
Raddoppia := x + x
end
Questa funzione "funzionerà" però solo con
i numeri interi "corti" (integer), e bisognerà riscriverla per i lunghi,
i reali ecc. Noi avremo in testa il semplicissimo concetto di "raddoppio",
ma non avremo alcun modo di tradurlo in codice una volta per tutte. E c'è
da notare che questo concetto non si limita ai tipi primitivi di numeri che
il linguaggio di programmazione ci fornisce, perché potremmo applicarlo
anche, ad esempio, a vettori e matrici di numeri. Ritorneremo tra un attimo
su questo.
Nella OOP, non si avrà più l'applicazione
della funzione f al dato x, bensì "l'invio del comando f all'oggetto
x", scritto (notazione puntata):
x.f
Chi scrive la funzione f, la strutturerà come una
sequenza di comandi cui l'oggetto x obbedirà. Cosa avrà
necessità di conoscere sul tipo effettivo di oggetto che sarà
x? Importante è solamente che x conosca i comandi di cui f è
costituita, e sappia reagire ad essi. Solo alcuni tipi di oggetti conosceranno
quei comandi, per cui il programmatore che scrive f dovrà sapere a
che tipi di oggetti rivolgersi. Sembra che siamo da capo, di nuovo a scrivere
la funzione "Raddoppia" per gli interi, i reali
ecc. In realtà, disponendo di un linguaggio OOP si può lo stesso
ricadere nel problema, ma si hanno, se si vuole, i mezzi per evitarlo. Se
si osserva la definizione "astratta" di
"Raddoppia":
Raddoppia
(x)
= x + x
si vede che l'unica cosa indispensabile è che l'oggetto
x sappia cosa vuol dire l'addizione. Nella OOP (almeno in quella vera, di
Eiffel e Smalltalk per esempio) noi potremmo scrivere la funzione
Raddoppia prevedendo di applicarla, come si
dice, "alla classe degli oggetti addizionabili". Potremmo scrivere, usando
una sintassi quasi alla Eiffel:
class Addizionabili
...
feature Raddoppia is
Result := x + x
end
...
in cui, alla classe di oggetti
Addizionabili, cioè quelli che conoscono
come rispondere al comando + , rendiamo noto cosa voglia dire il comando
(o caratteristica: feature) Raddoppia,
spiegandoglielo in termini di addizioni, che loro comprendono.
A questo punto, la funzione
Raddoppia si estenderà a tutti gli oggetti
Addizionabili, tra cui ci potranno essere numeri
interi e reali, che sono già sottotipi del tipo addizionabile per
il linguaggio di programmazione, ma anche tipi di dato costruiti da noi,
per esempio matrici di numeri. Basterà che i nuovi oggetti siano
dichiarati "eredi" del tipo addizionabile:
class Matrici inherit
Addizionabili
e si spieghi loro come reagire al comando caratterizzante
la classe di oggetti Addizionabili, cioè
il + . Nel caso delle matrici questo significherà addizionare tra
loro tutti gli elementi di identica posizione.
A questo punto, i comandi:
intero_tal_dei_tali.Raddoppia
oppure:
matrice_tal_altra.Raddoppia
saranno interpretati ed eseguiti, perché
Raddoppia è scritto usando solo l'addizione,
e sia l'intero che la matrice sanno cosa vuol dire sommarsi, perché
sono "eredi", potremmo dire sottotipi, della classe di oggetti
Addizionabili.
Come si vede, la programmazione OOP è "nominale",
cioè il suo codice è una sequenza di comandi di cui si conosce
il nome ma non l'esatta esecuzione: chi scrive
Raddoppia non conosce affatto cosa bisogna fare
per raddoppiare una matrice. E' infatti la matrice, come oggetto intelligente
che sa rispondere a dei comandi, che sa cosa fare quando le viene inviato
il comando Raddoppia. E' del tutto ovvio che
ci vorrà qualche programmatore (quello che implementa la classe
Matrici) che scriva nei dettagli cosa vuol dire
fare l'addizione di matrici, ma il punto importante, centrale, fondamentale,
è che qualcuno avrà potuto scrivere una volta per tutte la
funzione Raddoppia, cogliendola nella sua più
totale astrazione, e tutti gli altri potranno riutilizzare il suo lavoro,
senza ripartire ogni volta dall'invenzione della ruota.
Questo modo di procedere non produce programmi "che si
scrivono da soli", semplicemente ripartisce il lavoro nel modo più
opportuno, separando il compito di definire comandi come
Raddoppia, da quello di programmare l'addizione
di matrici, in modo che combinandoli, non sia più necessario per nessuno
scrivere direttamente il caso (concreto) di "raddoppio di matrice".
Ovviamente la semplicità dell'esempio portato non
implica che in OOP ci si occupi di cose così stupide, anzi. Ci sono
per esempio delle costruzioni ricorrenti con frequenza quotidiana nel lavoro
di programmazione, come liste, tabelle, stack ecc., e tutte possono essere
colte nella loro generalità programmandole una volta per tutte, e
creando i casi concreti che di volta in volta servono quali eredi di quelli
generali.
Possiamo ora passare ad una spiegazione dettagliata dei
termini tipici della OOP, che potranno essere comprensibili avendo presente
quanto detto finora.
Innanzi tutto, in OOP il tipo di dato viene sostituito
dal concetto di "classe di oggetti". Come nella programmazione ordinaria,
quando si specifica una classe di oggetti, si elencano le parti di cui questi
oggetti sono composti (per esempio, un vettore tridimensionale è composto
di tre numeri reali), ma in più si definisce anche il "comportamento"
di questi tipi di oggetti, cioè i comandi ("messaggi") a cui questi
tipi di oggetti sapranno reagire. Le azioni che porranno in essere quando
riceveranno un messaggio, saranno delle procedure interne, i cosiddetti "metodi"
(di risposta, si intende).
Importante è capire che sia i sottocomponenti
dell'oggetto che queste procedure interne, che costituiscono "l'implementazione"
dell'oggetto e che dal punto di vista di un programmatore ordinario sarebbero
tutto, dal punto di vista della OOP non sono nemmeno (direttamente) necessarie.
Quello che è fondamentale è l'elenco dei
nomi dei messaggi a cui quella classe di oggetti sa rispondere, quello che
in breve potremmo definire il "vocabolario" dell'oggetto. Si possono definire
infatti quelle che vengono dette classi "astratte", che non hanno alcuna
implementazione, cioè sono senza una definizione esplicita di cosa
siano i loro componenti e le loro procedure interne, ed hanno solo un elenco
di messaggi a cui rispondere. Un tipico esempio che rende bene l'idea è,
nell'ambito di un programma di grafica, la definizione della classe astratta
di "Figura_geometrica". Questa corrisponde ad
un'astrazione della nostra mente, e ad essa possiamo associare delle operazioni
anch'esse astratte nel senso di applicabili a qualunque figura, come ad esempio
sposta in alto di x, ruota di y, dilata di z, riempi con il colore xyz, ecc.
Ogni volta che introduciamo un nuovo tipo di figura geometrica, per esempio
un cerchio, la definiamo come erede della classe
Figura_geometrica:
class Cerchio inherit
Figura_geometrica
e implementiamo i suoi sottocomponenti (nel caso del cerchio
si può definirlo usando le coordinate del centro e la misura del raggio)
e i metodi di risposta a tutti i messaggi della classe
Figura_geometrica (per esempio, sposta in alto
di x verrà tradotta nell'aggiungere x al valore della coordinata verticale
del centro del cerchio). L'implementazione della classe astratta viene quindi
eseguita non subito, ma rimandata alle classi sue eredi, cioè alle
sue sottoclassi. In Eiffel non si parla di classe astratta, ma di "deferred
class", classe la cui implementazione è solo rimandata. Qual é
il vantaggio, se poi dobbiamo sempre di volta in volta definire il significato
di ciascuna delle operazioni astratte che si possono eseguire su una figura
geometrica? Il vantaggio è che si possono concepire e realizzare pezzi
di codice OO che utilizzano solo il concetto di figura geometrica e di operazioni
generali su di essa, e questi funzioneranno con ogni tipo concreto di figura,
magari ancora da realizzare, non appena questa venga dichiarata "erede di
Figura_geometrica".
Nelle introduzioni alla OOP tipicamente si riassumono
le caratteristiche più importanti di questa con i termini
incapsulamento, ereditarietà e polimorfismo. Vediamone il
significato.
La stretta unione di dati e procedure di una classe, viene
detta "incapsulamento". Questo concetto è legato a quello di segretazione
dell'informazione ("information hiding"), ma non coincide del tutto con essa.
Nella OOP è necessario legare insieme (incapsulare) dati e procedure
che su essi operano perché solo l'oggetto (il suo programmatore
cioè) sa come reagire ad un determinato messaggio: lo spostamento
di questo compito dalla funzione all'oggetto è il punto cruciale di
tutta la OOP. Si può inoltre intendere la parola "capsula" nel
senso che l'implementazione concreta dell'oggetto non deve importare a nessuno
dei "programmi clienti", cioè quelli che usano quel tipo di oggetti:
essi si devono basare solo sul comportamento pubblico dell'oggetto, cioè
su quello che esso promette di fare in risposta ai messaggi che gli si mandano,
non sul modo concreto con cui internamente lo fa. In questo senso l'interno
della capsula deve rimanere nascosto agli utenti dell'oggetto. A questo
proposito, può sorgere un dubbio: se i programmi me li faccio tutti
da solo, cosa significa che non posso vedere l'interno di un oggetto, forse
che devo farmi colpire da amnesia non appena lo utilizzo? In realtà
no, ovviamente, significa solo che quando uso un oggetto il compilatore non
potrà che permettermi di mandargli messaggi ufficialmente non
riconosciuti, anche se so che al suo interno l'oggetto ha altri dati e procedure
che non fanno parte della "superficiale visibile" della capsula. E, cosa
più sottile ma anche più importante, non dovrò presupporre
nell'oggetto certi tipi di comportamenti che so avere (perché l'ho
programmato io), ma che ufficialmente non ha, perché non fanno parte
delle sue specifiche pubbliche.
Il concetto di "ereditarietà" è quello che
permette di applicare i comportamenti astratti definiti da una classe alle
sue classi eredi. Questo significa che con:
class Classe_figlia inherit
Classe_madre
...
...
si metterà
Classe_figlia in grado di rispondere a tutti
i messaggi cui sa rispondere Classe_madre, e
quindi di utilizzare tutti i programmi scritti per essa. Così
Classe_figlia avrà tutte le caratteristiche
e capacità di Classe_madre, e qualcosa
in più di particolare e solo suo.
Il concetto di polimorfismo è strettamente collegato
a quello di ereditarietà, e consiste in questo: uno stesso codice
scritto per Classe_madre sarà applicabile
anche alla sua erede Classe_figlia, ma in questo
caso avrà effetti diversi perché si applica ad un tipo diverso
di oggetto, magari molto più complesso del tipo originale. Lo stesso
comando avrà quindi due forme diverse di effetti, a seconda dell'oggetto
su cui si trova ad agire, sarà cioè "polimorfo". Per capire
quale esatto tipo di azione applicare (binding), bisognerà attendere
il momento dell'esecuzione (runtime), avendo un collegamento tra comando
ed azione che è quindi dinamico, per cui si parla di "runtime dynamic
binding".
Questo fatto che dei comandi da noi scritti possano venire
interpretati in modo notevolmente diverso da quello che abbiamo in mente
mentre scriviamo il nostro programma, mette in luce una possibile debolezza
del codice OOP, cioè il fatto che questo codice è "nominale",
nel senso che si basa tutto sui nomi dei tipi di oggetti e delle loro
caratteristiche. Questo vuol dire che non basta che io dia ordini precisi
e che questi vengano interpretati fedelmente da bravi esecutori, se poi per
loro non hanno il significato che intendevo io: non basta avere molte parole
in comune, se poi queste non hanno lo stesso significato.
Questo è il problema nell'affidare ad altri parte
di un lavoro: si può rischiare che non facciano quello che intendevamo.
Non per questo però si può pensare di far tutto da sé,
rinunciando agli enormi vantaggi della cooperazione: questa debolezza deriva
quindi dallo stesso punto di forza del metodo.
Ci sono due linee di comportamento che si possono adottare
riguardo al problema "della fiducia": uno è quello di chi non si fida,
che controlla che quanto promesso dai fornitori nei contratti sia effettivamente
mantenuto, l'altro è quello che si fida del prossimo e si accorge
delle eventuali fregature solo all'ultimo momento. E' anche interessante
notare che questa distinzione corrisponde, per quanto riguarda l'uso diverso
che si fa della lingua, alla distanza tra il linguaggio tecnico-scientifico
e quello letterario.
Queste due tendenze sono incarnate dai due linguaggi OOP
citati all'inizio, Eiffel e Smalltalk. Il primo incarna lo spirito matematico
ed ingegneristico che pretende specifiche di funzionamento, e garantisce
risultati sottoponendo tutto a rigidi controlli. Il secondo invece, nell'ambito
del quale è stato introdotta non a caso la parola "messaggio" per
indicare un comando inviato ad un oggetto, l'associazione è molto
più liberale, tanto che può capitare che una esecuzione si
interrompa con l'informazione (per l'utente umano stavolta!) "messaggio non
compreso", che significa che qualche parte del programma ha avuto un
"fraintendimento" con l'oggetto con cui comunicava.
Entrambi sono modi di procedere legittimi, perché
adatti ad ambiti e scopi diversi.
Per terminare, è utile un brevissimo riassunto
che sprema tutto il contenuto di quanto scritto sopra:
- la programmazione ordinaria ha un difetto fondamentale,
quello che bisogna sempre specificare su che preciso tipo di dati una funzione,
come Raddoppia, lavora (ad esempio gli interi);
- il ragionare umano non procede però in questo
modo, ma per astrazioni: l'unica cosa importate per esprimere
Raddoppia è che l'oggetto a cui si applica
possa essere addizionato, non se sia un intero, espresso da un byte, o un
reale, rappresentato con due;
- i "tipi di oggetti" che quindi ci servono sono quelli
per cui hanno significato alcune operazioni astratte: per esempio la classe
degli oggetti Addizionabili è quella
per cui ha significato l'operatore +;
- un programma che contenga comandi ad oggetti
Addizionabili, non è tenuto a sapere
cosa esattamente siano questi oggetti, proprio per l'astrazione di cui si
diceva. Non potrà essere quindi lui ad eseguire l'operazione di addizione;
- il programma che conosce il modo di addizionare due
oggetti Addizionabili di un certo specifico
tipo, ad esempio due matrici, deve stare quindi da qualche altra parte;
- nasce così una spontanea e perfetta unione tra
il dato, ad esempio una matrice, e il codice che gestisce le operazioni su
di essa. L'oggetto della OOP è proprio l'unione di queste due cose:
l'operazione diventa una caratteristica (feature) dell'oggetto, che può
essere attivata "mandando un messaggio" all'oggetto, in questo caso "sommati
a ...";
- un oggetto complesso come una matrice può quindi
essere utilizzato anche da un programma scritto da qualcuno che ignorava
perfino l'esistenza di tale tipo di oggetti. Basta infatti che questo programma
usi solo messaggi di una classe più ampia di oggetti (gli
Addizionabili) della quale la classe
Matrici abbia ereditato le caratteristiche;
- si vede quindi che la richiesta di astrazione porta
necessariamente all'incapsulamento (dati e procedure che su questi operano,
strettamente unite in una capsula, la classe), alla ereditarietà (se
si dichiara un oggetto erede di una classe generale, l'oggetto saprà
rispondere a tutti i comandi della classe progenitrice) e al polimorfismo
(l'oggetto derivato interpreterà il messaggio a modo suo, un modo
possibilmente diverso da quello della classe originale). Le tre caratteristiche
tipicamente invocate per descrivere l'OOP derivano quindi in modo naturale
dalla sola necessità di astrazione;
- la programmazione OOP è sostanzialmente nominale,
e questo modo di procedere può essere lasciato alla libera anarchia
e creatività, come in Smalltalk, o essere interpretato alla stringente
maniera dei matematici, come in Eiffel, ottenendo due diversi, entrambi utili,
modi di realizzare la programmazione ad oggetti.
Questo è quanto, riguardo ai "perché" della
programmazione ad oggetti. Non diremo nulla dell'altra importantissima
caratteristica della OOP, quella organizzativa: la visione di un mondo
di oggetti in interazione che sostituisce quella di un computer
che esegue un "programma principale", il quale opera richiamando delle
sottoprocedure. Dal punto di vista di chi ci lavora, è proprio questa
diversa organizzazione che costituisce il salto più grande nello stile
di lavoro di programmazione, rispetto a quella ordinaria. Ma, come detto,
questo è tutto un altro argomento.
|
 |