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.

Consiglio

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.

Consiglio

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.
Consiglio

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.

Nota

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:

  1. Il report dell’eccezione contiene una descrizione del problema.
  2. 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.
Consiglio

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.

Consiglio

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.

  1. 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.

  1. 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.
Nota

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.

Consiglio

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");
}