Al momento stai visualizzando Stranger things in Java: costruttori

Stranger things in Java: costruttori

Questo articolo fa parte della serie “Stranger things in Java”, dedicata agli approfondimenti del linguaggio che ci permetteranno di padroneggiare anche gli scenari più strani che si possono presentare quando programmiamo. Tutti gli articoli sono ispirati dal contenuto dal libro “Java for Aliens” (in inglese) e dal libro “Il nuovo Java”.

In questo articolo, vedremo alcune situazioni in cui l’utilizzo di un concetto che è alla base della programmazione Java come il costruttore, può nascondere alcune insidie. In particolare, dopo aver chiarito alcune definizioni fondamentali, esploreremo i rapporti tra i costruttori e l’ereditarietà, i costruttori e il polimorfismo, ed il lavoro del compilatore.

 

Definizione e proprietà

Sappiamo già che un costruttore è considerato un metodo speciale, che per sua natura è adatto soprattutto all’inizializzazione delle variabili d’istanza. Un semplice esempio di costruttore è quello contenuto all’interno della seguente classe:

public class Libro {
    private String titolo;

    public Libro(String titolo) {
        this.titolo = titolo;
    }

    public String getTitolo() {
        return titolo;
    }
}

Notiamo che mentre i metodi vengono invocati mediante l’operatore dot (il simbolo “.”):

String titoloDelLibro = libro.getTitolo();

i costruttori vengono invocati tramite l’operatore new:

Libro libro = new Libro("Java for Aliens");

In particolare, a differenza di un metodo ordinario un costruttore ha quindi le seguenti caratteristiche:

  • ha lo stesso nome della classe cui appartiene;
  • non ha tipo di ritorno;
  • è chiamato automaticamente ogni volta che viene istanziato un oggetto della classe cui appartiene, relativamente a quell’oggetto;
  • è presente in ogni classe compilata.

L’ultimo punto è chiarito nella prossima sezione.

 

Costruttore di default

In ogni classe compilata è sempre presente un costruttore. Infatti, anche se una classe è sprovvista di un costruttore esplicitamente, il compilatore aggiungerà automaticamente all’interno del bytecode il cosiddetto costruttore di default. Il costruttore di default contiene una sola istruzione di cui parleremo tra breve, ed inoltre non ha parametri. Attenzione però, che un costruttore senza parametri esplicitamente dichiarato non è un costruttore di default. Per esempio, il costruttore presente nella seguente classe non è un costruttore di default, ma semplicemente un costruttore senza parametri:

public class Libro {
    public Libro() {
        System.out.println("Costruttore senza parametri (non di default)");
    }
}

Il compilatore in questo caso non aggiungerà un costruttore di default, perché un costruttore esplicito in questa classe esiste già. Si noti che la decisione del compilatore non dipende dal numero dei parametri presenti nel costruttore, ma solo dalla presenza di un costruttore esplicito o meno. Per esempio, non sarebbe aggiunto nessun costruttore di default, neanche nel caso del primo esempio di questo articolo. Infatti, il compilatore aggiunge il costruttore di default, solo per permettere alla classe di essere istanziata tramite l’operatore new. In questo modo, è possibile imparare a programmare ad oggetti senza conoscere il concetto di costruttore, e rendere la curva di apprendimento meno ripida nei primi tempi.

 

Costruttori ed ereditarietà

L’ereditarietà non è applicabile ai costruttori. Anche quando sono dichiarati pubblici, i costruttori non sono ereditati per un motivo molto semplice: il proprio nome. Per esempio, consideriamo la seguente classe LibroSuJava estensione della classe Libro:

public class LibroSuJava extends Libro {

}

Se i costruttori si ereditassero, la classe LibroSuJava erediterebbe un costruttore che si chiama Libro. In pratica è come se la classe LibroSuJava fosse scritta nel seguente modo:

public class LibroSuJava extends Libro {
    public Libro() {
        System.out.println("Costruttore senza parametri (non di default)");
    }
}

Ma un costruttore che si chiama Libro in una classe che si chiama LibroSuJava, non potrà mai essere chiamato! Infatti, per istanziare un oggetto dalla classe LibroSuJava, bisogna obbligatoriamente chiamare un costruttore chiamato LibroSuJava utilizzando l’operatore new.

Per esempio, se vogliamo istanziare un oggetto LibroSuJava scriveremo:

LibroSuJava libroSuJava = new LibroSuJava();

invocando il costruttore della classe LibroSuJava tramite l’operatore new.

Ora supponiamo di aver ereditato il costruttore Libro nella classe LibroSuJava. Per poter utilizzare tale costruttore dovremmo poter scrivere la seguente istruzione:

LibroSuJava libroSuJava = new Libro();

che però implica l’istanza di un oggetto della classe Libro, e non di un oggetto della classe LibroSuJava, ottenendo anche un errore in compilazione, perché non è possibile assegnare un reference di tipo LibroSuJava ad un oggetto di tipo Libro. Infatti, ecco l’output:

error: incompatible types: Libro cannot be converted to LibroSuJava
        LibroSuJava libroSuJava = new Libro();

 

Costruttori e paradigmi

Il fatto che i costruttori non siano ereditati dalle sottoclassi, è coerente con la sintassi del linguaggio, ma contemporaneamente è in contraddizione con i principi della programmazione ad oggetti. In particolare sembrano violati i paradigmi del riuso e dell’astrazione. Infatti, nel momento in cui lo sviluppatore ha deciso di implementare il meccanismo dell’ereditarietà, ha dovuto testarne la validità mediante la cosiddetta relazione is a (in italiano è un). Alla domanda: “un oggetto istanziato dalla candidata sottoclasse può considerarsi anche un oggetto della candidata superclasse?” ha infatti risposto affermativamente. In questo particolare caso, un libro su Java è un libro, e quindi deve avere tutte le caratteristiche di un libro. In particolare deve riutilizzarne anche il costruttore. Non potendolo ereditare però, l’astrazione e il riuso sembrano violati. Invece, Java è coerente con i paradigmi object oriented. Infatti, aggiungiamo un’altra proprietà alla definizione di costruttore data prima:

  • un qualsiasi costruttore (anche quello di default), come prima istruzione, invoca sempre un costruttore della superclasse.

Per esempio, aggiungiamo dei costruttori di convenienza alle classi Libro e LibroSuJava:

public class Libro {
    public Libro() {
        System.out.println("Costruito un Libro!");
    }
}

public class LibroSuJava extends Libro {
    public LibroSuJava() {
        System.out.println("Costruito un Libro su Java!");
    }
}

Il lettore, avendo appreso che i costruttori non sono ereditati, dovrebbe concludere che l’istanza di un LibroSuJava ottenuta mediante una sintassi del tipo:

new LibroSuJava(); /* N.B.: L’assegnazione di un reference non è 
 obbligatoria per istanziare un oggetto */

produrrebbe in output la seguente stringa:

Costruito un Libro!

Invece, l’output risultante sarà:

Costruito un Libro! 
Costruito un Libro su Java!

Questo perché, il costruttore LibroSuJava ha prima invocato il costruttore della superclasse Libro, e poi ha eseguito l’istruzione in esso contenuta. Infatti, la chiamata al costruttore di una superclasse, viene effettuata tramite la parola chiave super.

 

Costruttori, keyword super e interventi del compilatore

Se la parola chiave this come reference implicito all’oggetto corrente, possiamo definire la parola chiave super come reference implicito all’intersezione tra l’oggetto corrente e la sua superclasse. Questa keyword ci permette di accedere ai componenti della superclasse ed in particolare al suo costruttore. In ogni costruttore infatti, è sempre presente una chiamata al costruttore della superclasse, tramite una sintassi speciale che sfrutta la parola chiave super, che viene utilizzata come fosse un metodo a cui passare parametri. Anche in questo caso, se l’istruzione non è presente esplicitamente, sarà aggiunta nel bytecode in fase di compilazione. Per esempio, nella classe LibroSuJava, il costruttore verrà modificato dal compilatore nel seguente modo:

public class LibroSuJava extends Libro {
    public LibroSuJava() {
        // super(); //istruzione inserita se non fornita esplicitamente.
        System.out.println("Costruito un Libro su Java!");
    }
}

Ecco perché il costruttore (senza parametri) della classe Libro viene invocato dal costruttore della classe LibroSuJava. Possiamo esplicitare la chiamata a super, ma se non lo facciamo il compilatore aggiungerà implicitamente questa istruzione. La chiamata ad un costruttore della superclasse è quindi inevitabile.

Notare che anche il costruttore della classe Libro chiamerà il costruttore della sua superclasse Object, mediante un comando super inserito implicitamente in fase di compilazione.

Supponiamo ora di voler modificare la superclasse Libro come abbiamo visto nel primo esempio di questo articolo:

public class Libro {
    private String titolo;

    public Libro(String titolo) {
        this.titolo = titolo;
    }

    public String getTitolo() {
        return titolo;
    }
}

Questa classe può essere compilata correttamente, ma la sua sottoclasse LibroSuJava non più:

LibroSuJava.java:1: error: constructor Libro in class Libro 
  cannot be applied to given types;
public class LibroSuJava extends Libro {
       ^
  required: String
  found: no arguments
  reason: actual and formal argument lists differ in length
1 error

Questo è singolare, perché abbiamo modificato la superclasse, ma l’errore è stato introdotto nella sottoclasse. Interpretando il messaggio di errore del compilatore comunque, si può intuire cosa sia successo: tutto dipende dal lavoro nascosto del compilatore.

Il costruttore della sottoclasse infatti, tramite l’istruzione implicita super() ha cercato di invocare un costruttore della superclasse che non esiste: il costruttore senza parametri. Infatti, siccome abbiamo aggiunto un costruttore esplicitamente nella classe Libro, in essa non verrà più inserito implicitamente il costruttore di default dal compilatore. Quest’ultimo però, ha inserito un chiamata al costruttore senza parametri della superclasse Libro, come prima istruzione del costruttore della sottoclasse LibroSuJava (vedi codice). Ma ora il costruttore senza parametri (quello che era il costruttore di default) nella superclasse Libro non c’è più.

Per risolvere il problema, la soluzione migliore sembra modificare la classe LibroSuJava nel seguente modo:

public class LibroSuJava extends Libro {
    public LibroSuJava(String titolo) {
        super(titolo);
    }
}

Con la sintassi precedente abbiamo invocato esplicitamente il costruttore della superclasse che prende in input una stringa.

Se avessimo più costruttori nella superclasse potremmo scegliere quale chiamare. Per esempio, se la classe Libro definisse più costruttori nel seguente modo:

public class Libro {
    public Libro(String titolo, String autore) {
        this(titolo); // Chiamata al secondo costruttore (vedi prossima sezione)
        setAutore(autore);
    }

    public Libro(String titolo) {
        this.titolo = titolo;
    } 
    // resto del codice omesso
}

la classe LibroSuJava potrebbe chiamare a seconda del caso il costruttore più adeguato:

public class LibroSuJava extends Libro {
    public LibroSuJava(String titolo) {
        super(titolo);
    }

    public LibroSuJava(String titolo, String autore) {
        super(titolo, autore);
    }
    //...
}

 

Overload, costruttori e parola chiave this

Ora sappiamo che possiamo invocare un costruttore di una superclasse mediante la parola chiave super, ma in realtà possiamo anche utilizzare la parola chiave this per invocare i costruttori della stessa classe. Segue un semplice esempio di overload di costruttori che utilizza la keyword this come una chiamata a un metodo costruttore della stessa classe:

public class Cliente {
    private String nome;
    private String indirizzo;
    private String numeroDiTelefono;

    public Cliente() {
        // costruttore inserito esplicitamente (non di default)
    }

    public Cliente(String nome) {
        this.nome = nome;
    }

    public Cliente(String nome, String indirizzo) {
        this(nome);
        this.indirizzo = indirizzo;
    }
    
    public Cliente(String nome, String indirizzo, String numeroDiTelefono) {
        this(nome, indirizzo);
        this.numeroDiTelefono = numeroDiTelefono;
    }
    // resto del codice omesso
}

Possiamo osservare come i costruttori si invocano l’un l’altro evitando di duplicare codice già scritto.

In questo modo verrà favorito il riuso del codice.

 

Precisazione sulle chiamate tra costruttori

La chiamata tramite super ad un costruttore di una superclasse o tramite this ad un costruttore della classe stessa, può essere usata solo come prima istruzione in un costruttore. Questo implica che solo un’istruzione tra super o this sarà presente in un costruttore. Se si inserisce esplicitamente il comando this, allora la chiamata a supernon può essere inserita nello stesso costruttore. Si noti però, che se un costruttore chiama un altro costruttore mediante l’istruzione this, il costruttore chiamato a sua volta chiama sicuramente, o un terzo costruttore tramite un altro comando this, o un costruttore della superclasse con il comando super. Insomma il costruttore della superclasse verrà invocato in qualsiasi caso prima o poi. Per esempio, la seguente classe rappresenta un carattere (font) da utilizzare in un editor:

public class Carattere {
    private String tipo;
    private int dimensione;

    public Carattere(String tipo) {
        this(tipo, 12);
    }

    public Carattere(String tipo, int dimensione) {
        //qui verrà inserito un super() dal compilatore
        setTipo(tipo);
        setDimensione(dimensione);
    }
    //Omessi metodi set e get...
}

Essa dichiara un primo costruttore che invoca il secondo con il comando this(). Nel secondo costruttore però, il compilatore inserirà implicitamente l’istruzione super(). La chiamata al costruttore della superclasse (in questo caso è la classe Object) è stata solo rimandata.

I comandi per invocare costruttori tramite le keyword this e super possono essere inseriti solo come prima istruzione di un costruttore, e mai in un metodo che non sia un costruttore.

 

Costruttori e override

Sappiamo che non esiste ereditarietà di costruttori, anche se l’istanza di una sottoclasse, grazie al comando super, provocherà sempre una chiamata ad un costruttore della superclasse. In qualsiasi caso, non esistendo ereditarietà, non ha senso parlare di override di costruttori. Ma c’è una situazione che riguarda i costruttori e l’ordine in cui vengono eseguiti rispetto all’inizializzazione degli attributi, che merita la nostra attenzione.

Siccome lo scenario è un po’ complesso da spiegare in astratto, caliamoci nel contesto di un esempio di codice. Consideriamo la seguente classe astratta Attrezzo:

abstract class Attrezzo {
    public Attrezzo() {
        eseguiLavoro();
    }

    public void eseguiLavoro() {
        System.out.println("In azione...");
    }
}

Questa classe dichiara un costruttore che invoca il metodo eseguiLavoro definito all’interno della stessa classe. Ora consideriamo la seguente classe Martello che estende Attrezzo e che fa override del metodo eseguiLavoro:

class Martello extends Attrezzo {
    String bersaglio ="chiodo";

    public Martello () {
        //chiamata implicita a super();
    }

    @Override
    public void eseguiLavoro() {
        System.out.println("Sto martellando il " + data);
    }
}

Consideriamo infine la seguente classe che istanzia la sottoclasse Martello:

public class TestAttrezzo {
    public static void main(String[] args) {
        Attrezzo attrezzo = new Martello();
    }
}

Queste classi vengono compilate correttamente, ma lanciando la classe TestAttrezzo, al runtime otterremo l’output:

Sto martellando il null

Questo perché, la variabile bersaglio viene utilizzata prima che possa essere inizializzata nella sottoclasse, e quindi ha ancora valore null. In particolare, nel momento in cui abbiamo invocato il costruttore della classe Martello, come prima istruzione (implicita) è stato invocato il costruttore della superclasse Attrezzo, che a sua volta ha invocato direttamente il metodo eseguiLavoro che ha stampato il messaggio quando la variabile bersaglio non era ancora stata inizializzata.

In questo caso, abbiamo ottenuto solo un output errato, ma in realtà l’utilizzo di reference con  valori null, può portare facilmente il runtime ad essere interrotto da eccezioni di tipo NullPointerException. Per esempio, se modifichiamo il metodo eseguiLavoro nella sottoclasse nel seguente modo:

public void eseguiLavoro() {
    if (!bersaglio.isEmpty()) {
        System.out.println("Sto martellando il "+ data);
    }
}

allora il programma sarà interrotto e stamperà il seguente output:

Exception in thread "main" java.lang.NullPointerException
    at Martello.eseguiLavoro(TestAttrezzo.java:17)
    at Attrezzo.<init>(TestAttrezzo.java:3)
    at Martello.<init>(TestAttrezzo.java:12)
    at TestAttrezzo.main(TestAttrezzo.java:24)

 

Conclusioni

In questo articolo, abbiamo visto come un argomento base del linguaggio come i costruttori, in realtà nasconda anche scenari complessi. Come al solito, avere una solida base teorica, ci permetterà di gestire tutte le situazioni senza sorprese. Con le parole chiavi this e super, riusciremo a gestire correttamente il riuso e l’astrazione anche con i costruttori, nonostante essi non siano ereditati e di conseguenza non sia possibile riscriverli nelle sottoclassi. Abbiamo anche visto come è importante conoscere il comportamento del compilatore. Infatti, benché gli inserimenti di codice implicito nel bytecode siano stati progettati per facilitare l’apprendimento e ridurre la verbosità del linguaggio, in realtà possono portare anche a malfunzionamenti del codice se non padroneggiati opportunamente.

 

Note dell’autore

Questo articolo è basto su alcuni paragrafi dei capitoli 3, 5 e 7 del libro “Il nuovo Java” e del capitolo 8 del mio libro in inglese “Java for Aliens”.

Lascia un commento