I livelli e i modificatori di accesso in Java

Questo articolo descrive in modo dettagliato il concetto dei livelli e dei modificatori “di accesso” in Java.

il
Senior Java developer, Collaboratore di IProgrammatori

Il linguaggio Java, come altri linguaggi di programmazione, prevede la possibilità di applicare dei “livelli di accesso” ai tipi (classi, interfacce, ecc...) e ai membri dei tipi. Un livello di accesso, detto in modo molto generico, serve a specificare un certo grado di visibilità di un elemento nei confronti di altre parti di un programma.

L’argomento dei livelli di accesso (e dei relativi modificatori di accesso) è molto basilare ma estremamente importante e fondamentale in Java. L’utilizzo appropriato dei livelli di accesso infatti permette anche di mettere in pratica i buoni principi della programmazione ad oggetti, tra cui sicuramente i concetti di encapsulation, data-hiding e inoltre i ben noti design pattern.

L’obiettivo di questo articolo è di spiegare in maniera molto dettagliata tutti i livelli e i modificatori di accesso. Nella prima parte che segue verrà data una spiegazione generale dei livelli di accesso poi, in seguito, ciascun livello verrà ripreso e descritto più approfonditamente utilizzando anche del codice di esempio. Il codice utilizzato come esempio, replicato di volta in volta cambiando un livello di accesso, si basa su 5 semplici classi suddivise in due package. Nel codice verrà chiarito con dei commenti cosa è e cosa non è lecito in base al livello di accesso.

Livelli e modificatori di accesso

Nel linguaggio Java esistono esattamente quattro livelli di accesso, sono quelli elencati di seguito partendo dal livello più “ristretto” (il meno permissivo) fino a quello più “ampio” (il più permissivo):

  • private   (più “ristretto”)
  • package
  • protected
  • public   (più “ampio”)

Questi elencati sono i livelli “concettuali”, ciascuno di essi rappresenta un concetto come è definito dalle specifiche del linguaggio Java. Il significato (in breve) dei livelli è il seguente:

  • un membro con livello di accesso private è accessibile solamente all’interno della classe in cui è definito il membro

  • un membro con livello di accesso package è accessibile solamente dalle classi e sotto-classi nello stesso package della classe in cui è definito il membro

  • un membro con livello di accesso protected è accessibile dalle classi e sotto-classi nello stesso package della classe in cui è definito il membro e in più è accessibile anche da qualunque altra sotto-classe in altri package ma solamente per effetto della ereditarietà

  • un membro con livello di accesso public è accessibile da qualunque classe in qualunque package

I livelli public e private sono quelli più semplici da comprendere e rappresentano in pratica i due estremi opposti, ovvero rispettivamente i concetti di: “accessibile a tutte le classi” e “accessibile a nessun’altra classe”. Invece package e protected sono due livelli intermedi che differiscono tra di loro solo per un dettaglio veramente molto “fine” e importante che verrà spiegato meglio, in maniera più approfondita, nelle sezioni successive.

Questi di cui si è parlato finora, come già detto, sono i livelli “concettuali”. In Java esistono delle parole chiave che sono dei “modificatori”, cioè sono quelle parole che il programmatore scrive materialmente nella dichiarazione di un elemento per cambiarne il significato/utilizzo. Tra tutti i modificatori, ne esistono tre che sono più specificatamente dei modificatori “di accesso”, ovvero servono appunto per assegnare un livello di accesso.

I modificatori di accesso sono i seguenti:

  • private
  • protected
  • public

Quindi è bene chiarirlo e fissarlo fin da subito: i livelli di accesso (quelli “concettuali”) sono quattro mentre i modificatori di accesso sono solo tre.

Non esiste una parola apposita per indicare il livello package. Il livello package si ottiene semplicemente omettendo di scrivere public/protected/private. Il livello package viene detto solitamente anche il livello di default proprio perché è quello predefinito che risulta quando non si specifica esplicitamente un modificatore di accesso.

ATTENZIONE: in Java esistono le parole chiave package e default. Queste parole non sono riferite in alcun modo ai livelli/modificatori di accesso e non possono quindi essere usate in questo senso. La parola chiave package si può utilizzare solo all’inizio del sorgente per dichiarare il package a cui appartengono i tipi definiti nel sorgente, mentre la parola chiave default si può usare solo in tre contesti molto specifici: 1) come “caso” di default nella istruzione switch; 2) per dichiarare il valore di default di un elemento di una annotation (da Java 5); 3) per definire in una interface un metodo di default, cioè che possiede un corpo con del codice (da Java 8).

Come è stato detto all’inizio, il livello di accesso è applicabile ai tipi e ai membri dei tipi. Per “tipi” si deve intendere in senso generale tutti i tipi reference che è possibile definire in Java, ovvero:

  • classi
  • interfacce
  • enum (da Java 5)
  • annotation (da Java 5)
  • record (da Java 14)

I tipi possono essere “innestati” dentro altri tipi (si parla di inner/nested class) oppure possono essere tipi dichiarati a sé stanti (vengono detti tipi top-level). I tipi top-level possono avere solo i livelli di accesso public e package. A livello pratico vuol dire che si può scrivere public oppure si può omettere public, non ci sono altre possibilità per il livello di accesso.

public class TopLevelA { }      // OK, livello public
class TopLevelB { }             // OK, livello package (implicito)

private class TopLevelC { }     // NO, errore
protected class TopLevelD { }   // NO, errore

I membri dei tipi invece possono avere uno qualunque dei quattro livelli di accesso. Questo vale quindi per:

  • costruttori
  • variabili “di istanza” (non static) e “di classe” (static)
  • metodi “di istanza” (non static) e “di classe” (static)
  • tipi “innestati” (inner/nested class)

Esistono comunque dei casi particolari in cui certi elementi hanno già per specifica del linguaggio un livello di accesso prefissato che non si può cambiare. Ci sono due casi che sono sicuramente da citare e ricordare:

  • i metodi astratti e i metodi di default (Java 8) delle interfacce sono sempre implicitamente public (è lecito, volendo, mettere esplicitamente la parola public)
  • i costruttori delle enum sono sempre implicitamente private (è lecito, volendo, mettere esplicitamente la parola private)

Prima di vedere in modo più approfondito i livelli di accesso, è bene chiarire un aspetto che è da tenere sempre ben presente. Quando si deve stabilire se un membro è accessibile, bisogna sempre verificare prima l’accessibilità del tipo che contiene il membro. Se già il tipo non è accessibile, non lo sarà neanche il membro! Esempio molto basilare: in un package p1 c’è una classe C con livello package che possiede un metodo m1 con livello public. In un altro package p2 si vorrebbe usare la classe C per invocare m1. Il problema è che nel package p2 la classe C non è accessibile (per via del livello package) e quindi non sarà accessibile neanche m1, sebbene sia public.

Il livello di accesso private

Un membro con livello di accesso private è accessibile solamente all’interno della classe in cui è definito il membro. Questo è il livello più ristretto possibile che esiste in Java e serve generalmente quando si vuole nascondere dati ma anche “comportamenti” all’interno della classe in modo che non siano accessibili dall’esterno.

Nell’esempio di riferimento che segue, il metodo getNome() di Persona è marcato private, quindi è accessibile soltanto all’interno della classe Persona. Nel codice di Persona ci sono due riferimenti evidenti a getNome(): il primo è una invocazione di getNome() su un reference ad un oggetto Persona, il secondo è una invocazione di getNome() sul this (implicito), ovvero l’oggetto su cui è invocato il toString(). Entrambe le invocazioni sono lecite perché si trovano dentro la classe Persona.

Al contrario invece:

  • la classe Prova1, pur trovandosi nello stesso package di Persona, non può accedere a getNome()
  • la sotto-classe Studente, pur trovandosi nello stesso package di Persona, non può accedere a getNome()
  • la classe Prova2 (nell’altro package due) non può accedere a getNome()
  • la sotto-classe Impiegato (nell’altro package due) non può accedere a getNome()
package uno;

public class Persona {
    private String nome;

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

    public Persona(Persona altraPersona) {
        this.nome = altraPersona.getNome();   // OK, getNome() è accessibile
    }

    private String getNome() {
        return nome;
    }

    public String toString() {
        return "Persona " + getNome();   // OK, getNome() è accessibile
    }
}
package uno;

public class Prova1 {
    public static void stampa(Persona persona) {
        System.out.println(persona.getNome());   // ERRORE, getNome() non è accessibile
    }
}
package uno;

public class Studente extends Persona {
    public Studente(String nome) {
        super(nome);
    }

    public boolean nomeUguale(Persona altraPersona) {
        return getNome().equals(altraPersona.getNome());   // ERRORE, getNome() non è accessibile
    }

    public String toString() {
        return "Studente " + getNome();   // ERRORE, getNome() non è accessibile
    }
}
package due;

import uno.Persona;

public class Prova2 {
    public static void stampa(Persona persona) {
        System.out.println(persona.getNome());   // ERRORE, getNome() non è accessibile
    }
}
package due;

import uno.Persona;

public class Impiegato extends Persona {
    public Impiegato(String nome) {
        super(nome);
    }

    public boolean nomeUguale(Persona altraPersona) {
        return getNome().equals(altraPersona.getNome());   // ERRORE, getNome() non è accessibile
    }

    public String toString() {
        return "Impiegato " + getNome();   // ERRORE, getNome() non è accessibile
    }
}

Il livello di accesso package

Un membro con livello di accesso package è accessibile solamente dalle classi e sotto-classi nello stesso package della classe in cui è definito il membro. Il livello package concettualmente permette di dare “fiducia” alle altre classi nello stesso package impedendo però l’accesso da altri differenti package.

Nell’esempio di riferimento che segue, il metodo getNome() di Persona non ha un modificatore di accesso, quindi prende automaticamente il livello package. Il metodo getNome() diventa accessibile a tutte le classi nello stesso package della classe Persona. In particolare bisogna osservare i due seguenti effetti del livello package:

  • la classe Prova1 non è in relazione con Persona (non estende Persona) ma trovandosi nello stesso package può accedere “dall’esterno” al metodo getNome() avendo o ricevendo in qualche modo il riferimento ad un oggetto Persona

  • la classe Studente estende Persona e in questo modo “eredita” il metodo getNome() a cui può accedere; inoltre può anche accedere “dall’esterno” a getNome() avendo o ricevendo in qualche modo il riferimento ad un oggetto Persona

Al contrario invece:

  • la classe Prova2 (nell’altro package due) non può accedere a getNome()
  • la sotto-classe Impiegato (nell’altro package due) non può accedere a getNome()
package uno;

public class Persona {
    private String nome;

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

    public Persona(Persona altraPersona) {
        this.nome = altraPersona.getNome();   // OK, getNome() è accessibile
    }

    String getNome() {      // nessun modificatore di accesso = livello package
        return nome;
    }

    public String toString() {
        return "Persona " + getNome();   // OK, getNome() è accessibile
    }
}
package uno;

public class Prova1 {
    public static void stampa(Persona persona) {
        System.out.println(persona.getNome());   // OK, getNome() è accessibile
    }
}
package uno;

public class Studente extends Persona {
    public Studente(String nome) {
        super(nome);
    }

    public boolean nomeUguale(Persona altraPersona) {
        return getNome().equals(altraPersona.getNome());   // OK, getNome() è accessibile
    }

    public String toString() {
        return "Studente " + getNome();   // OK, getNome() è accessibile
    }
}
package due;

import uno.Persona;

public class Prova2 {
    public static void stampa(Persona persona) {
        System.out.println(persona.getNome());   // ERRORE, getNome() non è accessibile
    }
}
package due;

import uno.Persona;

public class Impiegato extends Persona {
    public Impiegato(String nome) {
        super(nome);
    }

    public boolean nomeUguale(Persona altraPersona) {
        return getNome().equals(altraPersona.getNome());   // ERRORE, getNome() non è accessibile
    }

    public String toString() {
        return "Impiegato " + getNome();   // ERRORE, getNome() non è accessibile
    }
}

Il livello di accesso protected

Un membro con livello di accesso protected è accessibile dalle classi e sotto-classi nello stesso package della classe in cui è definito il membro e in più è accessibile anche da qualunque altra sotto-classe in altri package ma solo per effetto della ereditarietà. Il livello protected è leggermente più “ampio” rispetto al package per un dettaglio molto particolare che sarà approfondito tra poco.

Nell’esempio di riferimento che segue, il metodo getNome() di Persona è marcato protected, quindi è accessibile da tutte le classi nello stesso package della classe Persona e inoltre, in aggiunta, è anche accessibile dalle sotto-classi nell’altro package due ma soltanto per via della ereditarietà. In particolare è bene osservare i seguenti effetti:

  • la classe Prova1 può accedere “dall’esterno” al metodo getNome() avendo o ricevendo in qualche modo il riferimento ad un oggetto Persona (nota: esattamente uguale al livello package)

  • la classe Studente può accedere a getNome() perché lo “eredita” da Persona e inoltre può anche accedere “dall’esterno” a getNome() avendo o ricevendo in qualche modo il riferimento ad un oggetto Persona (nota: esattamente uguale al livello package)

In più:

  • la classe Impiegato (nell’altro package due) estende Persona e per via del livello protectederedita” il metodo getNome() a cui può accedere ma solamente per questo effetto particolare della ereditarietà

Al contrario invece:

  • la classe Prova2 (nell’altro package due) non può accedere a getNome()
  • la classe Impiegato (nell’altro package due) non può accedere a getNome() “dall’esterno” su un reference ad un oggetto Persona

Differenza package vs protected

La differenza fondamentale e importante tra i livelli package e protected riguarda il terzo punto elencato. Il livello protected è da vedere concettualmente come: package + accessibilità dalle sotto-classi in altri package solo per ereditarietà.

L’accessibilità di un membro protected in altri package è dovuta solo ed esclusivamente al fatto che una sotto-classe “eredita” il membro quindi sostanzialmente lo possiede, un po’ come se il membro fosse stato definito proprio nella sotto-classe. La classe Impiegato infatti può invocare this.getNome() perché in pratica è su “sé stesso” avendolo ereditato da Persona ma attenzione, non può accedere al metodo getNome() su di un reference ad un oggetto Persona come se la invocazione fosse “dall’esterno”. Questo è il punto essenziale e critico del livello protected.

package uno;

public class Persona {
    private String nome;

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

    public Persona(Persona altraPersona) {
        this.nome = altraPersona.getNome();   // OK, getNome() è accessibile
    }

    protected String getNome() {
        return nome;
    }

    public String toString() {
        return "Persona " + getNome();   // OK, getNome() è accessibile
    }
}
package uno;

public class Prova1 {
    public static void stampa(Persona persona) {
        System.out.println(persona.getNome());   // OK, getNome() è accessibile
    }
}
package uno;

public class Studente extends Persona {
    public Studente(String nome) {
        super(nome);
    }

    public boolean nomeUguale(Persona altraPersona) {
        return getNome().equals(altraPersona.getNome());   // OK, getNome() è accessibile
    }

    public String toString() {
        return "Studente " + getNome();   // OK, getNome() è accessibile
    }
}
package due;

import uno.Persona;

public class Prova2 {
    public static void stampa(Persona persona) {
        System.out.println(persona.getNome());   // ERRORE, getNome() non è accessibile
    }
}
package due;

import uno.Persona;

public class Impiegato extends Persona {
    public Impiegato(String nome) {
        super(nome);
    }

    public boolean nomeUguale(Persona altraPersona) {
        // OK, getNome() è accessibile sul this
        // ERRORE, getNome() non è accessibile su altraPersona !
        return getNome().equals(altraPersona.getNome());
    }

    public String toString() {
        return "Impiegato " + getNome();   // OK, getNome() è accessibile
    }
}

Il livello di accesso public

Un membro con livello di accesso public è accessibile da qualunque classe e sotto-classe in qualunque package. Questo in pratica è il livello più ampio possibile in Java e non pone alcuna restrizione per l’accesso ad un membro.

In sostanza, un membro con livello di accesso public è accessibile “dall’esterno” da qualunque altra classe in qualunque package e inoltre è anche “ereditato”, e quindi accessibile, da qualunque altra sotto-classe in qualunque package.

package uno;

public class Persona {
    private String nome;

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

    public Persona(Persona altraPersona) {
        this.nome = altraPersona.getNome();   // OK, getNome() è accessibile
    }

    public String getNome() {
        return nome;
    }

    public String toString() {
        return "Persona " + getNome();   // OK, getNome() è accessibile
    }
}
package uno;

public class Prova1 {
    public static void stampa(Persona persona) {
        System.out.println(persona.getNome());   // OK, getNome() è accessibile
    }
}
package uno;

public class Studente extends Persona {
    public Studente(String nome) {
        super(nome);
    }

    public boolean nomeUguale(Persona altraPersona) {
        return getNome().equals(altraPersona.getNome());   // OK, getNome() è accessibile
    }

    public String toString() {
        return "Studente " + getNome();   // OK, getNome() è accessibile
    }
}
package due;

import uno.Persona;

public class Prova2 {
    public static void stampa(Persona persona) {
        System.out.println(persona.getNome());   // OK, getNome() è accessibile
    }
}
package due;

import uno.Persona;

public class Impiegato extends Persona {
    public Impiegato(String nome) {
        super(nome);
    }

    public boolean nomeUguale(Persona altraPersona) {
        return getNome().equals(altraPersona.getNome());   // OK, getNome() è accessibile
    }

    public String toString() {
        return "Impiegato " + getNome();   // OK, getNome() è accessibile
    }
}

Ulteriori informazioni sul livello protected

Il livello protected è indubbiamente quello più particolare e critico da apprendere per via della sua caratteristica distintiva rispetto al livello package. In questa sezione si vuole evidenziare un altro aspetto del protected da tenere bene presente.

Partiamo con un breve e parziale riassunto del codice di esempio: nel package uno c’è la classe Persona e nel package due c’è la sotto-classe Impiegato.

package uno;

public class Persona {
    // .....

    protected String getNome() {
        return nome;
    }

    // .....
}
package due;

import uno.Persona;

public class Impiegato extends Persona {
    public Impiegato(String nome) {
        super(nome);
    }

    // .....
}

Ora, una questione potrebbe essere questa: se nel package due ci fosse un’altra classe es. Prova3 non in relazione né con Persona né con Impiegato, può accedere a getNome() usando un reference di tipo Impiegato?

In pratica una cosa del tipo:

package due;

public class Prova3 {
    public static void main(String[] args) {
        Impiegato impiegato = new Impiegato("Mario");
        System.out.println(impiegato.getNome());   // ERRORE, getNome() non è accessibile
    }
}

Il livello protected di getNome() in Persona permette alla sotto-classe Impiegato, nell’altro package due, di “ereditare” il metodo getNome() in modo da poterlo invocare direttamente sul this. Ma questo effetto non rende automaticamente “pubblico” il metodo alle altre classi del package di Impiegato, nemmeno usando un reference di tipo Impiegato. Solamente le sotto-classi di Persona (quindi anche le eventuali sotto-classi di Impiegato, anche in altri package) possono accedere al getNome() solo per questo particolare effetto della ereditarietà.

Quando c’è una super-classe nel package x e una sotto-classe in un differente package y, il livello protected in pratica rappresenta una sorta di “accordo privato” solo tra la super-classe e la sotto-classe. Questo “accordo” resta valido lungo tutta la linea di ereditarietà che sta “sotto” la classe base (che è Persona nell’esempio). Ci potrebbe essere infatti un altro package es. tre con la classe Manager che estende Impiegato, fatta così:

package tre;

import due.Impiegato;

public class Manager extends Impiegato {
    public Manager(String nome) {
        super(nome);
    }

    public String toString() {
        return "Manager " + getNome();   // OK, getNome() è accessibile
    }
}

Il metodo getNome() resta accessibile anche in Manager ma solo per questo effetto particolare della ereditarietà, perché lo “eredita” da Impiegato che a sua volta lo “eredita” da Persona.