Ciclo di vita del Software
Lo sviluppo di un software è un processo molto complesso: per semplicità lo si divide in fasi distinte; ciascuna fase prende il risultato della fase precedente, lo elabora e produce un deliverable che viene passato alla fase successiva.
Nello schema seguente viene mostrato il funzionamento del modello a cascata.
flowchart TD
id1[Studio di fattibilità]
id2[Analisi dei requisiti]
id3[Design]
id4[Implementazione e test unitari]
id5[Integrazione e test di sistema]
id6[Installazione]
id7[Manutenzione]
id1-->id2-->id3-->id4-->id5-->id6-->id7
Le varie fasi sono in generale abbastanza autoesplicative, vale la pena però spendere comunque due parole per specificare alcuni dettagli.
Le prime fasi servono per comprendere bene il dominio del progetto per poi produrre il documento di specifica dei requisiti che verrà poi tradotto, nelle fasi successive, in moduli software che fanno quanto richiesto.
La parte di testing è particolarmente delicata in quanto deve verificare che il prodotto finito sia conforme per filo e per segno a quanto richiesto dal committente. In futuro questo documento verrà aggiornato con ulteriori informazioni sulla fase di collaudo.
Negli anni si è scoperto che la principale criticità del modello a cascata è il fatto che se si rileva un difetto (sia esso un errore nelle specifiche o un cambiamento di piani), bisogna tornare indietro alle prime fasi, aggiustare quanto necessario per poi proseguire.
E’ evidente come questo possa portare a notevoli ritardi (con conseguente dispendio di soldi ed energie).
E’ per sopperire a tale criticità che si è diffusa la metodologia Agile (che comprende, tra le altre, le metodologie SCRUM, eXtreme Programming e DevOps).
Ulteriori informazioni riguardo i principi della metodologia Agile si possono trovare consultando il manifesto della metodologia Agile.
Chi segue la metodologia SCRUM suddivide il lavoro in sprint lunghi e sprint giornalieri: all’inizio di ogni sprint vi è una riunione tra i vari partecipanti al progetto che si confrontano sui progressi e sulle criticità rilevate e decidono le attività per lo sprint successivo.
Ciascuno sprint contiene una fase di design, una fase di implementazione ed un fase di collaudo.
La filosofia dietro Agile è quella di voler anticipare il cambiamento ed i problemi, non assumendo che tutto ciò che è stato fatto sia perfetto: dato che i vari sprint sono abbastanza brevi, se risulta necessario apportare cambiamenti a quanto già prodotto, il tempo necessario è di gran lunga inferiore rispetto al modello a cascata.
E’ stato dimostrato sperimentalmente che chi utilizza il modello a cascata ha una probabilità di fallire nel progetto molto più alta rispetto a chi adopera metodologie Agile.
Java
Nota: per procedere è fortemente consigliata almeno un’infarinatura sui concetti base di programmazione quali “funzione”, “variabile” e simili.
Java è un linguaggio di programmazione ad oggetti, onnipresente da decenni nei posti più disparati.
Nel mondo videoludico, l’esempio probabilmente più famoso di gioco scritto in Java è Minecraft ma anche molti dei giochi per i vecchi telefoni precedenti agli smartphone (il nome Gameloft non suona familiare?) sono stati scritti in Java. La piattaforma Android, pur non utilizzando la JVM (maggiori dettagli in seguito), viene programmata utilizzando prevalentemente linguaggio Java o derivati. La stragrande maggioranza delle smart card (tra cui anche bancomat e sim) implementa Java Card e, a partire da circa il 2008, la maggioranza dei lettori Blue Ray supporta BlueRay Disk Java per offrire contenuti interattivi all’utente.
Se ancora la motivazione ad imparare Java non fosse sufficiente, si prenda atto del fatto che, alla fine di questa sezione, sarà possibile comprendere quasi completamente questo magnifico capolavoro.
E’ stato accennato al fatto che Java è un linguaggio ad oggetti: ciò significa che la logica del programma è costruita attorno alla manipolazione dello stato degli oggetti. Gli esempi chiariranno questa definizione.
Un programma in Java non viene compilato direttamente nel linguaggio macchina nativo della macchina su cui gira il compilatore ma in java bytecode (un linguaggio intermedio indipendente dall’architettura della macchina host) che poi viene interpretato dalla JVM (Java Virtual Machine).
Questo rende possibile l’esecuzione di programmi scritti in java su qualsiasi architettura, a patto che su tale architettura sia stato eseguito il porting della JVM (i più coraggiosi possono trovare ulteriori informazioni qui e qui).
Programmazione ad oggetti
La programmazione ad oggetti si basa, appunto, su oggetti. Un oggetto è descritto dal suo stato e dai suoi metodi, ciascun metodo può modificare lo stato dell’oggetto od estrarre da esso informazioni.
Un oggetto (o classe) può essere istanziato molteplici volte, ottenendo molteplici istanze dello stesso oggetto, ciascuna con il proprio stato.
Nell’esempio successivo viene mostrato come definire una classe e come crearne un’istanza.
public class Program {
public static void main(String[] args) {
= new Square(3.0);
Square s
System.out.println(s.getSide()) // 3.0
.setSide(4.0);
sSystem.out.println(s.getArea()) // 16.0
}
}
class Square {
// Lo stato del quadrato è composto da una sola variabile di tipo double
private double side;
// Questo è il costruttore delle istanze di `Square`
public Square(double side) {
// `this` è una reference all'istanza sul quale è chiamato il metodo
// Si usa il punto ('.') per accedere a variabili e metodi di un oggetto
// In caso di omonimia tra variabili, si prende la variabile più "interna"
this.side = side;
}
// Questo metodo va a modificare lo stato del quadrato
public void setSide(double side) {
this.side = side;
}
// Questo metodo è detto "observer" in quanto estrare delle informazioni dallo stato del quadrato (in questo caso, il lato) e le restituisce al chiamante
public double getSide() {
return this.side;
}
// questo è anche un observer ma non restituisce lo stato in sè per se, bensì un'informazione derivata
public double getArea() {
return this.side * this.side;
}
}
Tranne casi speciali, in Java, il metodo main
deve
essere contenuta in una public class
con lo stesso nome del
file in cui è contenuta. Il metodo main
prende come
parametro un array di stringhe (denotato come String[]
e,
solitamente, chiamato args
) che contiene tutti i parametri
passati da linea di comando.
Il vantaggio dell’utilizzo di una classe per memorizzare quadrati è
dato dal fatto che se, per qualche motivo, fosse necessario cambiarne
l’implementazione, fintanto che getSide()
continua a
restituire il lato e getArea()
continua a restituire
l’area, non è necessario andare a modificare tutte le chiamate a tali
metodi.
Tutti i metodi di una classe accessibili dall’esterno sono detti
interfaccia della classe (da non confondersi con le
interface
di Java che sono spiegate in questo paragrafo): l’interfaccia serve per
astrarre l’utilizzo di un oggetto dalla sua implementazione.
Nell’implementazione della classe Square
è presente un
costruttore che inizializza l’istanza (in questo caso inizializza il
valore di side
): se non è presente un costruttore, viene
aggiunto automaticamente un costruttore di default che non prende alcun
parametro e che inizializza tutti i campi dello stato coi propri valori
di default.
Visibilità
Una classe può contenere al suo interno variabili, metodi e definizioni di altre classi ed enumerazioni. Ciascuna di queste può assumere quattro gradi diversi di visibilità:
Visibilità | Spiegazione |
---|---|
private |
L’attributo è accessibile solo dall’interno della classe stessa. |
protected |
L’attributo è accessibile solo dall’interno della classe stessa e dai suoi eredi. |
<non specificato> |
L’attributo è accessibile ovunque ma solo all’interno dello stesso
package (che è un modo di organizzare varie parti del codice, si pensi
ai namespace in c++). |
public |
L’attributo è visibile ovunque. |
Per questioni di sicurezza e ordine nel codice, è fortemente consigliato utilizzare la visibilità più ristretta possibile (information hiding).
private
e protected
si riferiscono alla
classe, non alla singola istanza, ne consegue che un’istanza di un
oggetto può accedere agli attributi privati e protetti delle altre
istanze di quello stesso oggetto.
Se un costruttore è private
allora l’oggetto non può
essere istanziato dall’esterno con quel costruttore (si vedrà in seguito che un oggetto può avere più
costruttori): in questo caso altri costruttori della stessa classe, se
necessario, potranno utilizzare il costruttore privato per
l’inizializzazione dell’oggetto. Se un costruttore è
protected
allora può essere chiamato solo da classi
figlie (si vedranno in
seguito).
Su StackOverflow è possibile trovare un’ottima spiegazione riguardo gli utilizzi dei costruttori privati.
Variabili statiche
Una variabile è dichiarata static
se è relativa alla
classe (e quindi condivisa tra tutte le istanze) e non alla singola
istanza.
public class Program {
public static void main(String[] args) {
, b, c;
ContaIstanze a
= new ContaIstanze();
a System.out.println(a.getNumeroIstanza()) // 0
= new ContaIstanze();
b System.out.println(b.getNumeroIstanza() + " " + b.getProssimoNumeroIstanza()) // 1 2
= new ContaIstanze();
c System.out.println(a.getNumeroIstanza() + " " + b.getNumeroIstanza() + " " + c.getNumeroIstanza()) // 0 1 2
.out.println(a.getProssimoNumeroIstanza() + " " + b.getProssimoNumeroIstanza() + " " + c.getProssimoNumeroIstanza()) // 3 3 3
system}
}
class ContaIstanze {
private static int numero_seriale = 0;
private int numero_istanza
public ContaIstanze() {
this.numero_istanza = numero_seriale;
++;
numero_seriale}
public int getNumeroIstanza() {
return this.numero_istanza;
}
public static int getProssimoNumeroIstanza() {
return numero_seriale;
}
}
Logicamente, non è possibile accedere a variabili non statiche da contesti statici.
Aliasing
Quando si memorizza un’istanza di oggetto in Java, non si sta memorizzando l’oggetto in se per se ma un riferimento ad esso. Da ciò segue che nel seguente codice
Object o1 = new Object();
Object o2 = o1;
System.out.println(o1 == o2); // true
le variabili o1
e o2
non sono due copie
dello stesso oggetto ma sono proprio lo stesso oggetto: si può scegliere
di usare arbitrariamente una delle due variabili.
Ereditarietà
Una classe B
eredita da (oppure
estende) un’altra classe A
se viene
dichiarata come
class B extends A {...}
Questo vuol dire che qualunque istanza di B
è anche
istanza di A
(ma non viceversa), dunque ha accesso a tutti
i metodi e attributi non privati di A
. Di conseguenza lo
stato di B
è composto dallo stato di A
ed,
eventualmente, anche da altre variabili.
Nelle classi figlie si usa super
per riferirsi
alla classe padre.
Una classe può essere estesa da molteplici classi.
Il seguente codice definisce le classi Cane
e
Gatto
estendendo AnimaleDomestico
. Tale classe
è responsabile del conteggio dei pasti delle bestiole. Si vedrà come non
è necessario scrivere due volte la logica che gestisce il conteggio dei
pasti in quando sia Cane
che Gatto
la
erediteranno da AnimaleDomestico
.
public class Program {
public static void main(String[] args) {
// La seguente istruzione sarebbe errata in quanto il costruttore di `AnimaleDomestico` non è accessibile da qui
// AnimaleDomestico ad = new AnimaleDomestico("Gino");
= new Gatto("Pino");
Gatto pino
.mangia();
pino.mangia();
pinoSystem.out.println(pino.getNumeroPasti())
}
}
class AnimaleDomestico {
private String nome;
private int numero_pasti;
// Costruttore protected in modo da non poter istanziare un animale domestico generico
protected AnimaleDomestico(String nome) {
this.nome = nome;
this.numero_pasti = 0;
}
protected void mangia() {
this.numero_pasti++:
}
// Questi metidi sono dichiarati final per vietare la sovrascrittura da parte di classi figlie
public final String getNome() {
return nome;
}
public final int getNumeroPasti() {
return this.numero_pasti;
}
}
class Cane extends AnimaleDomestico {
public Cane(String nome) {
super(nome); // Inizializza il cane col costruttore di `AnimaleDomestico`
// Dopo aver inizializzato la parte 'AnimaleDomestico' del cane, è possibile compiere altre azioni
}
// Sovrascriviamo il comportamento di `mangia()` per la classe `Cane`
@Override
public void mangia() {
super.mangia(); // Chiama la logica di `mangia()` definita nel padre
System.out.println("Woff Woff");
}
}
// La classe `Gatto` si comporta esattamente come `Cane`
class Gatto extends AnimaleDomestico {
public Gatto(String nome) {
super(nome);
}
@Override
public void mangia() {
super.mangia();
System.out.println("Miao Miao");
}
}
Il vantaggio di poter estendere classi a piacimento è che è possibile creare uno scheletro generico che poi può essere specializzato secondo necessità.
Si supponga di aggiungere all’esempio precedente la seguente classe:
class Veterinario {
public void visita(AnimaleDomestico animale) {
// Il seguente blocco condizionale è un anti-pattern, ovvero implementa una logica che può essere implementata in maniera più estendibile, leggibile e/o efficente in altro modo (si vedrà più avanti) ed è quindi per puro scopo dimostrativo
if(animale instanceof Cane) {
System.out.println("Visito il cane...");
} else if(animale instanceof Gatto) {
System.out.println("Visito il gatto...");
}
}
}
Dato che sia Cane
che Gatto
estendono
AnimaleDomestico
allora qualsiasi metodo che accetta come
paramero un AnimaleDomestico
accetterà anche una qualsiasi
istanza di Cane
o Gatto
:
= new Cane("Mino");
Cane c = new Gatto("Rino");
Gatto g
= new Veterinario();
Veterinario vet
.visita(c); // Visito il cane...
vet.visita(g); // Visito il gatto... vet
In Java, tutte le classi che non estendono nessuna classe, in realtà,
estendono implicitamente una classe generica denominata
Object
che contiene tutti i metodi presenti di default
all’interno di ogni classe. Per ulteriori informazioni è possibile
consultare la documentazione
di Object
.
Ciascuna classe eredita sempre e solo da un’altra classe
(eventualmente Object
).
Se si vuole fare in modo che non sia possibile estendere
ulteriormente una classe è possibile dichiararla come
final
.
Classi astratte
Una classe astratta descrive una classe non istanziabile ma estendibile.
Una classe astratta può contenere metodi astratti, ovvero metodi senza corpo che devono essere obblicagoriamente sovrascritti da eventuali sottoclassi non astratte.
Nell’esempio seguente, viene dichiarata una classe astratta atta a descrivere una figura geometrica colorata generica. Tale classe verrà poi estesa, ottenendo una classe che descrive un rettangolo.
abstract class FiguraGeometrica {
private final Color colore;
protected FiguraGeometrica(Color colore) {
this.colore = colore;
}
public abstract double getArea();
public abstract double getPerimetro();
}
class Rettangolo extends FiguraGeometrica {
private double larghezza;
private double altezza;
public Rettangolo(Color colore, double larghezza, double altezza) {
super(colore);
this.larghezza = larghezza;
this.altezza = altezza;
}
@Override
public double getArea() {
return this.larghezza * this.altezza;
}
@Override
public double getPerimetro() {
return 2 * (this.larghezza + this.altezza);
}
}
Interfacce
Le interfaccie sono molto simili alle classi astratte, tranne che contiene solo metodi senza corpo e astratti di default.
Le interfacce si dichiarano con interface
e si
utilizzano tramite implements
. Una classe non astratta che
implementa un interfaccia deve per forza implementarne tutti i
metodi.
class Veicolo {...}
interface Volante {
void vola();
}
class Aereo extends Veicolo implements Volante {
@Override
public void vola() {...}
}
class Elicottero extends Veicolo implements Volante {
@Override
public void vola() {...}
}
class Animale {...}
class Anatra extends Animale implements Volante {
@Override
public void vola() {...}
}
Un qualunque metodo che richiede un qualcosa in grado di volare (che
quindi implements Volante
) potrà accettare qualsiasi
istanza di Aereo
, Elicottero
o
Anatra
.
Interfacce che è bene conoscere presenti in Java sono Comparable
, Cloneable
, Iterable
e
Runnable
.
Overloading
In java è possibile dichiarare molteplici metodi con lo stesso nome nello stesso scopo purchè essi siano distinguibili da numero e/o tipo dei parametri. Questo è molto utile per definire comportamenti diversi a seconda del tipo dei parametri mantenendo il codice leggibile e pulito.
class Pacco {...}
class UfficioPostale {
public invia(Pacco pacco) {...}
public invia(Pacco[] pacchi) {
for(Pacco p: pacchi) {
invia(p);
}
}
}
L’UfficioPostale
nell’esempio dispone di due metodi
chiamati invia
: il primo prende come parametro un singolo
Pacco
mentre il secondo ne prende un’array e sfrutta il
primo metodo per inviare tutti i pacchi in esso contenuti.
In una classe è anche possibile avere molteplici costruttori:
class InteroVersatile {
private int valore;
// E' possibile istanziare la classe passando direttamente un intero...
public InteroVersatile(int valore) {
this.valore = valore;
}
// ... oppure una stringa che verrà convertita ad intero
// Il construttore è segnato come `throws NumberFormatEception` in caso si passi una stringa che non rappresenta un numero
public InteroVersatile(String valore) throws NumberFormatException {
super(Integer.parseInt(valore));
}
public int getValore() {
return this.valore;
}
}
Enumerazioni
Un’enumerazione è equivalente ad una classe normale tranne che ha un numero finito di istanze create all’inizio dell’esecuzione.
enum PuntoCardinale {
// Le istanze esistenti sono dichiarate separate da una virgola (',')
, NE, E, SE, S, SO, O, NO;
N}
Essendo le enumerazioni equivalenti a classi qualunque, esse possono disporre di costruttori e metodi:
enum PuntoCardinale {
// Nell'esempio precedente, veniva chiamato il costruttore di default
N("Nord"),
NE("Nord-est"),
E("Est"),
SE("Sud-est"),
S("Sud"),
SO("Sud-ovest"),
O("Ovest"),
NO("Nord-ovest");
private String nome_completo;
public PuntoCardinale(String nome_completo) {
this.nome_completo = nome_completo;
}
public String getNomeCompleto() {
return this.nome_completo;
}
}
Le enumerazioni vengono utilizzate nel seguente modo:
public class Program {
public static void main(String[] args) {
= PuntoCardinale.NE;
PuntoCardinale dir
System.out.println(dir.getNomeCompleto()) // Nord-est
System.out.println(PuntoCardinale.S == PuntoCardinale.S) // true - da ambo i lati del `==` compare la stessa istanza di `PuntoCardinale`, vedere il paragrafo sull `equals` per ulteriori dettagli
}
}
Di default, un’enumerazione eredita dalla classe Enum
(documentazione)
dunque dispone, tra gli altri, dei seguenti metodi che può risultare
utile conoscere:
valueOf(String name)
: restituisce l’istanza dell’enumerazione corrispondente al parametro;name()
: restituisce il nome dell’istanza sul quale il metodo è chiamato (equivalente atoString()
);values()
: restituisce un array di tutte le istanze della data enumerazione.
Generics
Si immagini di voler modellare un nuovo tipo di struttura dati non presente di default in Java. Idealmente, si vorrebbe fare in modo che tale struttura possa ospitare gati di qualunque tipo, senza doverla reimplementare ogni volta: i generics vengono incontro proprio a questo bisogno.
Nota: i generics funzionano solo con tipi di dato complessi,
pertanto bisognerà utilizzare Integer
al posto di
int
eccetera.
Uno scheletro molto grezzo della classe che rappresenta la struttura dati di cui prima è il seguente
class StrutturaDati<T> {...}
All’interno della definizione di StrutturaDati
si potrà
utilizzare il tipo generico T
ovunque si debba fare
riferimento alla tipologia di oggetti immagazzinati dalla struttura dati
(è possibile specificare un numero arbitrario di tipi generici
separandoli con delle virgole).
class StrutturaDati<T> {
private T qualcosa;
public void add(T el) {
// Codice specifico della struttura dati per aggiungere l'elemento nella struttura dati
}
public T getElementoPiuSimpatico() {
// Codice specifico della struttura dati per trovare e restituire l'elemento più simpatico
}
}
Si supponga che, per esigenze della struttura dati, sia necessario
poter avere accesso al metodo compareTo
(ulteriori dettagli
in seguito): ciò significa che la
struttura dati può ospitare solamente istanze di oggetti che sono
confrontabili con loro stessi.
class StrutturaDati<T extends Comparable<T>> {...}
In questo modo si ha obbligato la struttura dati a poter accogliere
solamente oggetti che implementano Comparable<T>
dove
T
è il tipo ospitato dalla struttura dati.
Di seguito una carrellata di definizioni che illustrano le potenzialità dei tipi generici
Tipo Generico | Significato |
---|---|
<T> |
Qualsiasi oggetto di tipo T o che eredita da
`T |
<T extends U> |
Qualsiasi oggetto che eredita da o estende T |
<T extends U & V> |
Qualsiasi oggetto che eredita da o estende contemporaneamente sia
U che V (se una tra U e
V è una classe, allora deve comparire in prima
posizione) |
<T super U> |
Qualsiasi oggetto di un tipo da cui U eredita |
<?> |
Qualsiasi oggetto |
<? extends Object> |
Equivalente a <?> |
<? extends U> |
Oggetto di tipo qualsiasi, a patto che erediti da o implementi
U |
<? super U> |
Oggetto di tipo qualsiasi, a patto che sia di un tipo da cui
U eredita |
<T extends I<T>> |
Oggetto di tipo T che eredita da o estende
I che prende come tipo generico T stesso |
<T extends I<? super T>> |
Oggetto di tipo T che eredita da o estende
I che prende come tipo generico un oggetto di tipo
qualsiasi, a patto che sia un tipo da cui T stesso
eredita |
Si usa ?
quando il tipo utilizzato nella definizione non
ha importanza e non deve essere utilizzato nell’implementazione.
Non c’è limite al numero di tipi generici utilizzati e alla
complessità delle loro definizioni: cose come
<T estends U, S super V>
sono ammesse.
Si può trovare un esempio dell’utilizzo del generico riportato
nell’ultima riga della tabella precedente nella documentazione
di List.sort
.
Se non è necessario genericizzare l’intera classe, è possibile genericizzare qualsiasi metodo o variabile indipendentemente:
class StrutturaDati<T> {
public <R extends T, S super V> R metodo(S param) {...}
}
Potrebbe venir da pensare che tipi di oggetti quali
List<String>
estendano, oltre che a
Object
anche List<Object>
: questo non è
assolutemente vero.
Eccezioni
La gestione degli errori in Java avviene attraverso le eccezioni. Un’eccezione è lanciata quando vi è un errore di qualche genere (ad esempio, quando si tenta di aprire un file che non esiste).
Si supponga di avere un metodo che potrebbe lanciare un eccezione:
int intFromString(String s) {
return Integer.parseInt(s);
}
Il metodo parseInt
lancia un’eccezione di tipo
NumberFormatException
se la stringa in ingresso non
rappresenta un intero valido e dunque Java obbliga lo sviluppatore a
gestire tale eventualità:
int intFromString(String s) {
try {
return Integer.parseInt(s);
} catch(NumberFormatException e) {
System.err.println("Errore: " + e);
return -1;
}
}
Pur avendo una soluzione che gestisce correttamente l’eccezione, il
chiamante non ha modo di capire se il valore restituito è
-1
a causa di un errore oppure se è perchè ha chiamato il
metodo con proprio "-1"
.
La cosa migliore è delegare la gestione dell’errore al chiamante
dichiarando il metodo come
throws NumberFormatException
:
int intFromString(String s) throws NumberFormatException {
return Integer.parseInt(s);
}
In questo modo il chiamante è obbligato a gestire a sua volta
l’eccezione (scegliendo se utilizzare un blocco try-catch
oppure se delegare a sua volta).
E’ possibile concatenare molteplici catch
: quando si
verifica un’eccezione, il blocco più in alto che corrisponde a tale
eccezione sarà quello scelto per gestirla (pertanto è consigliato
gestire le eccezioni dalla più specifica alla più generica).
try {...}
catch(ExceptionType1 e) {...}
catch(ExceptionType2 e) {...}
catch(ExceptionType3 e) {...}
Alla fine di una serie di catch
è possibile inserire un
blocco finally
che verrà eseguito indipendentemente dal
fatto che vi siano state eccezioni o meno. Questo blocco è utile per
liberare eventuali risorse aperte in blocchi try
che sono
stati interrotti da un’eccezione:
Socket s;
try {
= new Socket("127.0.0.1", 3000);
s .getOutputStream().write(42);
s} catch(IOException e) {
System.err.println(e);
} catch(Exception e) {
System.err.println(e);
} finally {
.close();
s}
Se ci si dimentica di chiudere la socket dopo l’errore, è possibile
che rimanga allocata in memoria indefinitamente causando un memory leak
di conseguenza è stato inventato il try-with-resources
:
try(Socket s = new Socket("127.0.0.1", 3000)) {
.getOutputStream().write(42);
s} catch(IOException e) {
System.err.println(e);
} catch(Exception e) {
System.err.println(e);
}
Così facendo, se gli oggetti istanziati implementano l’interfaccia
AutoCloseable
possono essere istanziati in un
try-with-resources
e verranno automaticamente chiusi alla
fine.
E’ possibile concatenare molteplici istruzioni che istanziano risorse separandole con un punto e virgola (‘;’).
Per lanciare eccezioni a mano si usa
raise new ExceptionType(...)
.
In Java, tutte le eccezioni ereditano da Error
,
Exception
o RuntimeException
:
classDiagram
class Throwable
class Error
class Exception
class RuntimeException
Throwable <|-- Error
Throwable <|-- Exception
Exception <|-- RuntimeException
Questo è un diagramma UML e serve a schematizzare le specifiche e le interazioni tra le varie classi di un programma Java, verrà spiegato in seguito.
Nel diagramma precedente sono state riportate le sole classi di interesse senza le proprie variabili e metodi.
Tutte le eccezioni che ereditano direttamente da
Exception
devono essere obbligatoriamente gestite o
delegate mentre quelle che ereditano direttamente da
RuntimeException
no (ma è fortemente consigliato
gestirle/delegarle in ogni caso per ulteriore sicurezza).
La tipologia Error
viene utilizzata per errori quali
stack overflow o memoria esaurita e deve obbligatoriamente causare la
terminazione del programma. Il suo utilizzo è sconsigliato.
Qualora le eccezioni esistenti non siano adatte per essere utilizzate, è possibile creare delle eccezioni personalizzate estendendo una delle classi di cui sopra:
class NuovaEccezione extends Exception {
public NuovaEccezione() {
super();
}
}
Se viene lanciata un’eccezione vuol dire che qualcosa è andato storto
e quel qualcosa deve essere in qualche modo sistemato pertanto è
fortemente sconsigliato creare blocchi try-catch
nei quali
il blocco catch
non fa nulla.
Equals e compareTo
Per confrontare due elementi, in Java, si usa l’operatore
==
che, come in qualsiasi altro linguaggio di
programmazione sensato, restituisce true
se i due elementi
sono uguali e false
altrimenti.
Se ==
è applicato a due istanze di oggetti, dei due
oggetti non vengono confrontati gli stati bensì l’indirizzo di
riferimento. Ne segue che il confronto tra oggetti risulta
true
se entrambi si riferiscono alla stessa istanza di una
classe e false
altrimenti.
Per permettere il confronto tra due oggetti, è possibile
sovrascrivere il metodo equals
che ogni classe eredita da
Object
:
class Libro {
private String titolo;
private String edizione;
public Libro(String titolo, String edizione) {
this.titolo = titolo;
this.edizione = edizione;
}
@Override
boolean equals(Object other) {
// Se `other` non è un'istanza di `libro` allora è per forza diverso
if(!(other instanceof Libro)) {
return false;
}
// Indipendentemente dal tipo dell'oggetto, se i due riferimenti coincidono allora sono per forza lo stesso oggetto
if(this == other) {
return true;
}
// Una volta esclusi i due casi di cui sopra, è possibile procedere al confronto
// Nell'esempio attuale si considerano due libri equivalenti se hanno lo stesso titolo, indipendentemente dall'edizione
// Notare che viene utilizzato il metodo `equals` della classe `String` che ritorna `true` se le due stringhe hanno lo stesso contenuto
return this.titolo.equals(((Libro)other).titolo);
}
}
Nel caso sia necessario implementare una relazione d’ordine tra
oggetti (non necessariamente due istanze della stessa classe) bisogna
dichiarare tale classe come implements Comparable<T>
(si vedrà il significato di <T>
nel paragrafo sui
generics): così facendo, si verrà obbligati ad implementare un metodo
int compareTo(T o)
che, per specifica, deve ritornare (a)
un numero negativo se this
viene prima di o
oppure (b) il numero zero se this
è equivalente ad
o
oppure (c) un numero positivo se this
viene
dopo di o
.
Come sempre, un esempio pratico potrà schiarire le idee: si supponga di dover memorizzare una lista di persone e di doverla ordinare per, ad esempio, altezza decrescente.
public class Program {
public static void main(String[] args) {
ArrayList<Persona> persone = new ArrayList<>();
.add(new Persona("Gino", 1.70));
persone.add(new Persona("Pino", 1.60));
persone.add(new Persona("Mino", 1.80));
persone
// Questo si può fare perchè `persone` è una lista di oggetti che implementano `Comparable` con loro stessi
Collections.sort(persone);
for(Persona p: persone) {
System.out.println(p.getNome() + ": " + p.getAltezza()); // Mino: 1.80
// Gino: 1.70
// Pino: 1.60
}
}
}
class Persona implements Comparable<Persona> {
private String nome;
private float altezza;
public Persona(String nome, float altezza) {
this.nome = nome;
this.altezza = altezza;
}
public String getNome() {
return this.nome;
}
public float getAltezza() {
return this.altezza;
}
@Override
public int compareTo(Persona o) {
if(this.altezza < o.altezza) {
return 1;
} else if(this.altezza == o.altezza) {
return 0;
} else {
return -1;
}
}
}
Il vantaggio di avere Comparable
genericizzato è che è
possibile poter disporre di metodi separati per il confronto tra diverte
tipologie di oggetti.
String e toString()
Le stringhe in java sono immutabili.
Qualsiasi oggetto in Java può essere rappresentto in forma testuale
grazie al fatto che Object
dispone di un metodo
toString
.
Di default, la rappresentazione testuale di un oggetto è della forma
NomeClasse@HashCode
, per cambiare ciò basta sovrascrivere
il metodo toString
:
public class Program {
public static void main(String[] args) {
= new Persona("Marco", 26);
Persona marco
// La variante di `println` che prende un `Object` come parametro stampa l'output di `toString`
System.out.println(marco);
}
}
class Persona {
private String nome;
private int eta; // Salvare l'età di una persona invece che la data di nascita dovrebbe essere inserito nella lista di crimini contro l'umanità
public Persona(String nome, int eta) {
this.nome = nome;
this.eta = eta;
}
@Override
public String toString() {
return "Mi chiamo " + this.nome + " e ho " + this.eta + " anni";
}
}
Le stringhe presenti al compile time in un programma Java sono tutte salvate in un area diversa da dove vengono salvate le stringhe generate a runtime. Questo porta a dei comportamenti apparentementi erronei da parte del programma:
String s1 = "Pippo"; // Presente a compile time
String s2 = "Pippo"; // Per non includere due volte la stessa stringa, `s2` punterà alla stessa istanza di stringa di `s1`
String s3 = new String("Pippo"); // La `"Pippo"` è la stessa delle righe precedenti ma non viene assegnata direttamente a `s3` ma clonata a runtime, pertanto, pur essendo partiti dalla stessa istanza, `s3` sarà un'istanza separata dalle altre due
.equals(s2); // true
s1.equals(s3); // true
s1== s2; // true
s1 == s3; // false s1
Clone
Si supponga di dover gestire un sistema bibliotecario memorizzando la lista di libri disponibili. Tale lista, per l’esterno, deve essere di sola lettura:
enum StatoPrestito {DISPONIBILE, PRESTATO};
class Libro {
private String titolo;
private StatoPrestito stato_prestito;
public Libro(String titolo) {
this.titolo = titolo;
this.stato_prestito = StatoPrestito.DISPONIBILE;
}
public String getTitolo() {
return this.titolo;
}
public StatoPrestito getStatoPrestito() {
return this.stato_prestito;
}
public void setStatoPrestito(StatoPrestito nuovo_stato) {
this.stato_prestito = nuovo_stato;
}
}
class SistemaBibliotecario {
private ArrayList<Libro> libri;
public SistemaBibliotecario() {
this.libri = new ArrayList<>();
}
public addLibro(Libro l) {
this.libri.add(l);
}
public ArrayList<Libro> getLibri() {
return this.libri;
}
}
Questo codice presenta due criticità gravi.
In primo luogo è possibile che due istanze diverse di
SistemaBibliotecario
abbiano in memoria la stessa istanza
di Libro
, ottenendo che un libro prestato da uno dei due
sistemi compaia come non diponibile anche nell’altro sistema. Questo
problema è facilmente risolvibile memorizzando, invece che l’istanza
originale del libro, una sua copia: il metodo addLibro
dovrà dunque essere modificato adeguatamente.
public void addLibro(Libro l) {
this.libri.add(l.clone());
}
dove il metodo clone
di Libro
è
implementato come segue
public Libro clone() {
return new Libro(this.titolo);
}
In questo modo, ciascun SistemaBibliotecario
avrà la
propria istanza di Libro
che non potrà interferire con
l’operato altrui.
La seconda criticità, forse ancora più grave, è che la lista di libri
presente in un SistemaBibliotecario
non è di sola lettura,
pertanto non vi è nulla che impedisca di fare qualcosa come
sistema_bibliotecario.getLibri().clear()
se si vuole
generare un po’ di caos tra i bibliotecari.
Si potrebbe pensare di restituire allora un clone della lista
public ArrayList<Libro> getLibri() {
return this.libri.clone();
}
Peccato che in tal modo sia ancora possibile fare qualcosa come
.getLibri().get(42).setStatoPrestito(StatoPrestito.PRESTATO) sistema_bibliotecario
per far risultare un libro come prestato quando invece non lo è
(questo è dato dal fatto che, mentre la lista viene clonata, i
riferimenti alle varie istanze di Libro
al suo interno,
invece, sono sempre le stesse).
Questa seconda criticità è più subdola e può essere risolta con una
deep-clone
ovvero una clonazione ricorsiva prima della
lista, poi degli oggetti contenuti nella lista, poi degli oggetti
referenziati da questi ultimi e così via.
Il metodo corretto è dunque
public ArrayList<Libro> getLibri() {
ArrayList<Libro> lista = new ArrayList<>();
for(Libro l: this.libri) {
.add(l.clone());
lista}
return lista;
}
Nella deep-clone
, non è più necessario proseguire con la
clonazione ricorsiva quando si vuole clonare un’oggetto che contiene
solamente variabili che non sono reference oppure se le reference che ci
sono, sono tutte verso oggetti immutabili (come le stringhe).
Per indicare che in un oggetto è disponibile il metodo clone (e,
dunque, poterlo utilizzare come parametro dentro metodi che richiedono
che un oggetto sia clonabile) si marca la dichiarazione della classe di
tale oggetto come implements Cloneable
.
Iterabilità
Si supponga di voler iterare su tutti gli oggetti contenuti in una collezione: come già visto, è possibile utilizzare un enhanced for loop:
HashSet<Elemento> elementi = new HashSet<>();
for(Elemento el: elementi) {
System.out.println(el);
}
Sia dato il caso in cui è necessario iterare, invece che su una
collezione già presente in java, su un tipo di dato qualunque da noi
definito: per rendere possibile l’utilizzo dell’enhanced for loop, è
necessario che la classe della quale è istanza l’oggetto su cui si vuole
iterare implementi l’interfaccia Iterable
.
Gli oggetti che implementano l’interfaccia Iterable
dispongono di un metodo iterator()
che restituisce un
oggetto che implementa Iterator<T>
dove
T
è il tipo di dato memorizzato nella collezione.
Un Iterator<T>
implementa i metodi
hasNext()
e next()
.
Nell’esempio seguente viene implementata una classe
PythonRange
sulle cui istanze è possibile iterare in un
modo simile a come si fa in Python.
public class Program {
public static void main(String[] args) {
for(Integer i: new PythonRange(10)) {
System.out.println(i); // Tutti i numeri [0, 10)
}
for(Integer i: new PythonRange(7, 12)) {
System.out.println(i); // Tutti i numeri [7, 12)
}
}
}
class PythonRange implements Iterable<Integer>{
private int begin, end;
public PythonRange(int end) {
this(0, end);
}
public PythonRange(int begin, int end) {
this.begin = begin;
this.end = end;
}
// Restituisce un'istanza dell'iteratore (ogni iteratore è a se)
@Override
public Iterator<Integer> iterator() {
return new Iterator<Integer>() {
private int i = begin;
// Restituisce `true` se è possibile procedere ulteriormente
@Override
public boolean hasNext() {
return i < end;
}
// Ritorna l'elemento successivo (lancia un'eccezione se non è possibile farlo)
@Override
public Integer next() {
if(this.hasNext())
return (i++);
else
throw new NoSuchElementException();
}
// Eventualmente è possibile implementare anche il metodo `remove()` (che rimuove l'elemento dalla collezione) e `forEachRemaining(Consumer<? super E> action)` che esegue un metodo su ciascun elemento sul quale ancora non si ha iterato (vedere il paragrafo sulla programmazione funzionale per ulteriori informazioni).
};
}
}
Programmazione funzionale
La programmazione funzionale è un paradigma di programmazione diverso da quello ad oggetti: essa promuove l’espressione della computazione come applicazione di una o più funzioni ai dati invece che una sequenza ordinata di istruzioni.
Tutta la programmazione funzionale in Java si basa sulle cosiddette interfaccie funzionali ovvero interfaccie che possiedono un solo metodo.
La programmazione funzionale in Java è lazy: questo significa che le varie funzioni applicate ad un dato non vengono applicate realmente finchè non è necessario l’utilizzo del risultato dell’operazione.
Questo tipo di funzionamento porta a due considerazioni alle quali bisogna fare molta attenzione:
- non è detto che una funzione venga applicata sempre a tutti gli elementi contenuti nella struttura dati sotto esame;
- non è garantito l’ordine di esecuzione di una funzione sugli elementi contenuti nella struttura dati sotto esame.
Da questi due punti si può anche capire perchè sia necessario applicare solamente funzioni pure (ovvero senza side effects) agli elementi contenuti nella struttura dati: dato che ne l’esecuzione ne l’ordine sono garantiti, diventa impossibile predire il comportamento del programma.
Nonostante ciò, il punto di forza principale di questo paradigma è proprio la mancanza di side-effect delle funzioni: l’utilizzo di funzioni pure comporta una più facile verifica della correttezza e della mancanza di bug del programma e la possibilità di una maggiore ottimizzazione dello stesso in quanto, dato che le funzioni applicate non interferiscono tra di loro, è possibile parallelizzarle senza problemi.
Le varie funzioni vengono applicate attraverso uno stream (che non
centra nulla con gli stream tipo quelli di input/output) che si ottiene
chiamando stream()
sulle strutture dati supportate.
E’ importante ricordare che le varie funzioni non vengono applicare
sulla struttura dati che ha fornito lo stream ma su una sua copia: la
struttura dati originale rimarrà intatta. Per ottenere uno stream che
parallelizza le applicazioni di funzioni, si chiama
stream().parallel()
sulla struttura dati.
Metodi utili:
filter(Predicate<? super T> predicate)
: filtra gli elementi dello stream mantenendo solo quelli che soddisfano il predicato;forEach(Consumer<? super T> action)
: chiama la funzione specificata con ogni elemento dello stream come parametro (questa è l’unica funzione che garantisce l’ordine di esecuzione)map(Function<? super T, ? extends R> mapper)
: restituisce uno stream costituito dal risultato dell’applicazione della funzione ad ogni elemento dello stream originale. Il nuovo stream sarà composto da elementi di tipoR
;flatMap(Function<? super T, ? extends Stream<? extends R>> mapper)
: utilizza i principi della funzionemap
iterando sugli elementi di unostream
e concatenandoli in un solo livello (ad esempio, se chiamandomap
si otterrebbe uno stream di stream, chiamandoflatMap
si otterrebbe uno stream che contiene, ordinatamente, tutti gli elementi contenuti negli stream che si sarebbero ottenuti con lamap
);reduce(T identity, BinaryOperator<T> accumulator)
: esegue una riduzione degli elementi dello stream, utilizzando il valore di identità fornito e una funzione associativa di accumulazione, e restituisce un singolo elemento di tipo T;collect(Collector<? super T, A, R> collector)
: esegue un’operazione di riduzione mutabile sugli elementi dello stream utilizzandoCollector
;distinct()
: rimuove gli elementi duplicati, ritorna uno stream contenente solo elementi distinti;Optional<T> findFirst()
: ritorna il primo elemento dello stream, se esiste;count()
: ritorna il numero di elementi dello stream.
Nella relativa
documentazione è possibile trovare tutti i tipi di interfaccia
funzionale già presenti in Java. In ogni caso è possibile dichiarare
un’interfaccia funzionale personalizzata decorandola con
@FunctionalInterface
.
Esistono classi specializzate come la IntStream
che
fornisce operazioni specializzate su un tipo di dato specifico (in
questo caso, int
).
Come sempre un esempio potrà chiarire molto i concetti presentati.
// Si vuole progettare un programma che calcola l'altezza media di tutte le persone nate in un giorno dispari
ArrayList<Persona> persone = new ArrayList<Persona>();
for(int i = 10; i < 21; i++) {
.add(new Persona(i, 160 + i));
persone}
.stream().parallel() // Utilizziamo uno stream parallelo
persone.filter(p -> p.getGiorno_di_nascita() % 2 == 0) // Vengono mantenute solo le persone
.mapToInt(p -> p.getAltezza()) // Di ciascuna persona si prende l'altezza e la si mette in uno stream di interi
.average() // `IntStream` fornisce, tra le altre, la funzione `average`
.ifPresentOrElse(System.out::println, () -> { // Il valore ritornato da `average` è un `Optional`, più dettagli in seguito
System.out.println("Nessuno è nato in un giorno pari");
});
Optional
Il tipo Optional<T>
si usa per descrivere una
variabile che potrebbe, o meno, contenere un dato. Nell’esempio
precedente, si è visto il tipo Optional
ritornato da
average
in quanto, non è garantito che lo stream sul quale
è stata chiamata average
contenga elementi.
L’utilizzo principale di Optional
è quello di eliminare
i problemi dovuti a null
: applicando una funzione ad un
Optional
verrà restituito un altro Optional
che conterrà il risultato della funzione applicata al valore del vecchio
optional (se pieno), altrimenti un Optional
vuoto.
In termini prettamente di informatic teorica, Optional
è
una monade. Si rimanda alla relativa pagina
wikipedia per ulteriori informazioni.
Di seguito un elenco di metodi da conoscere per l’utilizzo di
Optional<T>
:
Optional.of(T value)
: restituisce unOptional
che contienevalue
(se ènull
lancia unaNullPointerException
);Optional.empty()
: restituisce unOptional
vuoto;Optional.ofNullable(T value)
restituisce unOptional
eventualmente vuoto;isPresent()
restituisce un booleano che indica se l’optional contiene qualcosa;ifPresent(Consumer<? super T>)
: se nell’Optional
è presente un valore allora chiama la funzione passata come parametro passando come parametro il valore contenuto;flatMap(Function<? super T, ? extends Optional<? extends U>> mapper)
: ritorna unOptional
vuoto se l’Optional
sul quale è chiamato è vuoto, altrimenti ritorna unOptional
che contiene il valore restituito dalla funzione passata come parametro chiamata con il valore contenuto nell’Optional
di partenza come parametro;orElse(T val)
: se l’Optional
non è vuoto ne ritorna il contenuto, altrimenti ritornaval
;ifPresentOrElse(Consumer<? super T> action, Runnable emptyAction)
: se l’Optional
non è vuoto allora chiamaaction
passando come parametro il contenuto, altrimenti chiamaemptyAction
.
Multithreading
Il modo più semplice per creare e lanciare thread in parallelo
consiste nell’estendere la classe Thread
:
public class Program {
public static void main(String[] args) {
Thread t = new MyThread();
.start(); // Il metodo `start` crea un nuovo thread che esegue il codice contenuto nel metodo `run`
t}
}
class MyThread extends Thread {
public MyThread() {...}
@Override
public void run() {
// Codice eseguito dal thread
}
}
Un metodo alternativo (utile nel caso in cui la classe che dovrebbe
diventare un thread debba estendere altre classi) consiste nel
sostituire l’estensione a Thread
con l’implementazione di
Runnable
:
public class Program {
public static void main(String[] args) {
= new MyRunnable();
MyRunnable r
Thread t = new Thread(r); // E' necessario creare il thread partendo dal runnable
.start();
t}
}
class MyRunnable implements Runnable {
public MyRunnable() {...}
@Override
public void run() {
// Codice eseguito dal thread
}
}
Tra le altre cose, è possibile assegnare un nome a ciascun thread e cambiarne la priorità (in breve, la priorità indica quanto tempo viene dedicato a ciascun thread - più è alta la priorità e maggiore sarà la frazione del tempo totale che il thread avrà a disposizione).
Quando si lavora con molteplici thread che accedono agli stessi dati, è bene prestare attenzione a creare un meccanismo di mutua esclusione robusto, altrimenti si rischia di trovare dati sfalsati.
Si supponga di avere due thread che devono entrambi incrementare una stessa variabile: se il primo viene interrotto, dopo la lettura del valore ma prima della scrittura, dal secondo che, invece, riesce a terminare il suo incremento prima di venir interrotto a sua volta dal primo, il quale poi prosegue salvando il risultato incrementato, il lavoro del secondo thread verrebbe sovrascritto dalla scrittura tardiva da parte del primo (ottenendo così una variabile incrementata una singola volta invece delle due previste).
public class Program {
private static int x = 0;
public static void main(String[] args) {
new Thread(() -> {x++;}).start();
new Thread(() -> {x++;}).start();
System.out.println(x);
}
}
Ci si aspetterebbe di ricevere “2” come output ma, come si potrà notare eseguendo il codice molteplici volte, alcune volte l’output sarà diverso.
Di seguito verranno illustrate le principali soluzioni che java mette a disposizione per prevenire questo titpo di problemi.
Synchronized
La keyword synchronized
si usa applicata ad interi
metodi o a singole porzioni di codice che devono essere eseguite in
maniera atomica e in mutua esclusione tra loro.
synchronized
funziona specificando l’oggetto sul quale
si vuole sincronizzarsi (eventualmente, this
): due thread
che eseguono blocchi di codice sincronizzati su oggetti diversi non
avranno problemi mentre se due thread eseguono blocchi di codice
sincronizzati sullo stesso oggetto, uno solo dei due avrà il via libera
e l’altro dovrà attendere che il primo abbia finito.
E’ possibile sincronizzare blocchi di codice solamente su oggetti e non tipi su tipi di dati elementari.
class ClasseConSincronizzazioni {
// Le sincronizzazioni di `metodo1` e `metodo 2` sono equivalenti
public void metodo1() {
synchronized(this) {...}
}
public synchronized void metodo2() {...}
// Se si vuole sincronizzare su una variabile semplice, si istanzia un `Object` e si usa quello
private int variabile_semplice;
private Object lock_variabile_semplice = new Object();
public void metodo3() {
synchronized(lock_variabile_semplice) {...}
}
}
Per motivi legati alla cache coherence, anche i blocchi nella quale una variabile condivisa è utilizzata in sola lettura vanno sincronizzati.
La keyword synchronized
applicata ad un metodo non viene
passata alle sottoclassi per ereditarietà pertanto si dovrà specificarla
ogni volta ove necessario.
Se non si presta sufficiente attenzione, è possibile causare dei deadlock:
class Deadlocked {
private Object lock1 = new Object();
private Object lock2 = new Object();
public void metodo1() {
synchronized(lock1) {
synchronized(lock2) {...}
}
}
public void metodo2() {
synchronized(lock2) {
synchronized(lock1) {...}
}
}
}
Si supponga di avere due thread che, sulla stessa istanza di
Deadlocked
chiamano uno metodo1
e l’altro
metodo2
. In questa situazione è probabile che si verifichi
un cosiddetto deadlock in quanto, se entrambi i thread
riscono a prendere il lock, rispettivamente, su lock1
e
lock2
, allora nessuno dei due potrà procedere ulteriormente
e rilsciare il lock finchè l’altro non termina.
Wait e notify
Si consideri il classico caso di produttore/consumatore dove un thread si occupa di produrre un dato mentre un’altro si occupa di utilizzarlo:
class ProduttoreConsumatore<T> {
private T dato = null;
public synchronized void deposita(T dato) {
while(this.dato != null) {
wait();
}
this.dato = dato;
notifyAll();
}
public synchronized T preleva() {
;
T dato
while(this.dato == null) {
wait();
}
= this.dato;
dato
notifyAll();
return dato;
}
}
I metodi protagonisti di questo paragrafo sono 3: -
wait
: rilascia eventuali lock, mette il thread in attesa di
una notify
sull’oggetto sul quale la wait
è
stata chiamata e poi prosegue; se un lock è stato rilasciato, prima di
proseguire attende di poterlo riprendere; - notify
:
risveglia un thread a caso tra quelli mandati in wait dall’oggetto sul
quale il metodo è stata chiamato; - notifyAll
: risveglia
tutti i thread mandati in wait dall’oggetto sul quale il metodo è stato
chiamato.
Dato che notify
non è deterministica (e quindi potrebbe
risvegliare il thread sbagliato), si preferisce utilizzare
notifyAll
per risvegliare tutti i thread e racchiudere la
wait
dentro un while
per fare in modo che solo
il thread giusto possa andare avanti. Per ulteriori informazioni sul
meccanismo wait
/notify
, consultare la documentazione.
Un thread dispone di uno stato che può mutare a seconda delle condizioni in cui si trova e dei metodi che in esso vengono chiamati. Lo schema seguente mostra gli stati e le transizioni.
stateDiagram-v2
initial --> ready: Thread.start()
ready --> running: _Al thread viene assegnato uno slot temporale_
running --> ready: _Il thread consuma completamente il suo slot temporale_
running --> blocked: _Il thread attende il lock_
blocked --> ready: _Al thread è dato il lock richiesto_
running --> waiting: wait()
waiting --> ready: notify(), notifyAll()
running --> sleeping: Thread.sleep()
sleeping --> ready
running --> stopped: _Il metodo run è terminato_
Dato che un blocco synchronized
non impedisce al thread
di essere interrotto, è possibile che vi sia dell’inconsistenza anche
quando un metodo è correttamente sincronizzato. Per risolvere tale
problema si utilizzano i cosiddetti oggetti immutabili.
Un oggetto immutabile è un istanza di una classe che non ammette in
alcun modo modifiche allo stato (nella pratica, questo si realizza non
includendo metodi in grado di modificare lo stato).
Multithreading avanzato
La classe
ReentrantLock
Per evitare problemi di deadlock, è possibile utilizzare la classe
ReentrantLock
. Questa classe, permette di prendere e
rilasciare lock in modo non bloccante in modo da poter controllare se il
lock sia stato preso con successo.
public class ClasseConLockCorretti {
private Lock lock1, lock2;
public ClasseConLockCorretti() {
= new ReentrantLock();
lock1 = new ReentrantLock();
lock2 }
public void metodo() {
// Si tenta di prendere ciascun lock in maniera non bloccante e ci si segna se l'operazione ha avuto successo
boolean gotFirstLock = lock1.tryLock();
boolean gotSecondLock = locl2.tryLock();
// Se entrambi i lock sono stati presi, allora si può procedere
if(gotFirstLock && gotSecondLock) {
try {
// Codice che richiede entrambi i lock
} finally {
// E' necessario sbloccare i lock manualmente
if(gotFirstLock)
.unlock();
lock1
if(gotSecondLock)
.unlock();
lock2
// Si usa un `finally` per garantire che, qualunque cosa succeda, i lock vengano sbloccati
}
}
}
}
Esecutori
Nel caso in cui si debba gestire una grande quantità di thread, l’overghead che si paga ad ogni context switch non è più trascurabile rispeto al tempo di esecuzione effettivo. Per risolvere questo problema, si utilizzano gli esecutori, dei quali ne esistono di svariati tipi.
La factory Executors.newSingleThreadExecutor()
si usa
per creare un esecutore che esegue un numero arbitrario di
Runnable
in modo sequenziale, tutti su un singolo
thread.
ExecutorService e = Executors.newSingleThreadExecutor();
// Si aggiungono i vari `Runnable`
.execute(new Runnable() {...});
e.execute(new Runnable() {...});
e.execute(new Runnable() {...});
e.execute(new Runnable() {...});
e
// L'`ExecutorService` va spento manualmente quando tutti i `Runnable` hanno terminato
.shutdown(); e
La factory Executors.newFixedThreadPool()
si usa per
crare un esecutore equivalente all’esecutore precedente ma è possibile
specificare quanti thread si suddividono i Runnable
(che
comunque vengono presi dalla coda in ordine e vengono portati a
termine).
// Il numero di thread gestiti dall'esecutore è specificato come parametro, in questo caso `3`
ExecutorService e = Executors.newFixedThreadPool(3);
La factory Executors.newVirtualThreadPerTaskExecutor()
si usa per assegnare più thread virtuali ad uno stesso thread fisico, in
modo da non incappare in errori dati dalla limitatezza delle risorse
della macchina su cui è in esecuzione il programma. Quando uno di questi
thread virtuale viene messo in attesa da operazioni di input/output, al
suo posto viene messo in esecuzione un altro thread virtuale.
ExecutorService e = Executors.new VirtualThreadPerTaskExecutor();
.submit(() -> {...});
e.submit(() -> {...});
e.submit(() -> {...});
e
.shutdown(); e
Tipi di dato atomici
In java sono presenti dei tipi di dato sul quale è possibile eseguire operazioni atomiche senza preoccuparsi di dover sincronizzare tutto correttamente.
Esempi di tipi di dati atomici sono AtomicInteger
,
BlockingQueue
, ConcurrentMap
,
AtomicIntegerArray
. Ciascuna di queste classi si comporta
come la rispettiva classe non atomica.
Per ulteriori informazioni è possibile consultare la relativa documentazione.