Programmazione Ad Oggetti In Java

  • Uploaded by: Vincenzo De Notaris
  • 0
  • 0
  • December 2019
  • PDF

This document was uploaded by user and they confirmed that they have the permission to share it. If you are author or own the copyright of this book, please report to us by using this DMCA report form. Report DMCA


Overview

Download & View Programmazione Ad Oggetti In Java as PDF for free.

More details

  • Words: 6,602
  • Pages: 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).

Related Documents

Programmazione B2
June 2020 14
Programmazione Scolastica
August 2019 16
Programmazione B1
June 2020 16
Programmazione A2
June 2020 14
Programmazione A1
June 2020 10

More Documents from ""