Torna alla pagina di Programmazione degli Elaboratori
:: Capitolo 4: Programmazione orientata agli oggetti ::
Indice
1 Concetti fondamentali
1.1 Astrazione
1.2 Tipi di dati astratti
1.3 Incapsulamento
1.4 Esempio di incapsulamento balordo in C
1.5 Classi e oggetti
2 Riuso del software
2.1 Approcci al riuso del software
2.2 Riuso del software e programmazione ad oggetti
2.3 Pattern
2.4 Componenti
2.5 Programmazione orientata agli aspetti
2.6 Riusabilità in Java
3 Interfacce utente
3.1 Interfacce a carattere
3.2 Interfacce grafiche
1. Concetti fondamentali
La Programmazione Orientata agli Oggetti (OOP da qui in poi) rappresenta lo sviluppo delle intuizioni della programmazione strutturata, concentrandosi più sui dati da manipolare, piuttosto che sulle procedure che eseguono la manipolazione.
1.1 Astrazione
L'idea che sta alla base dell'astrazione è frammentare un sistema complesso nei suoi componenti fondamentali, descrivendoli nel modo più semplice e preciso possibile trascurandone i dettagli non rilevanti.
1.2 Tipo di Dato Astratto
Il Tipo di Dato Astratto è un modello che definisce un insieme di operazioni che costituiscono la sua interfaccia, e il suo dominio di applicazione è definito da assiomi e precondizioni. Esso trascura la rappresentazione fisica del dato nella macchina, poiché in generale ciò al programmatore non importa.
Ha le seguenti caratteristiche:
- Interfaccia
- Nome
- Operazioni consentite
- Assiomi e Precondizioni
E' come un uovo: i dettagli sono il tuorlo, nascosti al mondo, mentre ciò che è visibile (l'Interfaccia) è il guscio.
Un esempio di dato astratto che usiamo tutti i giorni è il numero naturale, del quale definiamo il nome, di cui sappiamo le operazioni consentite (chiedere alla Citrini), e di cui abbiamo nozione dei suoi Assiomi e delle sue Precondizioni (eg., n + 0 != n; n * 0 = 0 e così via).
Altro esempio più elaborato è quello dello stack. La sua interfaccia minima è definita da:
- PUSH <dato>, che aggiunge un dato in cima alla pila;
- TOP, che restituisce il dato in cima alla pila (precondizione: il numero di elementi sullo stack è positivo, ovvero la pila deve contenere almeno un dato);
- DROP, che elimina il dato in cima alla pila (ha la stessa precondizione di prima).
Un assioma potrebbero essere che "TOP non varia il numero dei dati contenuti nella pila", oppure che "se lo stack contiene n elementi, dopo l'istruzione DROP ne contiene "n-1".
1.3 Incapsulamento
Incapsulare significa "intuorlare" qualche cosa, cioè farla finire in un immaginario tuorlo di un dato astratto, e progettarne il guscio.
Il programmatore OOP individua i concetti del suo programma, e li intuorla, incapsulandoli da qualche parte e nascondendone i dettagli implementativi.
Uno dei vantaggi dell'incapsulamento è che posso mantenere l'interfaccia, e modificarne il contenuto come voglio, senza che il resto del sistema ne soffra. Ciò permette di limitare le dipendenze tra i vari oggetti alle sole interfacce, che meglio rappresentano l'idea di dato astratto.
La dipendenza tra oggetti vuol dire questo: creo il tipo di dato astratto Motore, con l'interfaccia composta da Accendi, Accelera, Molla il Gas, Spegni, e poi creo il tipo di dato astratto Automobile che incorpora il Motore. Nel momento in cui decido di cambiare il codice che esegue l'operazione Accendi, per esempio, non devo stare a modificare tutto l'ambaradan dell'automobile, perché l'incapsulamento permette di fregarmene: una volta che ho deciso che l'interfaccia del Motore è composta da Accendi, Accelera, Molla il Gas e Spegni, io programmatore di Automobile non devo sapere altro. Il programmatore di Motore farà tutti i casini che vuole, ma finché l'interfaccia non cambia, non ho problemi.
Domanda: ma non si può fare la stessa cosa con un linguaggio strutturato? Per esempio, creo delle struct
, e creo delle funzioni che le manipolino?.
Risposta: sì, certo. Il videogioco Doom è stato programmato così. Unico problema: siccome nei linguaggi strutturati non c'è la nozione di incapsulamento, può accadere che qualcuno, senza difficoltà, si metta a cambiare le funzioni manipolatrici, oppure decida di non usarle, e tutto il mio lavoro di progettazione va a balle. L'incapsulamento invece mi dice: l'interfaccia è questa: da qui non si scappa, e non si può sbagliare. Obbligo chi usa il mio codice a 1) non poter modificare il funzionamento interno (vero in parte, poi vedremo perché); 2) a usare la stessa interfaccia sempre e comunque.
Se volete vi faccio anche l'esempio di programmazione pseudo incapsulata in C... ma sì dai!
1.4 Esempio di incapsulamento balordo in C
Voglio un programma che manipoli dei numeri immaginari, e devo decidere se usare la notazione geometrica o quella trigonometrica (consultare il Cariboni per i dettagli).
Nel file complex.h
metto delle definizioni così:
double re(void * this);
double im(void * this);
double mod(void * this);
Queste funzioni mi restituiscono la parte immaginaria, quella reale ed il modulo del mio numero reale. La cosa interessante è che prendono come argomento un tipo anonimo che più anonimo non si può: un puntatore a void.
Poi scrivo il file complex.c
(notate il .c
e ci metto la definizione della struct complex
:
struct complex {
double re;
doube im;
}
I file .c
in genere vengono "inclusi" solo di proposito, in genere si includono solo gli header.
Invece, visibili al programmatore, metto le implementazioni delle mie operazioni double re
e così via:
double re(void * this) {
struct complex * z = (struct complex *)this;
return z -> re;
}
Questa funzione prende il parametro void * this
, lo casta a struct complex *
, e restituisce la parte reale di esso.
Quindi, il programmatore è limitato a fare una cosa del genere:
void * z;
printf("%f\n", re(z));
Ok. Come faccio a creare un numero complesso a partire dalla sua parte immaginaria e dalla sua parte complessa? Occorre una funzione che prenda in argomento queste cose, e le restituisca come void * this
. Eccola:
//complex.h
void * newComplex(double re, double im);
//complex.c
void * newComplex(double re, double im) {
struct complex * z;
z = (struct complex *)malloc(sizeof(struct complex));
z -> re = re;
z -> im = im;
return z;
}
Questa funzione si chiama costruttore: costruisce il tipo di dato.
Quello che abbiamo sviluppato qui è una specie di anticipo della programmazione strutturata. Se un giorno decido di cambiare la rappresentazione da geometrica (quella implementata qui) ad esponenziale, devo solo ricordarmi di modificare il costruttore e le funzioni double re
etc., perché tanto il programmatore usa sempre e solo il tipo di dati void *
, e quindi se ne frega di come implemento io alla fine il mio bel numero complesso.
La OOP mi fa fare tutto ciò in modo elegante.
1.5 Classi e Oggetti
La Classe è il tipo di dato astratto che descrive tutti i casi particolari di oggetti di un certo tipo. Una Classe Conto Corrente descrive sia il conto presso la Banca Popolare di Lodi, che presso la Banca del Seme. E' l'ultimo gradino di un processo di astrazione:
Realtà -> Modello -> Oggetto -> Classe
Parto dalla realtà, e ne creo un modello. Il mio modello (la mia banca, in questo esempio) vedo che è composto da entità monadiche, i miei conti correnti. Ho tanti conti, quello di Patrizia? pieno delle bustarelle della Lobby del Fil di Ferro, e quello di Dario? finanziato dai Terroristi Tettamanziani per la Jihad del Pinguino, ma sono tutti simili. Quindi, astraggo ancora, e arrivo al tipo di dato astratto: la Classe Conto Corrente. Applausi, prego.
Da notare che non ci sono regole fisse per stabilire chi è Classe e chi è Oggetto: è la pratica che ci fa capire che cosa è più comodo per noi.
L'Oggetto è una particolare incarnazione della Classe: come dicevo qui sopra, l'incarnazione di Dario del suo conto corrente è fatta in un modo, quella di Patty in in altro.
Si può quindi già trarre una distinzione tra ciò che pertiene ad una Classe, e ciò che pertiene ad un Oggetto.
Torniamo all'esempio delle automobili: ho creato la Classe 126?, che ha un motore di 650 cm3 e una potenza di 23 cavalli. Voglio poi implementare il Garage di Dario, che in un roseo futuro ne possiede 8. Ognuna di queste 126 appartiene alla Classe 126. Quindi, tutti i miei oggetti condivideranno la cilindrata e la potenza. Ecco quindi che la cilindrata e la potenza sono dati che appartengono alla Classe, mentre la targa di ogni singolo pezzo della collezione appartiene all'oggetto, cioè all'incarnazione (in gergo OOP, istanza).
Si può dire che l'idea tradizionale di struttura dati trova nell'OOP il suo corrispettivo nell'oggetto.
Per concludere con un'ultima nota di terminologia, le operazioni che si possono compiere su un oggetto o su una classe di oggetti vengono chiamati metodi.
Torna su
2. Riuso del codice
Fino ad ora Riuso del Codice ha significato il Copia e Incolla di codice scritto da altri. Riscrivere codice è costoso. Ogni anno la potenza di calcolo cresce mediamente del 20%, mentre la produttività dello sviluppo aumenta solo dell'8%. Il divario tra questi due fattori di crescita mi dice che per quanto mi sforzi, sarò sempre in perdita rispetto a quello che potrei guadagnare con l'aumento della potenza di calcolo. Ecco perché occorre far di tutto per rendere la programmazione il meno costosa possibile, e un modo per farlo è riusare il codice in modo intelligente.
2.1 Approcci al riuso del software
Ci sono 3 approcci fondamentali al riuso del codice
- Librerie di Componenti Riusabili;
- Schemi trasformazionali a spettro ristretto;
- Schemi trasformazionali a spettro largo.
Le librerie sono il sistema più usato:
- o uso codice di una libreria senza modificarlo (succede miliardi di volte al giorno, è comodissimo),
- o adatto pezzi di programma al mio scopo, cioè riscrivo il minimo possibile di una libreria già fatta (si può fare anche in automatico, ma è un ramo dell'informatica ancora non ben studiato).
Gli schemi trasformazionali fanno uso di linguaggi di descrizione di algoritmi, ad alto o basso livello, che generano automaticamente il codice che mi serve nel linguaggio che voglio. Ne sono un esempio le interfacce grafiche che mi permettono di mettere insieme una finestra con tutti gli annessi e connessi, generando automaticamente il codice C++ corrispondente. Il Tetty? non ha specificato la differenza tra spettro ampio e ristretto, che non è importante.
2.2 Riuso del software e programmazione ad oggetti
La OOP è quella che meglio supporta il riuso del codice, infatti:
- l'incapsulamento definisce bene l'interfaccia;
- le librerie contengono Classi, non sottoprogrammi, col vantaggio che non posso perdere funzioni per strada, ma vengono mantenute assieme ad esse come suoi metodi;
- incorpora già di suo la possibilità di estendere il codice, tramite le estensioni di classe e di interfaccia.
2.3 Pattern
La OOP tuttavia non basta: occorre uno Schema Progettuale = Pattern.
Il Pattern descrive un problema che si incontra comunemente nello scrivere programmi ed il nucleo della sua soluzione. Infatti, i problemi che saltano fuori durante la programmazione sono bene o male sempre quelli, cambia solo qualche dettaglio qua e là.
Per creare un pattern, seguo queste norme:
- identificare bene gli oggetti;
- raggrupparli in classi che dividano nel giusto modo proprietà della classe e proprietà dell'oggetto;
- definire le interfacce;
- stabilire le gerarchie tra classi e interfacce;
- stabilire relazioni tra classi ("questa classe comprende quella...", etc).
Il trucco è rispettare le specifiche, ma rimanere anche abbasanza generici da poter trattare altri problemi simili.
Quando si tenta di risolvere un problema, è raro riuscire al primo colpo: si procede a tentativi. Tutti questi tentativi producono esperienza, e il progettista esperto ha un bel fagotto di esperienze e di schemi di progetto, che si possono adattare a nuove situazioni.
Le aziende di software dipendono molto dal bagaglio di conoscenze dei proprio programmatori esperti. Ma se questi se ne vanno, cosa gli rimane? Loro portano tutto quello che sanno con sé e non lasciano dietro niente?
I pattern servono proprio a questo, cercare di mettere su carta l'impalpabile essenza dell'esperienza. I suoi elementi essenziali sono:
- nome;
- descrizione del problema e contesto di soluzione;
- soluzione
- classi e oggetti costitutivi;
- loro relazioni;
- conseguenze dell'applicazione del pattern
- risultati;
- vantaggi e svantaggi.
Concludendo, si può dire che i pattern sono un po' i mattoncini di base della programmazione Bottom - Up, con la differenza che qui mi danno un prefabbricato intero con fondamenta e pareti già tirate su.
2.4 Componenti
Mettendo insieme due idee potenti come l'incapsulamento e il pattern, si arriva al concetto di componente. Un componente non è altro che un programma in grado di svolgere una data funzione, ma concepito in modo tale da funzionare in diversi ambienti ed essere combinato con altri componenti per creare applicazioni più complesse. Certo, perché questo accada i componenti non possono essere sviluppati a cazzo caso, ma devono rispettare certi standard comuni per poter essere compatibili e sviluppare così una coerente architettura di componenti (come ad esempio la Java Beans).
I componenti generalizzano il compito che nei OOP svolgono gli oggetti, ovvero raggruppare una struttura dati e relative operazioni in un'entità logica autonoma, portandola alle estreme conseguenze. Un sistema complesso può infatti essere composto da diversi componenti anche scritti in linguaggi diversi, purché compatibili.
2.5 Programmazione orientata agli aspetti
Warning: qui entriamo nel filosofico.
Scrivo un programma, che fa una certa cosa. Posso dividere quello che fa e raggrupparlo in questi aspetti:
- che compito svolge;
- come gestisce le anomalie;
- come garantise la sicurezza;
- come comunica con altri componenti.
Un linguaggio di programmazione può essere adatto ad esprimere bene uno di questi aspetti, ma non un altro. Nessun linguaggio cattura tutti gli aspetti. Per esempio, il C svolge bene il suo compito, ma gestire le anomalie col C è una spina nel culo, senza contare che la sicurezza di funzione come fscanf
e compagnia è limitata (chi controlla che il char *
passato alla fscanf
sia abbastanza grande? Nessuno! e così via).
Un programma presenta tutti questi aspetti inestricabilmente mischiati insieme. Provate a scriverne uno qualsiasi, anche semplice, e vedrete linee di codice che si occupano delle anomalie (if (Fp1 == null) ...
) seguite da linee che si occupano della comunicazione (printf("Brao asen!\n");
) e così via.
Quando parlavamo della modularità, dicevamo di poter scomporre un programma complesso in blocchi funzionali. Qui è più o meno la stessa cosa: scompongo il programma in base agli aspetti.
Per gestire queste cose complesse esistono dei linguaggi orientati agli aspetti, che si occupano di tutto ciò. Descrivo i vari aspetti separatamente, e poi con un tessitore di aspetti, detto weaver, li metto insieme e compongo il mio programma.
Separare gli aspetti rende il mio programma più chiaro e più manutenibile, ed è più facile avere componenti riutilizzabili.
Per Java, esiste AspectJ, a cui sono seguiti altri software dedicati ad altri linguaggi, come AspectC++. In pratica, si tratta di estensioni del linguaggio di programmazione, che permettono di separare gli aspetti. Ci sono i punti di giunzione fra vari aspetti e robe così. Poi il programma prende in mano tutto ciò e scrive il programma finito in C++ o in Java o quello che è. Se devo modificare un aspetto, non vado a cercare nel codice generato, ma lavoro ancora in AspectJ. Si tratta di una tecnologia molto recente, non ancora ben digerita e sviluppata.
2.6 Riusabilità in Java
Java ha tra i principali obiettivi quello di produrre codici riusabili, e lo fa - come detto all'inizio - concentrandosi sulle interfacce piuttosto che sulle implementazioni.
Torna su
3. Interfacce utente
Anche l'utente ha bisogno di interagire con un programma! Il modo con cui lo può fare è - tadam! - un' interfaccia utente! Come per le interfacce delle classi, l'interfaccia utente è una lista di cose che l'utente può fare per far fare al programma quello che vuole.
3.1 Interfacce a carattere
Qualcuno ne ha mai usata una? Ma certo, quando scriviamo i programmi del Laboratorio di C, e da console immettiamo i dati, abbiamo realizzato una primitiva interfaccia a carattere.
Storicamente sono state le prime ad apparire, perché le più semplici da realizzare. L'utente dialoga col programma tramite il terminale, una volta c'era solo il terminale e quindi non c'era molta scelta.
Ci sono due tipi di interfacce a carattere: quelle guidate e quelle libere.
Le interfacce guidate presentano dei menu oppure delle richieste di dati a cui si può rispondere in un solo modo. Questo è il caso delle nostre applicazioncine da laboratorio. Il programma in cui si chiedeva il nome del file da aprire era un'interfaccia guidata. Potevo fare solo quello.
Le interfacce libere invece sono quelle in cui compare il famoso prompt dei programmi. Il prompt è per esempio quello che compare quando aprite la console di windows: il cursore lampeggia e attende istruzioni. Le istruzioni possono essere semplici (dir) oppure complesse (move *.exe d:\cartella) quanto si vuole. Ci sono alcune applicazioni in cui l'interfaccia libera è tanto potente da essere quasi un linguaggio di programmazione. Potete pensare alla Shell di Linux.
È ovvio che le interfacce a carattere guidate non solo sono più semplici da usare, ma anche più semplici da scrivere! Quelle libere invece sono difficili da scrivere, nella misura in cui il "linguaggio" che abbiamo deciso di implementare è complesso, e nella stessa misura sono anche "difficili" da usare. Molto meglio per l'utente avanzato, che in una Shell di un qualsiasi Unix può fare in pochi secondi quello che l'utente medio di Windows ci mette 3 ore cliccando a destra e a sinistra. (Nota: per copiare dalla chiavetta faccio così: mount /mnt/chiavetta && cp /mnt/chiavetta/* downloads && umount /mnt/chiavetta
. Qualcuno nota della differenza con il clicca di qua, clicca di là, cos'accadrà cos'accadrà!?:)
Questo tanto per dirvi che le interfacce a carattere non sono morte e non sono un ricordo del passato, anzi! Sono vive e vegete e spesso sono l'unico modo veloce per fare certe operazioni. Sono poco accattivanti, ma il computer deve innanzitutto funzionare...:)
3.2 Interfacce grafiche
Sono nate alla Xerox di Palo Alto negli anni 80. No, le finestre non le ha inventate micro$oft con windows. Anzi. Alla Xerox avevano inventato quel ridicolo oggetto chiamato mouse, che muoveva un cursore sullo schermo, cliccava di qua e di là e faceva accadere cose. I dirigenti Xerox pensavano che una cosa del genere sarebbe stata poco seria: non vedevano dei rispettabili manager agitare frecce per lo schermo come in un videogioco! Fu così che dell'idea si appropriò Steve Jobs, per i suoi primi Apple.
Requisito fondamentale per le interfacce grafiche è di avere un computer con... capacità grafiche! Queste capacità non sono state affatto scontate fino a metà anni 80: occorreva una costosa scheda video ed un monitor adatto. Ricordo il mio primo pc su cui girava Windows 2.1 (installato dai floppony da 5-1/4 pollici), e lo schermo era verde, ad una risoluzione bassa, con qualche sfumatura di grigio naturalmente visualizzata verde. La vita era difficile, ai tempi... Per la cronaca era un Olivetti M24.
Le Graphical User Interface, chiamate GUI, lavorano ad un livello simbolico. L'icona di un programma rappresenta un programma, il desktop è un simbolo per una scrivania di un ufficio, e così via. Si tratta di un'intuizione geniale.
L'utente di una GUI è lui a determinare il flusso degli eventi: non c'è il programmino in C che funziona in un solo verso: dammi il numero di chilometri percorsi; dammi il numero di litri consumati; ecco che ti dico quanti chilometri fai con un litro. L'interazione dell'utente con gli elementi della GUI (menu, bottoni, liste...) provoca degli eventi, che vanno interpretati: l'applicazione reagisce all'utente.
Per gestire questa faccenda degli eventi, un sistema GUI deve avere almeno questi 3 elementi:
- Una coda degli eventi, in cui tutti gli eventi generati vengono infilati come in uno stack, in attesa di essere analizzati;
- un dispatcher, cioè uno smistatore di eventi, che pesca un evento dalla coda, guarda di che tipo è e lo smista al suo...
- gestore di eventi: la parte di programma che gestisce l'evento.
Se clicco sul menu File
, genero un evento L'utente ha cliccato sul menu file
. Quest'evento finisce nella coda. Il dispatcher lo prende e dice: Mmm, un evento di tipo menu. Adesso vedo a chi lo devo mandare...
e quando lo trova glielo manda.
Ma il concetto di evento non si limita al bottone premuto o alla finestra che scorre. Può essere di qualsiasi tipo. Per esempio, se clicco sulla voce Salva
del menu File
, genero un evento particolare, che potrebbe chiamarsi L'utente vuole salvare quello che ha scritto
, e qualcuno deve occuparsi di questo evento.
La OOP si presta bene alla programmazione di GUI. Le classi fondamentali da implementare sono le seguenti:
- una classe per gli eventi: deve poter rappresentare tutto ciò che può succedere in un programma, e avere un'interfaccia standard;
- un dispatcher, che controlla la coda degli eventi e li manda ai listeners registrati;
- alcuni listeners: si tratta di classi che si iscrivono presso il registro del dispatcher e dicono: "Io mi occupo del menu file"; "Io invece del pulsante di chiusura"; "Io del bottone che dice 'Clicca qui per vincere 10.000$'";
- delle classi che invece gli eventi li generano.
Come potete vedere, questo è un pattern. Perché? Perché siccome tutte le GUI funzionano così, una volta che ho individuato uno schema di lavoro (quello qui sopra) tutte le altre GUI, pur diverse, saranno tutte simili.
La programmazione di GUI, come si può intuire, è di molto più complessa rispetto alla programmazione di altre interfacce a carattere.
Anche JAVA offre la possibilità di scrivere programmi con GUI, e lo fa attraverso la libreria JAVA.AWT. AWT sta per Advanced Windowing Toolkit. Toolkit vuol dire cassetta degli attrezzi.
I listeners devono implementare l'interfaccia XXXListener, dove XXX è il tipo di evento a cui reagiscono (ce ne sono centinaia).
Torna su
Torna alla pagina di Programmazione degli Elaboratori