Classi Annidate in Java

Edoardo Midali
Edoardo Midali

Le classi annidate rappresentano uno dei meccanismi più sofisticati di Java per organizzare il codice e creare strutture dati complesse. Permettendo di definire classi all’interno di altre classi, offrono un controllo granulare sull’incapsulamento e facilitano l’implementazione di pattern di design avanzati. Esistono quattro tipi principali di classi annidate, ognuna con caratteristiche e casi d’uso specifici.

Tipologie di Classi Annidate

Java distingue tra classi annidate statiche e non statiche, con ulteriori suddivisioni basate su dove e come vengono definite. Questa classificazione determina l’accesso alle variabili della classe esterna e il ciclo di vita delle istanze.

Nested Classes Statiche

Le classi annidate statiche sono classi indipendenti che risiedono logicamente all’interno di un’altra classe ma non hanno accesso automatico alle istanze della classe esterna. Sono essenzialmente classi di primo livello che utilizzano la classe esterna come namespace.

public class Matematica {
    private static final double PI = 3.14159;

    // Classe annidatta statica - no accesso alle istanze di Matematica
    public static class Punto {
        private double x, y;

        public Punto(double x, double y) {
            this.x = x;
            this.y = y;
        }

        public double distanzaDallOrigine() {
            return Math.sqrt(x * x + y * y);
        }

        // Può accedere ai membri statici della classe esterna
        public double calcolaCirconferenza(double raggio) {
            return 2 * PI * raggio; // Accesso a PI statico
        }

        // Non può accedere a membri di istanza senza un riferimento esplicito
        // public void metodoCheNonFunziona() {
        //     return this.metodoDiIstanza(); // ERRORE
        // }
    }

    private int valoreDiIstanza = 42;

    public void metodoDiIstanza() {
        System.out.println("Metodo di istanza");
    }
}

// Utilizzo della classe annidata statica
Matematica.Punto punto = new Matematica.Punto(3.0, 4.0);
double distanza = punto.distanzaDallOrigine();

Le classi annidate statiche sono ideali per implementare strutture dati di supporto, builder pattern e utility classes che sono logicamente correlate alla classe esterna ma operativamente indipendenti.

Inner Classes (Classi Annidate Non Statiche)

Le inner classes mantengono un riferimento implicito all’istanza della classe esterna che le ha create. Questo legame permette accesso completo a tutti i membri della classe esterna, inclusi quelli privati, ma richiede che esista sempre un’istanza della classe esterna.

public class Biblioteca {
    private List<String> libri;
    private String nome;

    public Biblioteca(String nome) {
        this.nome = nome;
        this.libri = new ArrayList<>();
    }

    public void aggiungiLibro(String titolo) {
        libri.add(titolo);
    }

    // Inner class - ha accesso a tutte le variabili di istanza
    public class Catalogatore {
        private String metodoCatalogazione;

        public Catalogatore(String metodo) {
            this.metodoCatalogazione = metodo;
        }

        public void catalogaLibri() {
            System.out.println("Catalogando " + libri.size() + " libri nella biblioteca " + nome);
            // Accesso diretto ai membri privati della classe esterna

            for (String libro : libri) { // Accesso alla lista privata
                System.out.println("Catalogando: " + libro + " con metodo " + metodoCatalogazione);
            }
        }

        public void aggiungiECataloga(String titoloNuovo) {
            // Può chiamare metodi della classe esterna
            Biblioteca.this.aggiungiLibro(titoloNuovo);
            this.catalogaLibri();
        }

        // Accesso all'istanza della classe esterna
        public Biblioteca getBiblioteca() {
            return Biblioteca.this; // Riferimento esplicito alla classe esterna
        }
    }

    public Catalogatore creaCatalogatore(String metodo) {
        return new Catalogatore(metodo);
    }
}

// Utilizzo dell'inner class
Biblioteca biblioteca = new Biblioteca("Centrale");
biblioteca.aggiungiLibro("1984");
biblioteca.aggiungiLibro("Il Signore degli Anelli");

Biblioteca.Catalogatore catalogatore = biblioteca.creaCatalogatore("Dewey");
catalogatore.catalogaLibri();

Classi Locali

Le classi locali sono definite all’interno di blocchi di codice, tipicamente all’interno di metodi. Hanno accesso alle variabili finali o effettivamente finali del metodo contenente, oltre a tutti i membri della classe esterna.

public class ProcessoreDati {
    private String nomeProcessor;
    private List<String> log;

    public ProcessoreDati(String nome) {
        this.nomeProcessor = nome;
        this.log = new ArrayList<>();
    }

    public void elaboraDati(List<Integer> numeri, String tipoElaborazione) {
        final String timestamp = LocalDateTime.now().toString();
        int contatoreElaborazioni = 0; // Effettivamente finale

        // Classe locale definita all'interno del metodo
        class ElaboratoreLocale {
            private String strategia;

            public ElaboratoreLocale(String strategia) {
                this.strategia = strategia;
            }

            public List<Integer> elabora() {
                List<Integer> risultati = new ArrayList<>();

                for (Integer numero : numeri) { // Accesso al parametro del metodo
                    int risultato = switch (strategia) {
                        case "quadrato" -> numero * numero;
                        case "doppio" -> numero * 2;
                        default -> numero;
                    };
                    risultati.add(risultato);
                }

                // Accesso a variabili del metodo e della classe esterna
                log.add("Elaborazione " + strategia + " completata alle " + timestamp);
                // contatoreElaborazioni++; // ERRORE: deve essere final o effettivamente final

                return risultati;
            }
        }

        ElaboratoreLocale elaboratore = new ElaboratoreLocale(tipoElaborazione);
        List<Integer> risultati = elaboratore.elabora();

        System.out.println("Risultati elaborazione " + tipoElaborazione + ": " + risultati);
    }

    public List<String> getLog() {
        return new ArrayList<>(log);
    }
}

Classi Anonime

Le classi anonime sono classi senza nome che estendono una classe esistente o implementano un’interfaccia. Sono definite e istanziate simultaneamente, tipicamente utilizzate per implementazioni di callback o listener.

public class GestoreEventi {
    private List<String> eventi;

    public GestoreEventi() {
        this.eventi = new ArrayList<>();
    }

    public void configuraTimer() {
        // Classe anonima che implementa ActionListener
        Timer timer = new Timer(1000, new ActionListener() {
            private int contatore = 0;

            @Override
            public void actionPerformed(ActionEvent e) {
                contatore++;
                String evento = "Timer tick #" + contatore + " alle " + LocalTime.now();
                eventi.add(evento); // Accesso alla variabile della classe esterna
                System.out.println(evento);

                if (contatore >= 5) {
                    ((Timer) e.getSource()).stop();
                    System.out.println("Timer fermato");
                }
            }
        });

        timer.start();
    }

    public void ordinaEventi() {
        // Classe anonima per Comparator personalizzato
        Collections.sort(eventi, new Comparator<String>() {
            @Override
            public int compare(String evento1, String evento2) {
                // Logica di ordinamento personalizzata
                return evento1.length() - evento2.length();
            }
        });
    }

    // Alternativa moderna con lambda expressions
    public void ordinaEventiConLambda() {
        eventi.sort((e1, e2) -> e1.length() - e2.length());
    }
}

Pattern di Utilizzo Avanzati

Iterator Pattern con Inner Classes

Le inner classes sono perfette per implementare il pattern Iterator, fornendo un accesso controllato alle strutture dati interne:

public class ListaPersonalizzata<T> {
    private Object[] elementi;
    private int dimensione;
    private static final int CAPACITA_INIZIALE = 10;

    public ListaPersonalizzata() {
        this.elementi = new Object[CAPACITA_INIZIALE];
        this.dimensione = 0;
    }

    public void aggiungi(T elemento) {
        if (dimensione >= elementi.length) {
            ridimensiona();
        }
        elementi[dimensione++] = elemento;
    }

    private void ridimensiona() {
        elementi = Arrays.copyOf(elementi, elementi.length * 2);
    }

    // Inner class per l'iterator
    public class IteratoreLista implements Iterator<T> {
        private int indiceCorrente = 0;
        private int dimensioneAlMomentoDellaCreazione = dimensione;

        @Override
        public boolean hasNext() {
            // Controllo per modifiche concorrenti
            if (dimensioneAlMomentoDellaCreazione != dimensione) {
                throw new ConcurrentModificationException("Lista modificata durante iterazione");
            }
            return indiceCorrente < dimensione;
        }

        @Override
        @SuppressWarnings("unchecked")
        public T next() {
            if (!hasNext()) {
                throw new NoSuchElementException();
            }
            return (T) elementi[indiceCorrente++];
        }

        @Override
        public void remove() {
            if (indiceCorrente == 0) {
                throw new IllegalStateException("next() non è stato chiamato");
            }

            ListaPersonalizzata.this.rimuoviIndice(indiceCorrente - 1);
            indiceCorrente--;
            dimensioneAlMomentoDellaCreazione--;
        }
    }

    public Iterator<T> iterator() {
        return new IteratoreLista();
    }

    private void rimuoviIndice(int indice) {
        System.arraycopy(elementi, indice + 1, elementi, indice, dimensione - indice - 1);
        elementi[--dimensione] = null;
    }
}

Builder Pattern con Nested Classes

Le classi annidate statiche sono ideali per implementare il Builder pattern:

public class Computer {
    private final String processore;
    private final String ram;
    private final String storage;
    private final boolean hasBluetooth;
    private final boolean hasWifi;
    private final String sistemaOperativo;

    private Computer(Builder builder) {
        this.processore = builder.processore;
        this.ram = builder.ram;
        this.storage = builder.storage;
        this.hasBluetooth = builder.hasBluetooth;
        this.hasWifi = builder.hasWifi;
        this.sistemaOperativo = builder.sistemaOperativo;
    }

    // Nested class statica per il builder
    public static class Builder {
        // Parametri obbligatori
        private final String processore;
        private final String ram;

        // Parametri opzionali con valori di default
        private String storage = "256GB SSD";
        private boolean hasBluetooth = false;
        private boolean hasWifi = true;
        private String sistemaOperativo = "Windows 11";

        public Builder(String processore, String ram) {
            this.processore = processore;
            this.ram = ram;
        }

        public Builder storage(String storage) {
            this.storage = storage;
            return this;
        }

        public Builder bluetooth(boolean hasBluetooth) {
            this.hasBluetooth = hasBluetooth;
            return this;
        }

        public Builder wifi(boolean hasWifi) {
            this.hasWifi = hasWifi;
            return this;
        }

        public Builder sistemaOperativo(String so) {
            this.sistemaOperativo = so;
            return this;
        }

        public Computer build() {
            return new Computer(this);
        }
    }

    @Override
    public String toString() {
        return String.format("Computer{processore='%s', ram='%s', storage='%s', bluetooth=%s, wifi=%s, so='%s'}",
                processore, ram, storage, hasBluetooth, hasWifi, sistemaOperativo);
    }
}

// Utilizzo del builder
Computer computer = new Computer.Builder("Intel i7", "16GB")
    .storage("1TB SSD")
    .bluetooth(true)
    .sistemaOperativo("Ubuntu 22.04")
    .build();

Considerazioni su Memory e Performance

Memory Leaks con Inner Classes

Le inner classes mantengono automaticamente un riferimento all’istanza della classe esterna, il che può causare memory leak se non gestito appropriatamente:

public class ContainerProblematico {
    private byte[] grandeArray = new byte[1024 * 1024]; // 1MB

    public Runnable creaTask() {
        // Questa inner class mantiene un riferimento a ContainerProblematico
        return new Runnable() {
            @Override
            public void run() {
                System.out.println("Task in esecuzione");
                // Anche se non usiamo grandeArray, rimane in memoria
            }
        };
    }
}

public class ContainerOttimizzato {
    private byte[] grandeArray = new byte[1024 * 1024];

    public Runnable creaTask() {
        // Classe statica non mantiene riferimenti alla classe esterna
        return new TaskStatico();
    }

    private static class TaskStatico implements Runnable {
        @Override
        public void run() {
            System.out.println("Task in esecuzione");
            // Nessun riferimento implicito alla classe esterna
        }
    }
}

Performance Implications

L’accesso ai membri della classe esterna attraverso inner classes ha un piccolo overhead rispetto all’accesso diretto, poiché il compilatore genera metodi sintetici per aggirare le restrizioni di accesso:

public class AnalisiPerformance {
    private int valoreSegreto = 42;

    public class InnerClassConAccesso {
        public int leggiValore() {
            return valoreSegreto; // Il compilatore genera un metodo sintetico
        }
    }

    public static class NestedClassSenzaAccesso {
        public int leggiValore(AnalisiPerformance istanza) {
            return istanza.valoreSegreto; // Accesso diretto, ma richiede parametro
        }
    }
}

Best Practices e Linee Guida

Preferisci Classi Annidate Statiche: Usa classi annidate statiche quando non hai bisogno di accedere alle istanze della classe esterna, per evitare reference nascoste e potenziali memory leak.

Limita la Complessità: Non annidare classi troppo profondamente; preferisci al massimo due livelli di nesting per mantenere la leggibilità.

Usa Nomi Descrittivi: Anche se le classi annidate sono nel namespace della classe esterna, usa nomi che chiariscano il loro scopo.

Considera le Alternative Moderne: Con Java 8+, molti casi d’uso delle classi anonime possono essere sostituiti con lambda expressions più concise.

Documentazione Esplicita: Documenta chiaramente la relazione tra la classe esterna e quella annidata, specialmente se ci sono dipendenze complesse.

Controllo dell’Accesso: Usa l’incapsulamento appropriato; non tutte le classi annidate devono essere pubbliche.

Conclusione

Le classi annidate offrono un meccanismo potente per organizzare il codice e implementare pattern complessi mantenendo un forte incapsulamento. La scelta tra i diversi tipi dipende dalle necessità specifiche di accesso ai dati e dal ciclo di vita degli oggetti.

Quando utilizzate appropriatamente, le classi annidate migliorano la coesione del codice e permettono di esprimere relazioni logiche complesse in modo chiaro e manutenibile. La chiave è comprendere le implicazioni di ogni tipo e scegliere quello più appropriato per il contesto specifico.