Definizione di Classi in Java
Nelle lezioni precedenti abbiamo iniziato a scrivere classi semplici. Tuttavia tutte quelle classi contenevano soltanto un singolo metodo main
.
Ora è arrivato il momento di mostrare come creare le classi personalizzate di cui abbiamo bisogno per applicazioni più sofisticate.
Queste classi di norma non possiedono un metodo main
; dispongono invece dei propri campi d’istanza e metodi.
Per costruire un programma completo combiniamo più classi, una delle quali include un metodo main
.
Un'esempio di classe
La forma più semplice di definizione di classe in Java è:
class NomeClasse
{
campo1
campo2
. . .
costruttore1
costruttore2
. . .
metodo1
metodo2
. . .
}
Consideriamo la seguente versione, molto semplificata, di una classe Impiegato
che un’azienda potrebbe usare per implementare un sistema di gestione stipendi:
class Impiegato
{
// campi d’istanza
private String nome;
private double stipendio;
private LocalDate dataAssunzione;
// costruttore
public Impiegato(String n, double s, int anno, int mese, int giorno)
{
nome = n;
stipendio = s;
dataAssunzione = LocalDate.of(anno, mese, giorno);
}
// un metodo
public String getNome()
{
return nome;
}
// altri metodi
. . .
}
Analizzeremo nel dettaglio l’implementazione di questa classe nelle sezioni che seguono. Prima, però, l'esempio che segue mostra la classe Impiegato
all’opera.
Nel programma creiamo un array di Impiegato
e lo riempiamo con tre oggetti Impiegato
:
Impiegato[] personale = new Impiegato[3];
personale[0] = new Impiegato("Mario Rossi", ...);
personale[1] = new Impiegato("Serena Verdi", ...);
personale[2] = new Impiegato("Elena Gialli", ...);
Successivamente utilizziamo il metodo aumentaStipendio
della classe Impiegato
per incrementare lo stipendio di ciascun dipendente del 5%:
for (Impiegato i : personale)
i.aumentaStipendio(5);
Infine stampiamo le informazioni di ogni dipendente, chiamando i metodi getNome
, getStipendio
e getDataAssunzione
:
for (Impiegato i : personale)
System.out.println("nome=" + i.getNome()
+ ",stipendio=" + i.getStipendio()
+ ",dataAssunzione=" + i.getDataAssunzione());
Osserviamo che il programma di esempio è composto da due classi: la classe Impiegato
e una classe ImpiegatoTest
dichiarata public
.
Il metodo main
che contiene le istruzioni appena descritte si trova nella classe ImpiegatoTest
.
Il nome del file sorgente è ImpiegatoTest.java perché deve corrispondere al nome della classe public
. In un file sorgente può esserci una sola classe public
, ma qualsiasi numero di classi non‐public.
Quando compiliamo questo sorgente, il compilatore crea due file di classe nella cartella: ImpiegatoTest.class
e Impiegato.class
.
Per avviare il programma passiamo all’interprete di bytecode il nome della classe che contiene il metodo main
:
java ImpiegatoTest
L’interprete inizia a eseguire il codice del metodo main
in ImpiegatoTest
, che a sua volta costruisce tre nuovi oggetti Impiegato
e ne mostra lo stato.
Di seguito il codice completo del programma, che può essere copiato e incollato in un file ImpiegatoTest.java:
import java.time.*;
/**
* Questo programma testa la classe Impiegato.
* @version 1.0
* @author DistortionByte
*/
public class ImpiegatoTest
{
public static void main(String[] args)
{
// riempiamo l’array personale con tre oggetti Impiegato
Impiegato[] personale = new Impiegato[3];
personale[0] = new Impiegato("Mario Rossi", 75000, 1987, 12, 15);
personale[1] = new Impiegato("Serena Verdi", 50000, 1989, 10, 1);
personale[2] = new Impiegato("Elena Gialli", 40000, 1990, 3, 15);
// aumentiamo lo stipendio di tutti del 5%
for (Impiegato i : personale)
i.aumentaStipendio(5);
// stampiamo informazioni su tutti gli oggetti Impiegato
for (Impiegato i : personale)
System.out.println("nome=" + i.getNome() + ",stipendio=" + i.getStipendio()
+ ",dataAssunzione=" + i.getDataAssunzione());
}
}
class Impiegato
{
private String nome;
private double stipendio;
private LocalDate dataAssunzione;
public Impiegato(String n, double s, int anno, int mese, int giorno)
{
nome = n;
stipendio = s;
dataAssunzione = LocalDate.of(anno, mese, giorno);
}
public String getNome()
{
return nome;
}
public double getStipendio()
{
return stipendio;
}
public LocalDate getDataAssunzione()
{
return dataAssunzione;
}
public void aumentaStipendio(double percentuale)
{
double aumento = stipendio * percentuale / 100;
stipendio += aumento;
}
}
Uso di più file sorgente
Il programma dell'esempio precedente contiene due classi in un unico file sorgente. Molti programmatori preferiscono invece collocare ogni classe nel proprio file. Per esempio, possiamo mettere la classe Impiegato
nel file Impiegato.java e la classe ImpiegatoTest
in ImpiegatoTest.java.
Se adottiamo questa soluzione, abbiamo due possibilità per compilare il programma. Possiamo invocare il compilatore Java con un carattere jolly:
javac Impiegato*.java
In tal modo, tutti i file sorgente che corrispondono al jolly verranno compilati in file .class
.
In alternativa possiamo semplicemente digitare
javac ImpiegatoTest.java
Potrebbe sorprenderci il fatto che la seconda scelta funzioni anche se il file Impiegato.java non viene compilato esplicitamente. Tuttavia, quando il compilatore Java vede la classe Impiegato
usata all’interno di ImpiegatoTest.java, cercherà un file chiamato Impiegato.class. Se non lo trova, cercherà automaticamente Impiegato.java e lo compilerà. Inoltre, se il timestamp della versione di Impiegato.java trovata è più recente di quello del file Impiegato.class esistente, il compilatore ricompilerà automaticamente il sorgente.
Il compilatore come un make integrato
Se abbiamo dimestichezza con l’utilità make di UNIX (o con una delle sue controparti per Windows, come nmake), possiamo considerare il compilatore Java come dotato di funzionalità make già integrate.
Analisi dettagliata della classe Impiegato
Nelle sezioni seguenti analizzeremo la classe Impiegato
. Iniziamo dai metodi. Esaminando il sorgente notiamo che la classe possiede un costruttore e quattro metodi:
public Impiegato(String n, double s, int anno, int mese, int giorno)
public String getNome()
public double getStipendio()
public LocalDate getDataAssunzione()
public void aumentaStipendio(double percentuale)
Tutti i metodi sono marcati come public
. La parola chiave public
significa che qualsiasi metodo di qualsiasi classe può invocarli. (I quattro possibili livelli di accesso verranno trattati in seguito).
Passiamo ora ai tre campi d’istanza che conterranno i dati manipolati all’interno di un’istanza di Impiegato
.
private String nome;
private double stipendio;
private LocalDate dataAssunzione;
La parola chiave private
garantisce che solo i metodi della classe Impiegato
possano accedere a questi campi d’istanza. Nessun metodo esterno può leggerli o modificarli.
Perché evitare campi public
Potremmo usare public
con i campi d’istanza, ma sarebbe una pessima idea. Campi dati public
permetterebbero a qualsiasi parte del programma di leggere e modificare i valori, distruggendo l’incapsulamento. Qualsiasi metodo di qualsiasi classe potrebbe modificarli e – per esperienza – qualche codice ne approfitterà quando meno ce lo aspettiamo. Raccomandiamo vivamente di rendere private tutti i campi d’istanza.
Infine, osserviamo che due dei campi d’istanza sono a loro volta oggetti: nome
è un riferimento a String
e dataAssunzione
è un riferimento a LocalDate
. È normale che le classi contengano campi d’istanza di tipo classe.
Prime operazioni con i costruttori
Esaminiamo il costruttore della nostra classe Impiegato
.
public Impiegato(String n, double s, int anno, int mese, int giorno)
{
nome = n;
stipendio = s;
dataAssunzione = LocalDate.of(anno, mese, giorno);
}
Notiamo che il nome del costruttore coincide con il nome della classe. Il costruttore viene eseguito quando creiamo oggetti di tipo Impiegato
, assegnando ai campi iniziali lo stato desiderato.
Per esempio, quando creiamo un’istanza con:
Impiegato michela = new Impiegato("Michela Blu", 100000, 1950, 1, 1)
otteniamo i seguenti valori iniziali:
nome = "Michela Blu"
stipendio = 100000
dataAssunzione = LocalDate.of(1950, 1, 1) // 1 gennaio 1950
C’è una differenza importante fra costruttori e altri metodi:
un costruttore può essere chiamato solo in combinazione con l’operatore new
. Non possiamo applicare un costruttore a un oggetto esistente per reimpostarne i campi. Ad esempio,
michela.Impiegato("Giovanna Verdi", 250000, 1950, 1, 1) // ERRORE
genera un errore di compilazione.
Per ora teniamo presente che:
- Un costruttore ha lo stesso nome della classe.
- Una classe può avere più di un costruttore.
- Un costruttore può accettare zero, uno o più parametri.
- Un costruttore non restituisce valori.
- Un costruttore è sempre invocato con l’operatore
new
.
Confronto con C++
Nota C++: i costruttori funzionano in Java come in C++. Ricordiamo però che tutti gli oggetti Java sono creati nell’heap e che il costruttore deve essere combinato con new
. È un errore comune, per chi proviene da C++, dimenticare new
:
Impiegato michela("Michela Blu", 100000, 1950, 1, 1); // C++, non Java
Questo funziona in C++, ma non in Java.
Attenzione alle omonimie
Facciamo attenzione a non introdurre variabili locali con gli stessi nomi dei campi d’istanza. Il seguente costruttore non imposterà lo stipendio:
public Impiegato(String n, double s, ...)
{
String nome = n; // ERRORE
double stipendio = s; // ERRORE
...
}
Il costruttore dichiara variabili locali nome
e stipendio
che oscurano i campi d’istanza omonimi. Alcuni programmatori commettono questo errore quando digitano velocemente, abituati ad anteporre il tipo di dato. È un problema subdolo e difficile da individuare; occorre prestare attenzione a non usare nomi di variabili uguali a quelli dei campi d’istanza.
Dichiarare variabili locali con var
A partire da Java 10 possiamo dichiarare variabili locali con la parola chiave var
, omettendo il tipo se questo può essere dedotto dal valore iniziale. Ad esempio, invece di:
Impiegato serena = new Impiegato("Serena Verdi", 50000, 1989, 10, 1);
scriviamo semplicemente
var serena = new Impiegato("Serena Verdi", 50000, 1989, 10, 1);
Questa soluzione è comoda perché evita di ripetere il nome di tipo Impiegato
.
D’ora in poi useremo la notazione var
quando il tipo è ovvio dal lato destro senza necessità di conoscere le API Java. Non useremo però var
con tipi numerici come int
, long
o double
, in modo da non dover distinguere fra 0
, 0L
e 0.0
. Una volta acquisita maggiore familiarità con l’API Java, potremo impiegare var
più frequentemente.
Ricordiamo che var
può essere usato solo con variabili locali all’interno dei metodi. Dobbiamo sempre dichiarare esplicitamente i tipi di parametri e campi d’istanza.
Gestire i riferimenti null
Nelle lezioni precedenti abbiamo visto che una variabile di oggetto contiene un riferimento a un oggetto, oppure il valore speciale null
per indicare l’assenza di un oggetto.
Sebbene questo sembri un meccanismo conveniente per gestire situazioni particolari, come un nome o una data d’assunzione sconosciuti, dobbiamo usare la massima cautela con i valori null
.
Se applichiamo un metodo a un valore null
, si verifica una NullPointerException
:
LocalDate compleanno = null;
String s = compleanno.toString(); // NullPointerException
Si tratta di un errore grave, simile a un’eccezione indice fuori dai limiti. Se il programma non cattura l’eccezione, viene terminato. Normalmente i programmi non catturano questo tipo di eccezioni e si affidano alla diligenza dei programmatori per evitarle.
Quando definiamo una classe è buona norma stabilire chiaramente quali campi possano essere null
. Nel nostro esempio non vogliamo che i campi nome
o dataAssunzione
siano null
. (Non ci preoccupiamo di stipendio
: ha tipo primitivo e non può mai essere null
.)
Il campo dataAssunzione
è garantito non‐null
, perché inizializzato con un nuovo oggetto LocalDate
. Il campo nome
, invece, diventa null
se il costruttore riceve un argomento null
per n
.
Esistono due soluzioni.
Approccio “permissivo”: sostituire l’argomento null
con un valore significativo non‐null
:
if (n == null) nome = "sconosciuto"; else nome = n;
Dalla versione 9 Java mette a disposizione il metodo Objects.requireNonNullElse
:
public Impiegato(String n, double s, int anno, int mese, int giorno)
{
nome = Objects.requireNonNullElse(n, "sconosciuto");
...
}
Approccio “duro”: rifiutare un argomento null
:
public Impiegato(String n, double s, int anno, int mese, int giorno)
{
Objects.requireNonNull(n, "Il nome non può essere null");
nome = n;
...
}
Se qualcuno costruisce un oggetto Impiegato
con un nome null
, si genera una NullPointerException
. A prima vista il rimedio può sembrare poco utile, ma offre due vantaggi:
- Il report dell’eccezione contiene una descrizione del problema.
- Il report indica esattamente dove si trova il problema. In caso contrario, la
NullPointerException
si verificherebbe in un altro punto, rendendo difficile risalire all’argomento errato del costruttore.
Quando accettiamo riferimenti come parametri
Ogni volta che accettiamo un riferimento a oggetto come parametro di costruzione, chiediamoci se vogliamo davvero modellare valori che possono essere presenti o assenti. In caso negativo, è preferibile l’approccio “duro”.
Parametri impliciti ed espliciti
I metodi operano sugli oggetti e accedono ai loro campi d’istanza. Per esempio, il metodo
public void aumentaStipendio(double percentuale)
{
double aumento = stipendio * percentuale / 100;
stipendio += aumento;
}
imposta un nuovo valore per il campo stipendio
dell’oggetto su cui è invocato. Consideriamo la chiamata
serena.aumentaStipendio(5);
L’effetto è aumentare del 5% il valore di serena.stipendio
. In dettaglio, la chiamata esegue le istruzioni:
double aumento = serena.stipendio * 5 / 100;
serena.stipendio += aumento;
Il metodo aumentaStipendio
ha due parametri. Il primo, detto implicito, è l’oggetto di tipo Impiegato
che precede il nome del metodo. Il secondo, il numero tra parentesi dopo il nome del metodo, è un parametro esplicito. (Alcuni lo chiamano target o receiver della chiamata.)
I parametri espliciti compaiono nella dichiarazione del metodo—per esempio double percentuale
. Il parametro implicito non compare nella dichiarazione.
In ogni metodo, la parola chiave this
si riferisce al parametro implicito. Se lo desideriamo, possiamo scrivere il metodo così:
public void aumentaStipendio(double percentuale)
{
double aumento = this.stipendio * percentuale / 100;
this.stipendio += aumento;
}
Alcuni programmatori preferiscono questo stile perché distingue chiaramente tra campi d’istanza e variabili locali.
Differenze tra Java e C++
In C++ i metodi si definiscono normalmente fuori dalla classe:
void Impiegato::aumentaStipendio(double percentuale) // C++, non Java
{
...
}
Un metodo definito dentro una classe C++ è automaticamente inline:
class Impiegato
{
...
int getNome() { return nome; } // inline in C++
};
In Java tutti i metodi sono definiti dentro la classe, ma ciò non li rende inline. L’ottimizzazione inline è compito della macchina virtuale: il compilatore just-in-time rileva i metodi brevi, chiamati di frequente e non sovrascritti, e li ottimizza.
Benefici dell’incapsulamento
Esaminiamo più da vicino i metodi, piuttosto semplici, getNome
, getStipendio
e getDataAssunzione
.
public String getNome()
{
return nome;
}
public double getStipendio()
{
return stipendio;
}
public LocalDate getDataAssunzione()
{
return dataAssunzione;
}
Questi sono esempi evidenti di metodi di accesso (accessor methods). Poiché si limitano a restituire i valori dei campi d’istanza, talvolta vengono chiamati field accessors.
Non sarebbe più semplice rendere nome
, stipendio
e dataAssunzione
campi pubblici, invece di avere metodi di accesso separati?
Tuttavia, il campo nome
è di sola lettura. Una volta impostato nel costruttore, non esiste alcun metodo per modificarlo. In questo modo abbiamo la garanzia che il valore di nome
non verrà mai corrotto.
Il campo stipendio
non è di sola lettura, ma può essere cambiato solo dal metodo aumentaStipendio
. Se mai il valore risultasse errato, potremmo fare il debug solo di quel metodo. Se stipendio
fosse stato pubblico, il problema avrebbe potuto trovarsi ovunque.
Talvolta vogliamo sia leggere che scrivere il valore di un campo d’istanza. In tal caso occorrono tre elementi:
- Un campo dati privato;
- Un metodo di accesso pubblico; e
- Un metodo mutator pubblico.
È più laborioso di un singolo campo pubblico, ma i vantaggi sono considerevoli.
- Possiamo cambiare l’implementazione interna senza influenzare alcun codice al di fuori dei metodi della classe. Per esempio, se l’archiviazione del nome venisse cambiata in
String nome;
String cognome;
potremmo modificare getNome
per restituire
nome + " " + cognome
e il resto del programma rimarrebbe completamente ignaro di tale modifica.
- I metodi mutator possono eseguire controlli d’errore, mentre una semplice assegnazione a un campo potrebbe ometterli.
Ad esempio, un metodo
setStipendio
potrebbe verificare che lo stipendio non sia mai inferiore a 0.
Restituire riferimenti a oggetti mutabili
Non scriviamo metodi di accesso che restituiscono riferimenti a oggetti mutabili. Infatti, se un metodo restituisce un riferimento a un oggetto mutabile, il chiamante può modificarne lo stato. Ciò rompe l’incapsulamento e può causare errori difficili da individuare. Ad esempio, consideriamo la classe Impiegato
che contiene un campo dataAssunzione
di tipo Date
:
class Impiegato
{
private Date dataAssunzione;
...
public Date getDataAssunzione()
{
return dataAssunzione; // ERRORE
}
...
}
A differenza di LocalDate
, che non ha metodi mutator, la classe Date
possiede il mutator setTime
. Ciò rompe l’incapsulamento:
Impiegato serena = ...;
Date d = serena.getDataAssunzione();
double dieciAnniMillis = 10 * 365.25 * 24 * 60 * 60 * 1000;
d.setTime(d.getTime() - (long) dieciAnniMillis); // regaliamo dieci anni di anzianità!
Il problema è sottile: d
e serena.dataAssunzione
puntano allo stesso oggetto.
Se dobbiamo restituire un riferimento a un oggetto mutabile, cloniamolo prima:
class Impiegato
{
...
public Date getDataAssunzione()
{
return (Date) dataAssunzione.clone(); // OK
}
...
}
Come regola empirica, usiamo sempre clone
quando dobbiamo restituire una copia di un campo mutabile.
Privilegi di accesso basati sulla classe
Sappiamo che un metodo può accedere ai dati privati dell’oggetto su cui viene invocato. Ciò che spesso sorprende è che un metodo può accedere ai dati privati di tutti gli oggetti della propria classe.
Per esempio, consideriamo un metodo equals
che confronta due impiegati.
class Impiegato
{
...
public boolean equals(Impiegato altro)
{
return nome.equals(altro.nome);
}
}
Una chiamata tipica è
if (serena.equals(mario)) ...
Il metodo accede ai campi privati di serena
, il che non sorprende. Accede però anche ai campi privati di mario
.
Ciò è legale perché mario
è un oggetto di tipo Impiegato
, e un metodo della classe Impiegato
è autorizzato ad accedere ai campi privati di qualsiasi oggetto di tipo Impiegato
.
Analogia con C++
La stessa regola vale in C++. Un metodo può accedere alle caratteristiche private di qualsiasi oggetto della sua classe, non solo del parametro implicito.
Metodi privati
Quando implementiamo una classe rendiamo privati tutti i campi dati, perché campi pubblici sono pericolosi.
Ma che dire dei metodi? Sebbene la maggior parte dei metodi sia public
, in certe circostanze i metodi private
sono utili. Talvolta vogliamo suddividere il codice di un’elaborazione in più metodi d’aiuto (helper methods). In genere questi metodi ausiliari non dovrebbero far parte dell’interfaccia pubblica: possono trovarsi troppo vicino all’implementazione corrente oppure richiedere un protocollo d’uso particolare. Tali metodi danno il meglio se dichiarati private
.
Per implementare un metodo privato in Java basta cambiare la keyword public
in private
.
Rendendo un metodo privato non siamo obbligati a mantenerlo disponibile se l’implementazione cambia. Il metodo potrebbe diventare più difficile da implementare o addirittura inutile qualora cambi la rappresentazione dei dati: questo è irrilevante. L’importante è che, finché il metodo resta privato, possiamo essere certi che nessun altro codice lo utilizzi; di conseguenza, lo si può semplicemente rimuovere. Se invece un metodo è pubblico, non lo si può eliminare con altrettanta facilità perché altro codice potrebbe dipenderne.
Campi d’istanza final
Possiamo definire un campo d’istanza come final
. Un simile campo deve essere inizializzato quando l’oggetto viene costruito; occorre quindi garantire che, al termine di ogni costruttore, il campo abbia ricevuto un valore. Dopo l’inizializzazione, il campo non potrà più essere modificato. Per esempio, il campo nome
della classe Impiegato
può essere dichiarato final
, perché non cambia mai dopo la costruzione dell’oggetto—non esiste alcun metodo setNome
.
class Impiegato
{
private final String nome;
...
}
Il modificatore final
è particolarmente utile per campi il cui tipo è primitivo o appartiene a una classe immutabile (una classe è immutabile se nessuno dei suoi metodi altera lo stato dei propri oggetti; ad esempio, la classe String
è immutabile).
Per classi mutabili il modificatore final
può invece confondere. Consideriamo, per esempio, il campo
private final StringBuilder valutazioni;
che viene inizializzato nel costruttore di Impiegato
con
valutazioni = new StringBuilder();
La parola chiave final
indica soltanto che il riferimento memorizzato in valutazioni
non potrà mai puntare a un oggetto StringBuilder
diverso. L’oggetto, però, può essere modificato:
public void assegnaStellaDoro()
{
valutazioni.append(LocalDate.now() + ": Stella d’oro!\n");
}