Вы находитесь на странице: 1из 32

La programmazione

ad oggetti in Java
Introduzione allo sviluppo orientato ad
oggetti

Materiale realizzato nell’ambito del corso di


Programmazione ad Oggetti tenuto dal professor
Mario Vento, CDL di Ingegneria Informatica, anno
accademico 2007/2008, Università degli Studi di
Salerno.

Vincenzo De Notaris
8/1/2008
LA PROGRAMMAZIONE AD OGGETTI IN JAVA

Paradigma ad oggetti

Ogni linguaggio di programmazione fornisce delle astrazioni. La complessità di tale


linguaggio ha una correlazione diretta all’astrazione con la quale si va ad operare.

I linguaggi di programmazione di basso livello rappresentano un’astrazione della


macchina in oggetto. Il programmatore è tenuto a stabilire la relazione che intercorre
tra il modello del calcolatore ed il modello del problema che si intende risolvere.

L’approccio orientato ad oggetti (Object Oriented Programmation) rappresenta


un’evoluzione importante dei concetti sopra detti, dando possibilità al programmatore
di rappresentare elementi dello spazio dei problemi stessi, senza quindi l’obbligo di
rifarsi alla peculiarità inside della specifica macchina. Il modello di sviluppo orientato
ad oggetti rappresenta, quindi, uno strumento potente e completo nell’ambito
dell’ingegneria del software.

L’oggetto

L’oggetto è l’unità fondamentale del modello, un entità a se stante tramite il quale è


possibile memorizzare dati. Gli attributi sono le qualità che volgiamo rappresentare nei
nostri oggetti, e per i nostri scopi possono essere considerati come i dati posseduti
dall’oggetto e che esso ha la responsabilità di gestire garantendone la consistenza.
L’insieme degli attributi di un oggetto è spesso identificato come lo stato dell’oggetto.
E’ interessante notare come nell’OOP un oggetto possa esser costituito da altri oggetti
(od anche tipi primitivi); data tale caratteristica è facile evincere come un programma
altro non sia che un insieme di oggetti che tra loro interagiscono.

Ciascun oggetto è un istanza di una classe, ove classe assume un significato


perfettamente comparabile a quelli di tipo; è possibile, quindi, affermate che ogni
oggetto è associato un tipo.
La keyword per istanziare un oggetto è new, seguendo le specifiche dettate
dall’implementazione costruttore. Nell’esempio sottostante, poiché non riceve
parametri, il costruttore è detto di default.

// istanza di un oggetto di tipo Console denominato nextGen


Console nextGen = new Console();

Ad un oggetto è possibile effettuare richieste, ossia chiedere di fruire un servizio. Le


operazioni che esso è in grado di compiere sono definite tramite la sua interfaccia, che
stabilisce quali richieste è possibile rivolgere ad un determinato oggetto. L’invocazione
di un metodo sull’oggetto prende la definizione di messaggio.

Tra oggetti diversi sussistono delle relazioni, catalogate in diversi tipi: strutturali, non
strutturali, di ereditarietà, di realizzazione, di associazione e di composizione.

L’information hiding

Una delle caratteristiche più importanti dell’OOP è l’information hiding, che permette
di creare una netta separazione tra colui che utilizza oggetti e colui che li definisce
attraverso delle regole di visibilità associate ad attributi e metodi di una classe. In Java
vengono utilizzate tre keyword per esplicitare tali regole. La parola chiave public
stabilisce che le definizioni che seguono sono disponibili a tutti, private che esse siano
visibili sono all’interno della classe, protected che esse non siano visibili all’esterno, ma
accessibili alle classi derivate (concetto che verrà chiarito in seguito).

public class Console{

// costruttore
public console(){
// inizializza gli attributi
}

// attributi
public int giocatori;
protected Joypad controller = new Joypad();
private Chip piastra = new Chip();

// metodi
public void setGiocatori(int giocatori){
this.giocatori = giocatori;
}

public void setJoypad (Joypad controller){


this.controller = controller;
}

public void setChip (Chip piastra){


this.piastra = piastra;
}

Progettare e programmare in questo modo rende possibile a tutte le altre parti del
sistema, cioè agli altri oggetti che collaborano ed utilizzano i servizi come dei client,
di ignorare il meccanismo interno di implementazione, permettendo, tra le altre cose,
di realizzare con maggiore libertà i servizi. Quest’approccio aumenta la modularità,
diminuisce l’accoppiamento tra le parti del sistema e promuove il riutilizzo del
software in contesti diversi da quello in cui è stato inizialmente progettato.

Come visto finora è possibile raggruppare gli oggetti del nostro dominio del problema
in classi.

Si è accennato al fatto che una classe è un utile meccanismo di astrazione, che, come
tale, ci permette di gestire la complessità del problema trascurando i particolari
accessori degli oggetti e rappresentando solo le caratteristiche importanti per la
nostra applicazione.

Una classe, quindi, non è corretta in sé, come un’astrazione non è corretta in sé, ma
solo in relazione al contesto in cui la facciamo: la domanda che ciò conduce nel
processo di astrazione è quali sono i comportamenti e gli attributi che regolano
l’andamento del sistema che vogliamo progettare e quali invece sono del tutto
accessori. Quindi una classe è un ‘insieme di comportamenti e attributi comuni ad un
insieme di oggetti del nostro sistema, che rappresentino una responsabilità univoca
(semantica comune).
I package

I package del linguaggio Java costituiscono uno strumento di raggruppamento di


classi, cosa che ci consente di scomporre una qualsiasi applicazione in maniera
modulare.
Un package, come da definizione, può contenere un numero qualunque di classi od
anche di altri package, quest’ultima operazione tramite la keyword import.

L’impiego dei package risulta essere uno strumento assai utile in quanto consente di
decomporre il problema in sottoproblemi, che possono essere assegnati ad un package
o ad insiemi di package. Le classi contenute nel package decomporranno e
risolveranno il sottoproblema seguendo l’approccio divide et impera.

E possibile con essi gestire lo sviluppo parallelo del codice, poiché package diversi
possono essere assegnati a diversi team di sviluppo, favorendo il rispetto dei tempi di
consegna, tracciare le dipendenze tra diversi moduli, realizzati con i package; in
questo modo è possibile tenere sotto controllo l’accoppiamento dell’applicazione.

Il meccanismo dei package consente anche di evitare conflitti sui nomi delle classi
quando queste vengono caricate da web. In questo caso, è possibile che esitano classi
con lo stesso nome residenti su siti differenti, e che , nel corso dell’esecuzione del
programma, vengano riferite. Il meccanismo dei package si dimostra quindi
insostituibile per evitare pericolose ambiguità: a ciascuna classe corrisponderà uno
spazio dei nomi differente, cui potremo riferirsi senza possibilità di errore.

Il meccanismo dei package fa parte integrante del linguaggio, anche quando non
vengono esplicitamente creati, infatti, compilando una classe, questa farà parte del
cosiddetto “package di default”, che corrisponde alla directory in cui risiede il .class.

Con i package è possibile organizzare anche le librerie Java, ovvero le API. Le classi
fondamentali per ogni programma sono nel package java.lang, l’unico ad essere
visibile automaticamente.

Le regole per la denominazione di package è similare a quella utilizzata per i domini


internet.
A punti consecutivi corrispondono directory annidate.

Poiché i package sono un meccanismo di aggregazione delle classi, una delle


caratteristiche fondamentali legate ad essi è la visibilità reciproca che hanno le classi
appartenenti allo stesso package ed a package diversi. Se una classe è definita public
in un package, classi esterne al package possono riferirla ed importarla, laddove al
contrario, se una classe ha visibilità di default, ovvero non è stata dichiarata public
essa sarà visibile solo alle classi appartenenti al suo stesso package. Questo promuove
la coesione delle classi all’interno di un package, che dovrebbero concorrere
strettamente a implementare le funzionalità assegnategli, che invece possono essere
accedute attraverso le sue classi pubbliche. Come si può vedere questo è un altro
meccanismo che assicura l’information hiding nei nostri programmi.
I modificatori

Java mette a disposizione una serie di modificatori che consentono di variare il


comportamento e la visibilità di variabili e metodi appartenenti ad una classe.

I modificatori di accesso sono public, protected, private.


Di questi è stato spiegato il significato in precedenza. C’è da aggiungere che i metodi
protetti sono visibili anche alle classi dello stesso package, oltre a quelle derivate.

Il modificatore static è usato per metodi e variabili di classe, ovvero variabili non
legate ad alcuna istanza ma direttamente alla classe in cui sono dichiarate.
Una variabile static è condivisa tra tutti gli oggetti istanza della stessa classe.
In particolare è possibile rappresentare i campi static come appartenenti ad un’area di
memoria comune a tutte le classi, mentre l’area di memoria delle variabili e d’istanza
viene copiata e ripetuta per ciascuna istanza della classe. Poiché è possibile creare
anche variabili reference di tipo static, ovvero variabili che puntino ad oggetti e
statiche, in Java esistono dei costrutti detti inizializzatori statici. Si tratta di una coppia
di parentesi, che si possono scrivere in qualunque punto all’interno di una classe,
precedute dal modificatore static, che viene eseguita non appena la classe viene
referenziata la prima volta, e prima di ogni suo costruttore. È possibile inserire quanti
blocchi stati si vuole, questi vengono eseguiti nell’ordine in cui appaiono nella classe
dall’alto verso il basso.
I problemi legati alla condivisione del valore delle variabili statiche divengono molto
forti nel caso di programmi concorrenti. In questo caso, è necessario utilizzare le
tecniche di sincronizzazione di accesso alle variabili della classe.
I metodi dichiarati statici sono anche detti metodi di classe. I metodi pubblici dichiarati
statici possono essere richiamati indipendentemente dall’esistenza di un istanza della
classe. I metodi che operano su uno specifico oggetto non devono essere dichiarati
static, mentre quelli che sono di utilità generale e che non agiscono sulle singole

istanze, devono essere dichiarati static. I metodi statici non possono accedere alle
variabili della classe che non sono dichiarate anch’esse statiche.

Il modificatore abstract è utilizzato per creare metodi e classi astratte. Questo concetto
verrà chiarito in seguito.

Il modificatore final viene utilizzato per classi, metodi e variabili, ed ha la


caratteristica di vietare ogni modifica sulla classe, metodo o attributo cui è applicato.
Il suo scopo è rendere immodificabile un campo, ossia semplicemente renderlo
costante.
Ci sono due motivi per dichiarare una classe final: vietare ad altri di creare sottoclassi
ed aumentare l’efficienza (in quanto il compilatore sa che non possono esserci delle
sottoclassi che ridefiniscono i metodi di una classe final). Il modificatore final ha un
comportamento diverso se applicato a variabili di tipo primitivo. Per i tipi primitivi, la
variabile diviene una costante il cui valore deve essere noto in compilazione. Essendo
una costante esso non potrà essere più modificato. Quando il modificatore final è
utilizzato per un riferimento a un oggetto, allora: al momento della dichiarazione, al
riferimento deve essere assegnato un oggetto; il riferimento non potrà in seguito
riferirsi a un altro oggetto. È però possibile modificare l’oggetto riferito.

Il modificatore syncronized serve a sincronizzare attributi o metodi. Verrà chiarito il suo


utilizzo approfondendo la concorrenza (programmazione multi-thread).

Le possibilità offerte dalle librerie di Java sono molte , ma non infinite: possono
esistere casi in cui si vuole accedere a codice già scritto o a dispositivi fisici attraverso
un’applicazione scritta in Java.
In questi casi è possibile usare il meccanismo dei metodi native (ovvero nativi) scritti in
C e C++.

L’ereditarietà

Il riuso del codice implementato per strutturare un oggetto è uno dei maggiori
vantaggi offerto dai linguaggi di programmazione ad oggetti. In tale contesto trova
perfetta collocazione il concetto di ereditarietà, uno dei meccanismi di
rappresentazione ed astrazione più potenti dei linguaggi object oriented. In
particolare, permette di generalizzare ed astrarre, rappresentando in una sola classe
(detta superclasse) attributi e metodi comuni a più classi (dette sottoclassi o classi
derivate). Quando si eredita da un tipo esistente, ne vien creato uno nuovo contenente
non soltanto gli elementi del tipo esistente (sebbene quelli private siano inaccessibili e
nascosti), ma anche la duplicazione dell’interfaccia della classe. Avendo superclasse e
classe derivata la stessa interfaccia, per differenziare le due vengono utilizzati due
procedimenti: aggiungere metodi alla classe derivata o ridefinirne l’implementazione.

public class Console{


// costruttori, attributi e metodi della classe Console
}

Si può osservare come l’ereditarietà avviene tramite la parola chiave extends.

public class Nintendo extends Console{


// costruttori, attributi e metodi della classe derivata Nintendo
}

public class Microsoft extends Console{


// costruttori, attributi e metodi della classe derivata Microsoft
}
public class Sony extends Console{
// costruttori, attributi e metodi della classe derivata Sony
}

E’ opportuno notare come Java una classe può ereditare al massimo da una sola
classe.
Il polimorfismo

Un elemento fondamentale della programmazione ad oggetti è il polimorfismo.


Letteralmente, la parola polimorfismo indica la possibilità per uno stesso oggetto di
assumere più forme, rendendoli di fatto intercambiabili. Esso indica l'attitudine di un
oggetto a mostrare più implementazioni per una singola funzionalità.

Tale comportamento è possibile grazie all’utilizzo del late binding (collegamento


dinamico) che evita chiamate assolute per il codice da eseguire, calcolando l’indirizzo
del corpo del metodo utilizzando informazioni immagazzinate nell’oggetto in causa. Di
conseguenza ciascun oggetto può comportarsi diversamente a seconda del contenuto
di una particolare porzione del sorgente.

Per rendere l’idea del concetto è utile vedere il codice, rifacendoci alle classi sopra
descritte.
Il metodo gioca utilizza come parametro un oggetto c di tipo Console.

public void gioca(Console c){


c.avviaDisco;
C.caricaMissione;
c.avviaMissione;
}

Con il polimorfismo possiamo utilizzare questo metodo per classi derivate diverse tra
loro senza implementazioni aggiuntive.

Console wii = new Nintendo();


Console xbox360 = new Microsoft();
Console ps3 = new Sony();

gioca(wii);
gioca(xbox360);
gioca(ps3);

Il metodo visto di trattare un tipo derivate come se fosse un tipo base è detto
upcasting (conversione di tipo verso l’alto).

Uno dei maggiori benefici del polimorfismo, come in effetti di un po' tutti gli altri
principi della programmazione ad oggetti, è la facilità di manutenzione del codice.
Classi e metodi astratti

Durante la programmazione è possibile incomba la necessità di utilizzare una classe


base dotata di sola interfaccia per le classi ad esse derivate, in modo che sia
impossibile creare un oggetto della superclasse in questione. Questo risultato si
ottiene attraverso la definizione di una classe astratta tramite la keyword abstract. Non
è possibile creare oggetti della classe astratta. Il meccanismo in questione è
funzionale anche per i metodi, in modo da posticipare l’implementazione nella classe
derivata stessa, ammesso e non concesso che essa realmente abbia significato in quel
punto della gerarchia.

Qui di seguito viene implementata in maniera differente la classe Console utilizza


precedentemente come esempio.

public abstract class Console{

public abstract void configura();

Il corpo del metodo configura vien rimandato alle classi derivate e può essere scritto in
maniera differente l’un dall’altro.

public class Nintendo extends Console{

public void configura(){


// configurazione specifica dell’oggetto Nintendo
}

public class Microsoft extends Console{

public void configura(){


// configurazione specifica dell’oggetto Microsoft
}

public class Sony extends Console{


public void configura(){
// configurazione specifica dell’oggetto Sony
}

I metodi astratti sono utilizzabili solo se all’interno di classi astratte.


Le interfacce

In Java non esiste come in altri linguaggi (ad esempio il C++) il concetto di ereditarietà
multipla secondo il quale una classe può essere una estensione di più superclassi: una
classe può essere la specializzazione di una sola classe. Questo rende il linguaggio più
semplice da usare, ma più restrittivo nella implementazioni di modelli che utilizzano
l’ereditarietà multipla.

Java risolve il problema con l’introduzione delle interfacce.


Un’interfaccia è una collezione di definizioni di metodi privi di implementazione, che
non possono contenere dichiarazioni di variabili istanza (variabili istanza: variabili
proprie di ogni oggetto e non comuni a tutti gli oggetti della classe); una qualunque
classe può implementare una o più interfacce.

Le interfacce non fanno parte della gerarchia delle normali classi: al vertice della
gerarchia delle interfacce non c’è la classe Object.

Per dichiarare una nuova interfaccia si usa la parola chiave interface.

public interface InizializzaConsole {

public static final int maxGiocatori = 4;


public abstract void configura();
void aggiorna();

I metodi possono essere dichiarati public e abstract e non possono essere protected o
private.

La parola chiave abstract è un rafforzativo perché i metodi delle interfacce sono


sempre astratti, anche se si omette la parola chiave.

Se non si specificano i modificatori come nel caso di aggiorna(), il metodo acquista la


stessa visibilità della classe (in questo caso public).

Un iterfaccia può essere definita come estensione di un’altra interfaccia tramite la


parola chiave extends.

public interface InizializzaConsole extends AltraInterfaccia {

public static final int maxGiocatori = 4;


public abstract void configura();
void aggiorna();
}

La gerarchia delle interfacce gode dell’ereditarietà multipla.

public interface InizializzaConsole extends AltraInterfaccia1,


AltraInterfaccia2, AltraInterfaccia3 {

public static final int maxGiocatori = 4;


public abstract void configura();
void aggiorna();

InizializzaConsole contiene tutte le definizioni di metodi e di costanti delle altre tre


interfacce che estende.

Con la parola chiave implements una classe si impegna a implementare i metodi definiti
nell’interfaccia.
Se una classe implementa un’interfaccia le sue sottoclassi ne ereditano i metodi.
Una classe può implementare un numero qualsiasi di interfacce.

public class Nintendo extends Console implements InizializzAConsole{


// costruttori, attributi e metodi della classe Nintendo
}

Se due interfacce hanno lo stesso metodo con lo stesso profilo basta implementarne
uno.
Se i metodi con lo stesso nome hanno diverso profilo vanno implementati entrambi.
Se i metodi con lo stesso nome hanno lo stesso profilo, ma restituiscono tipi diversi il
compilatore segnala un errore.

È possibile utilizzare le interfacce per dichiarare istanze di classi; in altre parole è


possibile dichiarare una variabile di tipo interfaccia e assegnarle istanze di classi che
implementano l’interfaccia.

InizializzaConsole init = new Nintendo();

Poiché init è un oggetto di tipo InizializzaConsole è possibile invocare su di esso i


metodi configura() ed aggiorna() descritti sopra.

Le interfacce possono anche essere utilizzate per raccogliere un insieme di costanti da


importare in varie classi
La gestione delle eccezioni

Con gestione delle eccezioni si suole intendere un meccanismo di gestione degli errori
integrando metodi di controllo direttamente nel linguaggio di programmazione; il Java
si rifà esattamente a questo modello.

Il programmatore definisce dei blocchi di codice (detti handler) per la gestione delle
eccezioni dei tipi interessati. Nel caso in cui venga rilevata una condizione anomala, il
metodo costruisce un ‘eccezione della classe che rappresenta e la “lancia”, per far sì
che i meccanismi preposti alla “cattura” possano gestirla.

Il lancio di un eccezione trasferisce il controllo dell’esecuzione all’handler attivo


specifico per quella data eccezione; nel caso in cui nessun handler venga trovato,
l’esecuzione del programma termina con un errore.

Il meccanismo delle eccezioni presenta considerevoli vantaggi: il programmatore è in


grado di decidere quali situazioni gestire ed in quale modo; le istruzioni delle eccezioni
sono separate da quelle che si occupano del flusso normale del programma, evitando
complessità di scrittura del sorgente; è possibile incorrere in un errore senza per forza
di cose terminare l’esecuzione del software.

In Java esiste è possibile verificare a tempo di compilazione che il programmatore,


tramite un messaggio a video, abbia gestito tutte le eccezioni appartenenti ad un dato
tipo, a meno che non abbia esplicitamente dichiarato di non voler occuparsi della
cosa. Le eccezioni che rientrano in questa specifica vengono denominate eccezioni
controllate (checked exceptions); esse sono determinate di tale specie nella classe
dell’eccezione stessa attraverso la keywork extends Exception.

public class EsempioException extends Exception {


// serial UID di default o generato, eventuale metodo di notifica
}

Un metodo che può lanciare un’eccezione controllata deve dichiararlo esplicitamente


nella sua intestazione e può essere richiamato solo all’interno del blocco di codice
associato ad un handler per quell’eccezione od all’interno di un altro metodo che
dichiara di poter lanciare quell’eccezione. Di conseguenza, quando viene scritto un
metodo contenente istruzioni in grado di lanciare un’eccezione controllata è costretto
a scegliere due alternative: inserire le istruzioni all’interno di un handler; dichiarare
che tal metodo può lanciare l’eccezione indi trasferire la responsabilità di gestione al
chiamante.
Nella definizione di un metodo le eccezioni controllate lanciabili vanno dichiarate dopo
l’elenco dei parametri tramite la keyword throws.
public void metodoConEccezione(int parametroEsempio) throws
EsempioException {
// implementazione del metodo
}

EsempioException è una classe di eccezione. E’ possibile dichiarare più classi di


eccezioni per un metodo.

E’ utile notare che se un metodo dichiara di poter lanciare eccezioni di una


superclasse, può lanciare anche eccezioni delle relative classi derivate.
Un handler viene definito con una sintassi ben precisa.

try{
// istruzioni che lanciano eccezioni
}catch(Esempio1Exception e){
// istruzioni associate all'eccezione di tipo Esempio1Exception
}catch(Esempio2Exception e){
// istruzioni associate all'eccezione di tipo Esempio2Exception
}finally{
// istruzioni "finali"
}

Le istruzioni del blocco che segue try vengono eseguite sotto il controllo dell’handler;
nel suo interno va inserito codice grado di lanciare un eccezione.
Le istruzioni di un blocco catch sono eseguite quando nell’esecuzione del blocco try
viene lanciata un eccezione del tipo corrispondente (o di una sua classe derivata) non
gestita da un handler più interno; la corrispondente variabile è associata all’oggetto
che è stato lanciato.
Le istruzioni del blocco finally vengono sempre eseguite, alla fine del blocco try o dei
blocchi catch eventualmente lanciati; esso è usato tipicamente per rilascio di risorse
od operazioni di ripristino.

Per lanciare un’eccezione, il programmatore deve creare un oggetto della classe che
rappresenta quella determinata eccezione, usando la seguente sintassi:

throw new EsempioException();

Bisogna tener conto che ad un eccezione possono essere associati anche dei
parametri, a differenza dell’esempio riportato.

La classe Throwable in Java descrive tutto ciò che può essere generato come un
eccezione.
In generale esistono due oggetti di tipo Throwable, ossia due gerarchie di ereditarietà:
Error, che rappresenta gli errori in fase di compilazione; Exception, il tipo base che può
essere generato da qualsiasi metodo delle classi della libreria standard e che
rappresenta le eccezioni controllate.
Esiste, inoltre, un’ulteriore classe di eccezioni denominata RuntimeException e che
rappresenta le eccezioni non controllate (unchecked excepetion), ossia gli errori che
possono verificarsi a tempo di esecuzione.

Per ricapitolare, le eccezioni vengono utilizzate per: gestire i problemi nel punto
adeguato, risolverli e chiamare nuovamente il metodo che ha generato l’eccezione;
risolvere il problema e continuare senza richiamare il metodo generatore; calcolare
risultati alternativi a quelli previsti; terminare il programma; semplificare il codice e la
sua manutenzione; rendere il software più sicuro.
I contenitori

Le classi Contenitors sono fra gli strumenti più potenti per lo sviluppo, ed assumono il
compito di “collezionare” gli oggetti.

La libreria dei contenitori si suddivide in due concetti distinti.

Vi sono le Collection, ossia raccolte di più elementi di un determinato tipo (ossia di


oggetti), spesso sottostanti a qualche regole; esse, a loro volta, possono essere o List
o Set.
List deve contenere gli elementi seguendo un criterio preciso di ordinamento lineare, e
può ammettere duplicati, quest’ultima particolarità non ammessa con i Set; la
struttura di dati associata al tipo List è l’ArrayList, quella associata al tipo Set è
l’HashSet. Esiste, inoltre, un’ulteriore tipologia di Set, denominata SortedSet in cui è
garantito l’ordinamento degli elementi; l’unica implementazione possibile è la TreeSet.

La categoria Collection contiene soltanto un elemento in ciascuna posizione.

L’altro concetto che contraddistingue i contenitori riguarda le Map.


Esse sono un gruppo di coppie di oggetti chiave-valore; ogni chiave è univocamente
associata ad un valore.
E’ utile osservare come una Map può restituire un Set delle proprie chiavi, una
Collection dei propri valori od un Set delle proprie coppie. Le strutture dati associate
ad una mappa sono la TreeMap e la HashMap. Nel caso sia importante anche
l’ordinamento è possibile ricorrere alla LinkedHashMap.

La LinkedHashMap utilizza i codici hash su ogni oggetto per velocizzare al massimo


l’operazione di ricerca. Durante lo scorrimento restituisce le coppie a seconda
dell’ordine con la quale essi sono stati inseriti all’interno di essa. Una proprietà
interessante è anche la possibilità di adottare per tale struttura di dati un algoritmo
LRU (last recently used) che restituisce gli elementi secondo il loro ordine di utilizzo.

La scelta di una struttura di dati anziché l’altra è dettata dalla complessità


computazionale associata e dall’utilizzo specifico che di esso si vuol fare E’ noto come
le tabelle hash abbiano un’ottima efficienza per le operazione di inserimento, ricerca
ed estrazione, mentre difettano nella stampa ordinata. Gli alberi, al contempo, sono
più lenti ma consentono di mantenere un ordine ben definito degli elementi inseriti (il
che implica efficienza nella stampa ordinata).

Qui di seguito viene riportato un esempio di implementazione di un contenitore, nello


specifico un Set.

Set setEsempio;
setEsempio = new HashSet();
In tal modo è stato salvaguardato il polimorfismo, ma ciò nonostante è possibile creare
un Set utilizzando direttamente il costruttore di HashSet.

Per le caratteristiche delle interfacce, dei costruttori e metodi è essenziale consultare


la documentazione del linguaggio Java (Javadoc).

Lo svantaggio principale dell’utilizzo dei contenitori è la perdita delle informazioni sul


tipo quando un oggetto viene inserito nel contenitore; difatti essi utilizzano riferimenti
ad Object, che è la radice di tutte le classi risultando di fatto un tipo “universale”.

Gli iteratori

Durante l’utilizzo dei contenitori risulta molto utile ricorrere ad oggetti denominati
Iterator, ossia iteratori.

Essi possono essere intesi come una sorta di “segnaposto” attraverso il quale scorrere
gli elementi immagazzinati.

Per chiedere ad un contenitore di passare un iteratore è necessario utilizzare il metodo


iterator(); tale iterator sarà in grado di restituire il primo elemento della sequenza alla
prima chiamata del suo metodo next().

Le operazioni possibili sono: l’acquisizione dell’oggetto successivo nella sequenza


tramite next(); verificare se esistono altri elementi nella sequenza con hasNext();
eliminare l’ultimo elemento restituito dall’iteratore con remove().

La vera potenza degli iteratori va ricercata nel fatto che essi danno la possibilità do
separare l’operazione di scorrimento di una sequenza dalla sottostanza a tale
sequenza.
La gestione dell’input/output

Le librerie di I/O utilizzano il concetto astratto di flusso, in inglese Stream, che


rappresenta una qualsiasi origine di dati. Il flusso nasconde i dettagli relativi alle
operazioni sui dati all’interno della reale periferica di lettura o scrittura.
Le classi delle librerie Java Il package Java.io è stato strutturato sulla base di due
classi, una per l'input, InputStream, ed una per l'output, OutputStream, che
presentano rispettivamente dei metodi per la lettura (read) e dei metodi per la
scrittura (write). In particolare esiste il metodo per la lettura e la scrittura di un singolo
byte che è astratto.
Per InputStream:

public abstract int read();

Per OutputStream:

public abstract void write(int variabile);

Sull'estensione di queste classi astratte vengono costruite le classi concrete,


relative allo specifico canale che si vuole utilizzare: ci sarà così un
FileInputStream ed un FileOutputStream, per la lettura e scrittura di file. Tra le
altre cose, per poter accedere alla lettura del disco fisso, tali classi devono fare
delle chiamate al sistema operativo.
Il compito di InputStream è quello di rappresentare classi che incapsulano gli
input generati da diverse sorgenti od origini di dati. Tali sorgenti possono
essere: un array di byte; un oggetto di tipo String; un file (quindi un oggetto di
tipo File); una pipe; dati vari, quali ad esempio quelli di connessione.
L’utilizzo di stati di oggetti per aggiungere responsabilità a singoli oggetti di
base in maniera dinamica rappresenta un pattern di programmazione
denominato Decorator; con esso è previsto che tutti gli oggetti che si intende
disporre abbiano la stessa interfaccia, in modo da poter inviare ad essi lo
stesso messaggio. Le classi filtro della libreria Java.io, ossia FilterInputStream e
FilterOutputStream, si ispirano proprio a questo pattern.
Le classi filtro eseguono sue operazioni fondamentalmente diverse. La classe
DataInputStream consente di leggere diversi tipi di dati primitivi per mezzo di metodi
di lettura specifici per byte, int, float a via discorrendo. Le restanti classi modificano,
invece, il comportamento interno di un InputStream, utilizzando, ad esempio, dei
buffer di lettura o tenera traccia dei caratteri letti.

La classe complementare alla DataInputStream è la DataOutputStream, che formatta


ciascuno dei tipi di dato primitivo in un flusso in modo che essi possano venir letti
DataInputStream. A questa classe è correlata la PrintStream, che si occupa della
scrittura dei dati.

Con l’avvento della versione 1.1 del linguaggio Java sono state introdotte le classi
Reader e Writer che, di fatto, per alcune operazioni vanno a sostituire le classi in
precedenza analizzate; quest’ultime, comunque, mantengono intatta la loro efficienza.

Qui di seguito riporto blocchi di codice nel quale vengono utilizzate le classi di cui
discusso.

ObjectInputStream streamLettura = new ObjectInputStream(new


BufferedInputStream(new FileInputStream(fileDaAprire)));
Oggetto oggettoEsempio = (Oggetto) streamLettura.readObject();

ObjectOutputStream streamScrittura = new ObjectOutputStream(new


BufferedOutputStream(new FileOutputStream(fileDaAprire)));
streamScrittura.writeObject(oggettoEsempio);

In relazione alla gestione dell’I/O, risulta molto importante il concetto di


serializzazione.

La serializzazione degli oggetti in Java consente di prendere un qualsiasi oggetto che


implementa l’interfaccia Serializable (implements Serializable) e di convertirlo in una
sequenza di byte che, in un secondo momento, potranno essere ripristinati per
costituire nuovamente l’oggetto originale.

Per serializzare un oggetto è bisogna utilizzare un oggetto di tipo ObjectOutputStream


costruito a partire da un oggetto OutputStream. Fatto ciò, è necessario richiamare il
metodo writeObject() è l’oggetto in questione verrà serializzato e scritto sull’oggetto
OutputStream.

public void scriviSuFile (FileOutputStream file, Oggetto oggettoEsempio)


throws IOException{
ObjectOutputStream out = new ObjectOutputStream(file);
out.writeObject(oggettoEsempio);
}

Analogamente alla fase di scrittura, per la lettura viene utilizzata la classe


ObjectInputStream, costruita a partire da InputStream, ed il metodo readObject(), sul
quale effettuare un’opportuna operazione di cast.
public Oggetto leggiDaFile(FileInputStream file) throws IOException,
ClassNotFoundException{
Oggetto oggettoEsempio = new Oggetto();
ObjectInputStream in = new ObjectInputStream (file);
oggettoEsempio = (Oggetto)in.readObject();
return oggettoEsempio;
}
La concorrenza con i thread

L’esecuzione di un programma comporta l’esecuzione delle sue istruzioni secondo una


sequenza detta sequenza dinamica o flusso di esecuzione (in inglese thread of
execution), possibilmente diversa dalla sequenza statica con cui le istruzioni in
questione sono state scritte. Tradizionalmente, per ogni esecuzione del programma,
c’è un flusso di esecuzione; in sostanza, le istruzioni vengono eseguite in maniera
strettamente sequenziale.
Nei sistemi operativi moderni, d’altronde, è possibile attivare più flussi che, durante
l’esecuzione, procedono in parallelo. Questa caratteristica è la chiave di volta dei
linguaggi denominati multi-threaded.

Nei calcolatori dotati di più processori o cpu multi-core, ogni processore esegue
istruzioni di un diverso thread.
Nelle macchine single-core il processore lavora su più thread, con una tempistica
stabilita dal SO.
In tal caso il parallelismo non è reale, bensì simulato e si parla di esecuzione
concorrente.

Uno dei grandi vantaggi della programmazione multi-threaded sta in un forte


incremento della reattività delle interfacce utente, più in generale dei servizi messi a
disposizione da un software, non più vincolati alla terminazione di un’altra operazione
precedentemente avviata.

I thread che fanno parte della stessa esecuzione condividono le risorse in gioco. Dal
momento che il sistema usa uno stack dei record di attivazione per gestire la
sequenza dinamica, ogni thread ha il proprio stack; con ciò si salvaguarda la
saturazione della memoria nel caso di un elevato numero di concorrenze.
Per attivare un thread in Java occorre creare un oggetto della classe Thread a cui
devono essere associate operazioni da eseguire. Per attivare l’esecuzione del nuovo
thread è necessario ricorrere al metodo start() dell’oggetto di tipo Thread; per fermarla
è possibile utilizzare il metodo stop().
Per effettuare quest’associazione esistono due metodi: creare una classe derivata da
Thread o creare una classe che implementa l’interfaccia Runnable, passando un
oggetto di questa classe al costruttore di Thread.

Nel primo caso, la classe derivata deve ridefinire il metodo public void run() della
classe Thread; il codice del metodo verrà eseguito nel nuovo thread.

Viene dapprima scritta la classe del thread che si desidera creare.

public class NuovoThread extends Thread {

public void run(){


// istruzioni da eseguire all’avvio di un oggetto NuovoThread
}

Ora verrà osservata l’effettiva implementazione di due thread in parallelo all’interno di


un metodo main (che lancia l’esecuzione).

public class TestNuovoThread {

public static void main(String[] args){


Thread t = new NuovoThread();

/*
* viene avviato il thread t contenente le istuzioni
* implementate nel metodo run dell'oggetto NuovoThread
*/
t.start();

/*
* in simultanea al thread t del tipo NuovoThread
* possono essere definite istruzioni eseguite
* nel thread del main
*/
}

}
Nel secondo caso, la classe deve implementare l’unico metodo definito in Runnable,
che ha lo stesso prototipo precedente: public void run().

Analogamente a prima:

public class NuovoThread implements Runnable{

public void run(){


// istruzioni da eseguire all’avvio di un oggetto NuovoThread
}
}

Mentre nel main:

public class TestNuovoThread {

public static void main(String[] args){


NuovoThread oggettoNuovoThread = new NuovoThread();
Thread t = new Thread(oggettoNuovoThread);

/*
* viene avviato il thread t contenente le istuzioni
* implementate nel metodo run dell'oggetto NuovoThread
*/
t.start();

/*
* in simultanea al thread t del tipo NuovoThread
* possono essere definite istruzioni eseguite
* nel thread del main
*/
}

Generalmente si preferisce utilizzare quest’ultima soluzione in quanto: la classe che


implementa Runnable può derivare da un’altra classe già definita nell’applicazione; la
classe che implementa Runnable può definire altri metodi senza rischio di entrare in
conflitto con i metodi già definiti con la classe Thread.

L’esecuzione di un programma multi-threaded normalmente termina quando tutti i


thread hanno completato l’esecuzione delle operazioni richieste. Esiste, tuttavia, la
possibilità di definire thread di servizio (deamon) la cui funzione è subordinata a quella
degli altri thread, per cui il programma ha fine nonostante ci siano deamon attivi, in
soldoni quando tutti i thread non di servizio hanno terminato l’esecuzione.
Un thread è definito come deamon richiamando il metodo public void
setDeamon(true); il metodo deve essere chiamato prima di start().
E’possibile forzare la terminazione di tutti i thread di un applicazione tramite il metodo
public static void exit(int status).
I thread di uno stesso programma possono accedere simultaneamente alle risorse
globali del programma. Da tale caratteristica nasce il problema della sincronizzazione
che serve ad evitare interferenze tra i thread che possono effettuare modifiche in
simultanea ad un'altra concorrenza, generando risultati non corretti.
Per assicurare la mutua esclusione (ossia che nessun thread abbia accesso ad una
risorsa in simultanea con un altro) il sistema operativo mette a disposizione uno
strumento chiamato mutex (mutual exclusion).

Un mutex è un indicatore associato ad una risorsa condivisa. Prima di utilizzare la


risorsa un thread deve acquisire il mutex, segnalando l’utilizzo di quella data risorsa;
una volta effettuata l’operazione richiesta il thread deve rilasciare il mutex,
segnalando che la risorsa condivisa non è più in uso.
Se un thread cerca di acquisire un mutex già acquisito da un altro, il sistema operativo
sospende l’esecuzione sin quando il mutex in questione non viene rilasciato,
garantendo la mutua esclusione.
Il meccanismo descritto funziona, però, solo se tutti i thread cercano di acquisire il
mutex prima di usare la risorsa condivisa; è responsabilità del programmatore
assicurarsi della buona implementazione d’esso.

Se i thread devono accedere contemporaneamente a più di una risorsa condivisa c’è il


rischio che venga creata una situazione di deadlock, in cui ciascun thread ha acquisito
parte delle risorse e viene sospeso in attesa che le altre vengano rilasciate; siccome
gli altri thread risultano tuttavia sospesi, nessuno può rilasciare risorse, generando
una fase di stallo. E’ anche in questo caso responsabilità del programmatore evitare il
verificarsi di deadlock, rispettando un ordine di acquisizione/rilascio mutex identico
per ogni thread.

Ogni oggetto, di qualunque classe, contiene un mutex.


L’uso del mutex di un oggetto di effettua con l’istruzione synchronized secondo la
sintassi:

synchronized (this) {
// istruzioni contenenti risorse comuni
}

Per semplificare la sintassi è possibile aggiungere il qualificatore synchronized


nell’intestazione di un metodo.

public synchronized void metodo() {


// istruzioni contenenti risorse comuni
}
In molti casi un thread deve aspettare il verificarsi di un evento esterno per continuare
la sua elaborazione, come, ad esempio: aspettare per un lasso di tempo prefissato;
attendere che un altro thread abbia terminato la sua esecuzione; attendere che una
struttura di dati condivisa si trovi in un particolare stato.
Il metodo più facile da pensare per realizzare ciò è la cosiddetta attesa attiva,
implementabile tramite un ciclo while ed una condizione. Con questo accorgimento il
thread in attesa si impegna a controllare continuamente il verificarsi della condizione
di sblocco.

E’ evidente come l’attesa attiva comporti un considerevole spreco di risorse di


sistema.
Il metodo di attesa più semplice è quello di sospendere il thread per un intervallo di
tempo stabilito a priori, il tutto attraverso il metodo sleep della classe Thread.

try {
// il thread viene “addormentato”
Thread.sleep(30000);

} catch (InterruptedException e) {
// attesa terminate attraverso un’eccezione
}

Il metodo sleep() è static, quindi si richiama usando direttamente il nome della classe
Thread; esso sospende il thread corrente per un tempo espresso in millisecondi
(msec).

Un thread può mettersi in attesa della terminazione di un altro thread usando uno sei
seguenti metodi della classe Thread: join(), che sospende il thread corrente fino a
quando non termina il thread destinatario del metodo; join(long msec), nel quale viene
specificato anche un tempo di attesa massimo.
Il metodo join va applicato al thread di cui vogliamo aspettare la terminazione.

try {
// viene stabilita l’attesa del thread t
t.join();

} catch (InterruptedException e) { }

// istruzioni da eseguire quando il t termina

Anche il tal caso è necessario un blocco composto da try e catch per gestire
l’eccezione di interruzione.

Nel caso in cui si vuol gestire l’attesa che una struttura di dati si trovi in una situazione
particolare, l’evento che fa risvegliare un thread non è prefissato ma dipende
dall’applicazione; è necessario, indi, un meccanismo più generale. Per realizzare
questo tipo di attesa Java mette a disposizione i metodi wait() e notifyAll(); il primo dei
due mette un thread in attesa che si sia un cambiamento nell’oggetto. Il secondo
avvisa tutti i thread in attesa che è avvenuto un cambiamento.
Il thread che richiama il wait deve aver acquisito il mutex dell’oggetto a cui viene
applicato il metodo. Il mutex viene rilasciato automaticamente ed il thread viene
sospeso fino a quando un altro thread non richiama notifyAll sullo stesso oggeto. Al
suo risveglio il thread riacquisisce il mutex sullo stesso oggetto.
L’acquisizione del mutex è necessaria per consentire al thread di esaminare lo stato di
una struttura di dati condivisa; il rilascio automatico del mutex, che riguarda solo
quello dell’oggetto a cui è applicato il metodo, consente agli altri thread di modificare
la risorsa condivisa. Il thread che richiama wait utilizza, tipicamente, un ciclo di
controllo.
Per quanto riguarda il metodo notifyAll, il thread che lo richiama deve aver acquisito il
mutex.
Tutti i thread in attesa vengono risvegliati, ma vengono eseguiti uno alla volta.

In una classe ben progettata, il codice che gestisce l’attesa della condizione è
incapsulato nei metodi dell’oggetto che contiene la struttura di dati.
L’interfaccia grafica

Un aspetto importante dei linguaggi di programmazione è la possibilità di creare GUI


(graphical user interface).

Java permette la creazione di applet, piccoli programmi che girano all’interno di un


browser web.

Essendo queste applicazione orientate alla rete, è necessario un forte vincolo al


programmatore in quanto è di primaria importanza la salvaguardia della sicurezza. Gli
svantaggi certamente notevoli sono: l’impossibilità di accedere al disco fisso; la
lentezza di un applet nella visualizzazione.
E’ vantaggioso, invece, il fatto che un applet non necessita di installazione e che il
software non è in grado di procurare danni al sistema sul quale gira.

I metodi principali messi a disposizione per la gestione degli applet sono init(), start(),
stop() e destroy().

Init viene chiamato automaticamente per eseguire la prima inizializzazione dell’applet,


compreso il layout dei componenti implementabili nell’interfaccia. Questo metodo sarà
sempre ridefinito.
Start viene chiamato ogni volta che l’applet entra nel campo del browser e serve per
avviare le sue normali operazioni.
Stop viene chiamato ogni qual volta l’applet esce dal campo del browser per
consentire all’applet di terminare le operazioni.
Destroy viene chiamato quando l’applet viene scaricato dalla pagina per eseguire il
rilascio definitivo di tutte le risorse del browser.

Creata un interfaccia grafica è assolutamente necessario associare ai componenti


(pulsanti, aree di testo, menu e via discorrendo) e fare in modo che essi siano in grado
di “catturarli”. Per consentire al programmatore quest’operazione il linguaggio Java
mette a disposizione il metodo addActionListener() che implementa l’interfaccia
ActionListener, la quale contiene come unico metodo denominato ActionPerformed();
quest’ultimo è la chiave della gestione degli eventi in quanto, al suo interno, vanno
inserite le istruzione che si desidera far compiere dallo specifico componente che lo
implementa.

Oltre agli applet è possibile utilizzare anche delle finestre grafiche chiamate Frame (il
cui significato, dall’inglese, è cornice).

Вам также может понравиться